diff --git a/.github/benchmark-configs.json b/.github/benchmark-configs.json index d7c6578c8bc6f..0fdce0d5c2e0c 100644 --- a/.github/benchmark-configs.json +++ b/.github/benchmark-configs.json @@ -41,7 +41,7 @@ "SINGLE_NODE_CLUSTER": "true", "MIN_DISTRIBUTION": "true", "TEST_WORKLOAD": "big5", - "WORKLOAD_PARAMS": "{\"snapshot_repo_name\":\"benchmark-workloads-repo-3x\",\"snapshot_bucket_name\":\"benchmark-workload-snapshots\",\"snapshot_region\":\"us-east-1\",\"snapshot_base_path\":\"10.3.2\",\"snapshot_name\":\"big5_1_shard_single_client\"}", + "WORKLOAD_PARAMS": "{\"snapshot_repo_name\":\"benchmark-workloads-repo-3x\",\"snapshot_bucket_name\":\"benchmark-workload-snapshots\",\"snapshot_region\":\"us-east-1\",\"snapshot_base_path\":\"10.5.0\",\"snapshot_name\":\"big5_1_shard_single_client\"}", "CAPTURE_NODE_STAT": "true", "TEST_PROCEDURE": "restore-from-snapshot" }, @@ -91,7 +91,7 @@ "MIN_DISTRIBUTION": "true", "TEST_WORKLOAD": "big5", "ADDITIONAL_CONFIG": "search.concurrent_segment_search.enabled:true", - "WORKLOAD_PARAMS": "{\"snapshot_repo_name\":\"benchmark-workloads-repo-3x\",\"snapshot_bucket_name\":\"benchmark-workload-snapshots\",\"snapshot_region\":\"us-east-1\",\"snapshot_base_path\":\"10.3.2\",\"snapshot_name\":\"big5_1_shard_single_client\"}", + "WORKLOAD_PARAMS": "{\"snapshot_repo_name\":\"benchmark-workloads-repo-3x\",\"snapshot_bucket_name\":\"benchmark-workload-snapshots\",\"snapshot_region\":\"us-east-1\",\"snapshot_base_path\":\"10.5.0\",\"snapshot_name\":\"big5_1_shard_single_client\"}", "CAPTURE_NODE_STAT": "true", "TEST_PROCEDURE": "restore-from-snapshot" }, @@ -109,7 +109,7 @@ "MIN_DISTRIBUTION": "true", "TEST_WORKLOAD": "big5", "ADDITIONAL_CONFIG": "search.concurrent_segment_search.mode:all", - "WORKLOAD_PARAMS": "{\"snapshot_repo_name\":\"benchmark-workloads-repo-3x\",\"snapshot_bucket_name\":\"benchmark-workload-snapshots\",\"snapshot_region\":\"us-east-1\",\"snapshot_base_path\":\"10.3.2\",\"snapshot_name\":\"big5_1_shard_single_client\"}", + "WORKLOAD_PARAMS": "{\"snapshot_repo_name\":\"benchmark-workloads-repo-3x\",\"snapshot_bucket_name\":\"benchmark-workload-snapshots\",\"snapshot_region\":\"us-east-1\",\"snapshot_base_path\":\"10.5.0\",\"snapshot_name\":\"big5_1_shard_single_client\"}", "CAPTURE_NODE_STAT": "true", "TEST_PROCEDURE": "restore-from-snapshot" }, @@ -127,7 +127,7 @@ "MIN_DISTRIBUTION": "true", "TEST_WORKLOAD": "big5", "ADDITIONAL_CONFIG": "search.concurrent_segment_search.mode:auto", - "WORKLOAD_PARAMS": "{\"snapshot_repo_name\":\"benchmark-workloads-repo-3x\",\"snapshot_bucket_name\":\"benchmark-workload-snapshots\",\"snapshot_region\":\"us-east-1\",\"snapshot_base_path\":\"10.3.2\",\"snapshot_name\":\"big5_1_shard_single_client\"}", + "WORKLOAD_PARAMS": "{\"snapshot_repo_name\":\"benchmark-workloads-repo-3x\",\"snapshot_bucket_name\":\"benchmark-workload-snapshots\",\"snapshot_region\":\"us-east-1\",\"snapshot_base_path\":\"10.5.0\",\"snapshot_name\":\"big5_1_shard_single_client\"}", "CAPTURE_NODE_STAT": "true", "TEST_PROCEDURE": "restore-from-snapshot" }, @@ -145,7 +145,7 @@ "MIN_DISTRIBUTION": "true", "TEST_WORKLOAD": "big5", "ADDITIONAL_CONFIG": "opensearch.experimental.feature.approximate_point_range_query.enabled:true", - "WORKLOAD_PARAMS": "{\"snapshot_repo_name\":\"benchmark-workloads-repo-3x\",\"snapshot_bucket_name\":\"benchmark-workload-snapshots\",\"snapshot_region\":\"us-east-1\",\"snapshot_base_path\":\"10.3.2\",\"snapshot_name\":\"big5_1_shard_single_client\"}", + "WORKLOAD_PARAMS": "{\"snapshot_repo_name\":\"benchmark-workloads-repo-3x\",\"snapshot_bucket_name\":\"benchmark-workload-snapshots\",\"snapshot_region\":\"us-east-1\",\"snapshot_base_path\":\"10.5.0\",\"snapshot_name\":\"big5_1_shard_single_client\"}", "CAPTURE_NODE_STAT": "true", "TEST_PROCEDURE": "restore-from-snapshot" }, @@ -178,7 +178,7 @@ "SINGLE_NODE_CLUSTER": "true", "MIN_DISTRIBUTION": "true", "TEST_WORKLOAD": "http_logs", - "WORKLOAD_PARAMS": "{\"snapshot_repo_name\":\"benchmark-workloads-repo-3x\",\"snapshot_bucket_name\":\"benchmark-workload-snapshots\",\"snapshot_region\":\"us-east-1\",\"snapshot_base_path\":\"10.3.1\",\"snapshot_name\":\"http_logs_1_shard\"}", + "WORKLOAD_PARAMS": "{\"snapshot_repo_name\":\"benchmark-workloads-repo-3x\",\"snapshot_bucket_name\":\"benchmark-workload-snapshots\",\"snapshot_region\":\"us-east-1\",\"snapshot_base_path\":\"10.5.0\",\"snapshot_name\":\"http_logs_1_shard\"}", "CAPTURE_NODE_STAT": "true", "TEST_PROCEDURE": "restore-from-snapshot" }, @@ -195,7 +195,7 @@ "SINGLE_NODE_CLUSTER": "true", "MIN_DISTRIBUTION": "true", "TEST_WORKLOAD": "nyc_taxis", - "WORKLOAD_PARAMS": "{\"snapshot_repo_name\":\"benchmark-workloads-repo-3x\",\"snapshot_bucket_name\":\"benchmark-workload-snapshots\",\"snapshot_region\":\"us-east-1\",\"snapshot_base_path\":\"10.3.2\",\"snapshot_name\":\"nyc_taxis_1_shard\"}", + "WORKLOAD_PARAMS": "{\"snapshot_repo_name\":\"benchmark-workloads-repo-3x\",\"snapshot_bucket_name\":\"benchmark-workload-snapshots\",\"snapshot_region\":\"us-east-1\",\"snapshot_base_path\":\"10.5.0\",\"snapshot_name\":\"nyc_taxis_1_shard\"}", "CAPTURE_NODE_STAT": "true", "TEST_PROCEDURE": "restore-from-snapshot" }, @@ -231,7 +231,7 @@ "MIN_DISTRIBUTION": "true", "TEST_WORKLOAD": "http_logs", "ADDITIONAL_CONFIG": "search.concurrent_segment_search.mode:auto", - "WORKLOAD_PARAMS": "{\"snapshot_repo_name\":\"benchmark-workloads-repo-3x\",\"snapshot_bucket_name\":\"benchmark-workload-snapshots\",\"snapshot_region\":\"us-east-1\",\"snapshot_base_path\":\"10.3.2\",\"snapshot_name\":\"http_logs_1_shard\"}", + "WORKLOAD_PARAMS": "{\"snapshot_repo_name\":\"benchmark-workloads-repo-3x\",\"snapshot_bucket_name\":\"benchmark-workload-snapshots\",\"snapshot_region\":\"us-east-1\",\"snapshot_base_path\":\"10.5.0\",\"snapshot_name\":\"http_logs_1_shard\"}", "CAPTURE_NODE_STAT": "true", "TEST_PROCEDURE": "intra-segment" }, @@ -267,7 +267,7 @@ "MIN_DISTRIBUTION": "true", "DATA_INSTANCE_TYPE": "c5.2xlarge", "TEST_WORKLOAD": "clickbench", - "WORKLOAD_PARAMS": "{\"snapshot_repo_name\":\"benchmark-workloads-repo-3x\",\"snapshot_bucket_name\":\"benchmark-workload-snapshots\",\"snapshot_region\":\"us-east-1\",\"snapshot_base_path\":\"10.3.2\",\"snapshot_name\":\"clickbench_3_shards\",\"warmup_iterations\":10,\"test_iterations\":20}", + "WORKLOAD_PARAMS": "{\"snapshot_repo_name\":\"benchmark-workloads-repo-3x\",\"snapshot_bucket_name\":\"benchmark-workload-snapshots\",\"snapshot_region\":\"us-east-1\",\"snapshot_base_path\":\"10.5.0\",\"snapshot_name\":\"clickbench_3_shards\",\"warmup_iterations\":10,\"test_iterations\":20}", "CAPTURE_NODE_STAT": "true", "TEST_PROCEDURE": "dsl-clickbench-snapshot" }, diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index a9d75f0f7b4c1..432691c117344 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -9,21 +9,14 @@ jobs: build: runs-on: ubuntu-latest + if: github.repository == 'opensearch-project/OpenSearch' permissions: contents: write steps: - - name: GitHub App token - id: github_app_token - uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0 - with: - app_id: ${{ secrets.APP_ID }} - private_key: ${{ secrets.APP_PRIVATE_KEY }} - installation_id: 22958780 - name: Get tag id: tag uses: dawidd6/action-get-tag@727a6f0a561be04e09013531e73a3983a65e3479 # v1 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1 with: - github_token: ${{ steps.github_app_token.outputs.token }} bodyFile: release-notes/opensearch.release-notes-${{steps.tag.outputs.tag}}.md diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index 6c747ee8e5ce9..c3baf26c436d3 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -1,40 +1,12 @@ +--- name: Backport on: pull_request_target: - types: - - closed - - labeled + types: [closed, labeled] jobs: backport: - name: Backport - runs-on: ubuntu-latest - # Only react to merged PRs for security reasons. - # See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target. - if: > - github.event.pull_request.merged - && ( - github.event.action == 'closed' - || ( - github.event.action == 'labeled' - && contains(github.event.label.name, 'backport') - ) - ) - permissions: - contents: write - pull-requests: write - steps: - - name: GitHub App token - id: github_app_token - uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0 - with: - app_id: ${{ secrets.APP_ID }} - private_key: ${{ secrets.APP_PRIVATE_KEY }} - installation_id: 22958780 - - - name: Backport - uses: VachaShah/backport@142d3b8a8c70dc54db515e653e5ed3c3fac64100 # v2.2.0 - with: - github_token: ${{ steps.github_app_token.outputs.token }} - head_template: backport/backport-<%= number %>-to-<%= base %> - failure_labels: backport-failed + if: github.repository == 'opensearch-project/OpenSearch' + uses: opensearch-project/opensearch-build/.github/workflows/backport-pr.yml@58f2e5108a5164e37af78f5d8de5d825825023a9 + secrets: + OPENSEARCH_CI_BOT_TOKEN: ${{ secrets.OPENSEARCH_CI_BOT_TOKEN }} diff --git a/.github/workflows/delete_backport_branch.yml b/.github/workflows/delete_backport_branch.yml deleted file mode 100644 index 2cceadeb1f1e0..0000000000000 --- a/.github/workflows/delete_backport_branch.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Delete merged branch of the backport PRs -on: - pull_request: - types: - - closed - -jobs: - delete-branch: - runs-on: ubuntu-latest - permissions: - contents: write - if: github.repository == 'opensearch-project/OpenSearch' && (startsWith(github.event.pull_request.head.ref,'backport/') || startsWith(github.event.pull_request.head.ref,'release-chores/')) - steps: - - name: Delete merged branch - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - github.rest.git.deleteRef({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: `heads/${context.payload.pull_request.head.ref}`, - }) diff --git a/.github/workflows/sandbox-check.yml b/.github/workflows/sandbox-check.yml index edafd1ccbcded..28f6a4e8ab63c 100644 --- a/.github/workflows/sandbox-check.yml +++ b/.github/workflows/sandbox-check.yml @@ -34,6 +34,10 @@ jobs: cache: gradle - name: Set up Rust uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + components: rustfmt + - name: Check Rust formatting + run: cargo fmt --all --manifest-path sandbox/libs/dataformat-native/rust/Cargo.toml -- --check - name: Install protobuf compiler run: sudo apt-get update && sudo apt-get install -y protobuf-compiler - name: Cache Cargo registry diff --git a/buildSrc/src/main/resources/forbidden/jdk-signatures.txt b/buildSrc/src/main/resources/forbidden/jdk-signatures.txt index b2fd479dce5ff..d63982d70c516 100644 --- a/buildSrc/src/main/resources/forbidden/jdk-signatures.txt +++ b/buildSrc/src/main/resources/forbidden/jdk-signatures.txt @@ -86,11 +86,6 @@ java.lang.reflect.AccessibleObject#setAccessible(java.lang.reflect.AccessibleObj @defaultMessage this method needs special permission java.lang.Thread#getAllStackTraces() -@defaultMessage Stopping threads explicitly leads to inconsistent states. Use interrupt() instead. -java.lang.Thread#stop() -# uncomment when https://github.com/elastic/elasticsearch/issues/31715 is fixed -# java.lang.Thread#stop(java.lang.Throwable) - @defaultMessage Please do not terminate the application java.lang.System#exit(int) java.lang.Runtime#exit(int) diff --git a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostIntegTests.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostIntegTests.java index b76081bd684e4..7a9734b6162e1 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostIntegTests.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostIntegTests.java @@ -279,6 +279,10 @@ public void testHeaders() throws Exception { Arrays.asList("Connection", "Host", "User-agent", "Date", "Upgrade", "HTTP2-Settings", "Content-Length") ); + if (method.equals("HEAD") && Runtime.version().feature() > 25 /* https://bugs.openjdk.org/browse/JDK-8369981 */) { + standardHeaders.remove("Content-Length"); + } + final Map> requestHeaders = RestClientTestUtil.randomHeaders(getRandom(), "Header"); final int statusCode = RestClientTestUtil.randomStatusCode(getRandom()); Request request = Request.newRequest(method, "/" + statusCode) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2b524e1385629..7ad3cda2e8588 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] opensearch = "3.8.0" -lucene = "10.4.0" +lucene = "10.5.0" bundled_jdk_vendor = "adoptium" bundled_jdk = "25.0.3+9" @@ -64,7 +64,7 @@ commonscollections4 = "4.5.0" aws = "2.32.29" awscrt = "0.35.0" reactivestreams = "1.0.4" -hadoop3 = "3.4.2" +hadoop3 = "3.5.0" # when updating this version, you need to ensure compatibility with: # - plugins/ingest-attachment (transitive dependency, check the upstream POM) diff --git a/libs/core/licenses/lucene-core-10.4.0.jar.sha1 b/libs/core/licenses/lucene-core-10.4.0.jar.sha1 deleted file mode 100644 index 89d6695f37459..0000000000000 --- a/libs/core/licenses/lucene-core-10.4.0.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -7493bc763cd5e91f2a8f7722c2f90d8ce15c6319 \ No newline at end of file diff --git a/libs/core/licenses/lucene-core-10.5.0.jar.sha1 b/libs/core/licenses/lucene-core-10.5.0.jar.sha1 new file mode 100644 index 0000000000000..c283fe8a56b45 --- /dev/null +++ b/libs/core/licenses/lucene-core-10.5.0.jar.sha1 @@ -0,0 +1 @@ +383deeee9b44ef15daaf7541d57ea5183d52a7b5 \ No newline at end of file diff --git a/libs/core/src/main/java/org/opensearch/Version.java b/libs/core/src/main/java/org/opensearch/Version.java index 475cc15c1cb4b..6efa888434817 100644 --- a/libs/core/src/main/java/org/opensearch/Version.java +++ b/libs/core/src/main/java/org/opensearch/Version.java @@ -173,7 +173,7 @@ private static String legacyFriendlyIdToString(int versionId) { public static final Version V_3_6_1 = new Version(3060199, org.apache.lucene.util.Version.LUCENE_10_4_0); public static final Version V_3_7_0 = new Version(3070099, org.apache.lucene.util.Version.LUCENE_10_4_0); public static final Version V_3_7_1 = new Version(3070199, org.apache.lucene.util.Version.LUCENE_10_4_0); - public static final Version V_3_8_0 = new Version(3080099, org.apache.lucene.util.Version.LUCENE_10_4_0); + public static final Version V_3_8_0 = new Version(3080099, org.apache.lucene.util.Version.LUCENE_10_5_0); public static final Version CURRENT = V_3_8_0; protected static final Map idToVersion; diff --git a/libs/netty4/licenses/lucene-core-10.4.0.jar.sha1 b/libs/netty4/licenses/lucene-core-10.4.0.jar.sha1 deleted file mode 100644 index 89d6695f37459..0000000000000 --- a/libs/netty4/licenses/lucene-core-10.4.0.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -7493bc763cd5e91f2a8f7722c2f90d8ce15c6319 \ No newline at end of file diff --git a/libs/netty4/licenses/lucene-core-10.5.0.jar.sha1 b/libs/netty4/licenses/lucene-core-10.5.0.jar.sha1 new file mode 100644 index 0000000000000..c283fe8a56b45 --- /dev/null +++ b/libs/netty4/licenses/lucene-core-10.5.0.jar.sha1 @@ -0,0 +1 @@ +383deeee9b44ef15daaf7541d57ea5183d52a7b5 \ No newline at end of file diff --git a/modules/lang-expression/licenses/lucene-expressions-10.4.0.jar.sha1 b/modules/lang-expression/licenses/lucene-expressions-10.4.0.jar.sha1 deleted file mode 100644 index 4206f09667e5f..0000000000000 --- a/modules/lang-expression/licenses/lucene-expressions-10.4.0.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -e50bb1b8f468887a315c456b75f8375ddba5559e \ No newline at end of file diff --git a/modules/lang-expression/licenses/lucene-expressions-10.5.0.jar.sha1 b/modules/lang-expression/licenses/lucene-expressions-10.5.0.jar.sha1 new file mode 100644 index 0000000000000..0b104cd0ce388 --- /dev/null +++ b/modules/lang-expression/licenses/lucene-expressions-10.5.0.jar.sha1 @@ -0,0 +1 @@ +a1820135f8c0f51a6635905d754705472592a008 \ No newline at end of file diff --git a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/document/bulk/BulkRequestParserProtoUtils.java b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/document/bulk/BulkRequestParserProtoUtils.java index 57e021a8498e3..d708ef23104a2 100644 --- a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/document/bulk/BulkRequestParserProtoUtils.java +++ b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/document/bulk/BulkRequestParserProtoUtils.java @@ -23,6 +23,7 @@ import org.opensearch.core.xcontent.MediaTypeRegistry; import org.opensearch.core.xcontent.XContentParser; import org.opensearch.index.VersionType; +import org.opensearch.index.mapper.extrasource.ExtraFieldValues; import org.opensearch.index.seqno.SequenceNumbers; import org.opensearch.protobufs.BulkRequest; import org.opensearch.protobufs.BulkRequestBody; @@ -96,7 +97,7 @@ private static Boolean valueOrDefault(Boolean value, Boolean globalDefault) { * @param byteString The protobuf ByteString to convert * @return A BytesReference wrapping the ByteString data */ - private static BytesReference byteStringToBytesReference(ByteString byteString) { + static BytesReference byteStringToBytesReference(ByteString byteString) { if (byteString == null || byteString.isEmpty()) { return BytesArray.EMPTY; } @@ -162,12 +163,14 @@ public static DocWriteRequest[] getDocWriteRequests( String pipeline = valueOrDefault(defaultPipeline, request.getPipeline()); Boolean requireAlias = valueOrDefault(defaultRequireAlias, request.getRequireAlias()); + ExtraFieldValues extraFieldValues = ExtraFieldValuesProtoUtils.fromProto(bulkRequestBodyEntry); OperationContainer operationContainer = bulkRequestBodyEntry.getOperationContainer(); switch (operationContainer.getOperationContainerCase()) { case CREATE: docWriteRequest = buildCreateRequest( operationContainer.getCreate(), bulkRequestBodyEntry.getObject(), + extraFieldValues, index, id, routing, @@ -183,6 +186,7 @@ public static DocWriteRequest[] getDocWriteRequests( docWriteRequest = buildIndexRequest( operationContainer.getIndex(), bulkRequestBodyEntry.getObject(), + extraFieldValues, opType, index, id, @@ -209,6 +213,7 @@ public static DocWriteRequest[] getDocWriteRequests( docWriteRequest = buildUpdateRequest( operationContainer.getUpdate(), updateDocBytes, + extraFieldValues, bulkRequestBodyEntry, index, id, @@ -222,6 +227,9 @@ public static DocWriteRequest[] getDocWriteRequests( ); break; case DELETE: + if (extraFieldValues.isEmpty() == false) { + throw new IllegalArgumentException("extra_field_values are not supported for delete operations"); + } docWriteRequest = buildDeleteRequest( operationContainer.getDelete(), index, @@ -250,6 +258,7 @@ public static DocWriteRequest[] getDocWriteRequests( * * @param createOperation The create operation protobuf message * @param documentBytes The document content as ByteString (zero-copy reference) + * @param extraFieldValues The extra field values to index outside {@code _source} * @param index The default index name * @param id The default document ID * @param routing The default routing value @@ -264,6 +273,7 @@ public static DocWriteRequest[] getDocWriteRequests( public static IndexRequest buildCreateRequest( WriteOperation createOperation, ByteString documentBytes, + ExtraFieldValues extraFieldValues, String index, String id, String routing, @@ -294,6 +304,7 @@ public static IndexRequest buildCreateRequest( .setIfSeqNo(ifSeqNo) .setIfPrimaryTerm(ifPrimaryTerm) .source(documentRef, mediaType) + .extraFieldValues(extraFieldValues) .setRequireAlias(requireAlias); return indexRequest; } @@ -303,6 +314,7 @@ public static IndexRequest buildCreateRequest( * * @param indexOperation The index operation protobuf message * @param documentBytes The document content as ByteString (zero-copy reference) + * @param extraFieldValues The extra field values to index outside {@code _source} * @param opType The default operation type * @param index The default index name * @param id The default document ID @@ -318,6 +330,7 @@ public static IndexRequest buildCreateRequest( public static IndexRequest buildIndexRequest( IndexOperation indexOperation, ByteString documentBytes, + ExtraFieldValues extraFieldValues, OpType opType, String index, String id, @@ -358,6 +371,7 @@ public static IndexRequest buildIndexRequest( .setIfSeqNo(ifSeqNo) .setIfPrimaryTerm(ifPrimaryTerm) .source(documentRef, mediaType) + .extraFieldValues(extraFieldValues) .setRequireAlias(requireAlias); } else { indexRequest = new IndexRequest(index).id(id) @@ -369,6 +383,7 @@ public static IndexRequest buildIndexRequest( .setIfSeqNo(ifSeqNo) .setIfPrimaryTerm(ifPrimaryTerm) .source(documentRef, mediaType) + .extraFieldValues(extraFieldValues) .setRequireAlias(requireAlias); } return indexRequest; @@ -379,6 +394,7 @@ public static IndexRequest buildIndexRequest( * * @param updateOperation The update operation protobuf message * @param documentBytes The document content as ByteString (zero-copy reference) + * @param extraFieldValues The extra field values to apply to update doc/upsert sources * @param bulkRequestBody The bulk request body containing additional update options * @param index The default index name * @param id The default document ID @@ -394,6 +410,7 @@ public static IndexRequest buildIndexRequest( public static UpdateRequest buildUpdateRequest( UpdateOperation updateOperation, ByteString documentBytes, + ExtraFieldValues extraFieldValues, BulkRequestBody bulkRequestBody, String index, String id, @@ -428,6 +445,7 @@ public static UpdateRequest buildUpdateRequest( // Populate all document-level fields updateRequest = fromProto(updateRequest, documentBytes, bulkRequestBody, ifSeqNo, ifPrimaryTerm); + applyUpdateExtraFieldValues(updateRequest, extraFieldValues); // Apply fetchSourceContext default if (fetchSourceContext != null) { @@ -443,6 +461,27 @@ public static UpdateRequest buildUpdateRequest( return updateRequest; } + private static void applyUpdateExtraFieldValues(UpdateRequest updateRequest, ExtraFieldValues extraFieldValues) { + if (extraFieldValues == null || extraFieldValues.isEmpty()) { + return; + } + + // Bulk gRPC has one extra_field_values map for the update item, so it applies to every concrete indexing path + // represented in the UpdateRequest. UpdateHelper later executes only the doc path or the upsert path. + boolean applied = false; + if (updateRequest.doc() != null) { + updateRequest.docExtraFieldValues(extraFieldValues); + applied = true; + } + if (updateRequest.upsertRequest() != null) { + updateRequest.upsertExtraFieldValues(extraFieldValues); + applied = true; + } + if (applied == false) { + throw new IllegalArgumentException("extra_field_values require an update doc or upsert document"); + } + } + /** * Populates an UpdateRequest with values from protobuf messages. * Equivalent to {@link UpdateRequest#fromXContent(XContentParser)} for REST API. diff --git a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/document/bulk/ExtraFieldValuesProtoUtils.java b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/document/bulk/ExtraFieldValuesProtoUtils.java new file mode 100644 index 0000000000000..dd1f541dfe69b --- /dev/null +++ b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/document/bulk/ExtraFieldValuesProtoUtils.java @@ -0,0 +1,112 @@ +/* + * 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.transport.grpc.proto.request.document.bulk; + +import com.google.protobuf.ByteString; +import org.opensearch.index.mapper.extrasource.BytesValue; +import org.opensearch.index.mapper.extrasource.ExtraFieldValue; +import org.opensearch.index.mapper.extrasource.ExtraFieldValues; +import org.opensearch.index.mapper.extrasource.FloatArrayValue; +import org.opensearch.protobufs.BinaryFieldValue; +import org.opensearch.protobufs.BulkRequestBody; +import org.opensearch.protobufs.FloatList; + +import java.util.HashMap; +import java.util.Map; + +/** + * Converts protobuf extra field values into OpenSearch extra source values. + */ +final class ExtraFieldValuesProtoUtils { + + private ExtraFieldValuesProtoUtils() {} + + static ExtraFieldValues fromProto(BulkRequestBody body) { + Map m = body.getExtraFieldValuesMap(); + if (m.isEmpty()) { + return ExtraFieldValues.EMPTY; + } + + Map out = new HashMap<>(Math.max(16, m.size() * 2)); + for (Map.Entry e : m.entrySet()) { + try { + out.put(e.getKey(), toExtraFieldValue(e.getValue())); + } catch (IllegalArgumentException ex) { + throw new IllegalArgumentException("Invalid extra_field_values entry [" + e.getKey() + "]: " + ex.getMessage(), ex); + } + } + return new ExtraFieldValues(out); + } + + private static ExtraFieldValue toExtraFieldValue(BinaryFieldValue protoVal) { + switch (protoVal.getBinaryFieldValueCase()) { + case BYTES_VALUE: { + return new BytesValue(BulkRequestParserProtoUtils.byteStringToBytesReference(protoVal.getBytesValue().getBytes())); + } + case FLOAT_ARRAY_VALUE: { + return toInternalFloatArrayValue(protoVal.getFloatArrayValue()); + } + case BINARYFIELDVALUE_NOT_SET: + default: + throw new IllegalArgumentException("Unsupported/empty BinaryFieldValue: " + protoVal.getBinaryFieldValueCase()); + } + } + + private static FloatArrayValue toInternalFloatArrayValue(org.opensearch.protobufs.FloatArrayValue fav) { + switch (fav.getEncodingCase()) { + case BINARY_LE: { + final ByteString bs = fav.getBinaryLe().getBytesLe(); + int dim = resolvePackedDimension(bs, fav.getBinaryLe().getDimension(), Float.BYTES, "float"); + return FloatArrayValue.fromPackedBytes(BulkRequestParserProtoUtils.byteStringToBytesReference(bs), dim); + } + case VALUES: { + final FloatList fl = fav.getValues(); + final int count = fl.getValuesCount(); + final float[] arr = new float[count]; + // Important: Avoid boxing, protobuf uses primitive float list internally + for (int i = 0; i < count; i++) { + arr[i] = fl.getValues(i); + } + return FloatArrayValue.fromFloatArray(arr); + } + case ENCODING_NOT_SET: + default: + throw new IllegalArgumentException("FloatArrayValue.repr is not set"); + } + } + + private static int resolvePackedDimension(ByteString bytes, int dimension, int bytesPerElement, String valueType) { + if (dimension < 0) { + throw new IllegalArgumentException(valueType + " dimension must be >= 0 but was " + dimension); + } + + int byteLength = bytes.size(); + if (dimension == 0) { + if (byteLength % bytesPerElement != 0) { + throw new IllegalArgumentException( + valueType + " packed_le byte length must be multiple of " + bytesPerElement + " but was " + byteLength + ); + } + return byteLength / bytesPerElement; + } + + final int expectedByteLength; + try { + expectedByteLength = Math.multiplyExact(dimension, bytesPerElement); + } catch (ArithmeticException e) { + throw new IllegalArgumentException(valueType + " dimension too large: " + dimension, e); + } + if (byteLength != expectedByteLength) { + throw new IllegalArgumentException( + "Bad packed " + valueType + " length=" + byteLength + " expected=" + expectedByteLength + " (dim=" + dimension + ")" + ); + } + return dimension; + } +} diff --git a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/search/aggregation/bucket/terms/TermsAggregationBuilderConverter.java b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/search/aggregation/bucket/terms/TermsAggregationBuilderConverter.java index d23d1a460dc33..d80ee665ee4b1 100644 --- a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/search/aggregation/bucket/terms/TermsAggregationBuilderConverter.java +++ b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/search/aggregation/bucket/terms/TermsAggregationBuilderConverter.java @@ -78,7 +78,7 @@ public AggregationBuilder fromProto(String name, AggregationContainer container) // common fields for ValuesSourceAggregation ValuesSourceProtoFields valuesSourceFields = ValuesSourceProtoFields.builder() .field(terms.hasField() ? terms.getField() : null) - .missing(terms.hasMissing() ? FieldValueProtoUtils.fromProto(terms.getMissing()) : null) + .missing(terms.hasMissing() ? FieldValueProtoUtils.fromProto(terms.getMissing(), false) : null) .valueType(terms.hasValueType() ? terms.getValueType() : null) .format(terms.hasFormat() ? terms.getFormat() : null) .script(terms.hasScript() ? ScriptProtoUtils.parseFromProtoRequest(terms.getScript()) : null) diff --git a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/search/aggregation/metrics/MaxAggregationProtoUtils.java b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/search/aggregation/metrics/MaxAggregationProtoUtils.java index f2d18ca75d121..265f427d963f5 100644 --- a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/search/aggregation/metrics/MaxAggregationProtoUtils.java +++ b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/search/aggregation/metrics/MaxAggregationProtoUtils.java @@ -43,7 +43,7 @@ public static MaxAggregationBuilder fromProto(String name, MaxAggregation maxAgg ValuesSourceProtoFields fields = ValuesSourceProtoFields.builder() .field(maxAggProto.hasField() ? maxAggProto.getField() : null) - .missing(maxAggProto.hasMissing() ? FieldValueProtoUtils.fromProto(maxAggProto.getMissing()) : null) + .missing(maxAggProto.hasMissing() ? FieldValueProtoUtils.fromProto(maxAggProto.getMissing(), false) : null) .valueType(maxAggProto.hasValueType() ? maxAggProto.getValueType() : null) .format(maxAggProto.hasFormat() ? maxAggProto.getFormat() : null) .script(maxAggProto.hasScript() ? ScriptProtoUtils.parseFromProtoRequest(maxAggProto.getScript()) : null) diff --git a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/search/aggregation/metrics/MinAggregationProtoUtils.java b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/search/aggregation/metrics/MinAggregationProtoUtils.java index 0ad7afe39e5d0..58732f6a79fe0 100644 --- a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/search/aggregation/metrics/MinAggregationProtoUtils.java +++ b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/request/search/aggregation/metrics/MinAggregationProtoUtils.java @@ -43,7 +43,7 @@ public static MinAggregationBuilder fromProto(String name, MinAggregation minAgg ValuesSourceProtoFields fields = ValuesSourceProtoFields.builder() .field(minAggProto.hasField() ? minAggProto.getField() : null) - .missing(minAggProto.hasMissing() ? FieldValueProtoUtils.fromProto(minAggProto.getMissing()) : null) + .missing(minAggProto.hasMissing() ? FieldValueProtoUtils.fromProto(minAggProto.getMissing(), false) : null) .valueType(minAggProto.hasValueType() ? minAggProto.getValueType() : null) .format(minAggProto.hasFormat() ? minAggProto.getFormat() : null) .script(minAggProto.hasScript() ? ScriptProtoUtils.parseFromProtoRequest(minAggProto.getScript()) : null) diff --git a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/response/common/FieldValueProtoUtils.java b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/response/common/FieldValueProtoUtils.java index a8fe5fcc98a56..5b1a2e20ef89d 100644 --- a/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/response/common/FieldValueProtoUtils.java +++ b/modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/response/common/FieldValueProtoUtils.java @@ -9,6 +9,7 @@ import org.opensearch.common.Numbers; import org.opensearch.protobufs.FieldValue; +import org.opensearch.protobufs.NullValue; import java.math.BigInteger; @@ -51,11 +52,8 @@ public static FieldValue toProto(Object javaObject) { * @throws IllegalArgumentException if the Java object type cannot be converted */ public static void toProto(Object javaObject, FieldValue.Builder fieldValueBuilder) { - if (javaObject == null) { - throw new IllegalArgumentException("Cannot convert null to FieldValue"); - } - switch (javaObject) { + case null -> fieldValueBuilder.setNullValue(NullValue.NULL_VALUE_NULL); case String s -> fieldValueBuilder.setString(s); case Integer i -> { org.opensearch.protobufs.GeneralNumber.Builder num = org.opensearch.protobufs.GeneralNumber.newBuilder(); diff --git a/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/document/bulk/BulkRequestParserProtoUtilsTests.java b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/document/bulk/BulkRequestParserProtoUtilsTests.java index bc3cc7217591f..9909ce34583ae 100644 --- a/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/document/bulk/BulkRequestParserProtoUtilsTests.java +++ b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/document/bulk/BulkRequestParserProtoUtilsTests.java @@ -19,10 +19,17 @@ import org.opensearch.core.common.bytes.BytesReference; import org.opensearch.core.xcontent.MediaType; import org.opensearch.index.VersionType; +import org.opensearch.index.mapper.extrasource.BytesValue; +import org.opensearch.index.mapper.extrasource.ExtraFieldValue; +import org.opensearch.index.mapper.extrasource.ExtraFieldValues; +import org.opensearch.index.mapper.extrasource.FloatArrayValue; import org.opensearch.index.seqno.SequenceNumbers; +import org.opensearch.protobufs.BinaryFieldValue; import org.opensearch.protobufs.BulkRequest; import org.opensearch.protobufs.BulkRequestBody; import org.opensearch.protobufs.DeleteOperation; +import org.opensearch.protobufs.FloatBinaryLE; +import org.opensearch.protobufs.FloatList; import org.opensearch.protobufs.IndexOperation; import org.opensearch.protobufs.OpType; import org.opensearch.protobufs.OperationContainer; @@ -30,6 +37,8 @@ import org.opensearch.protobufs.WriteOperation; import org.opensearch.test.OpenSearchTestCase; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; import java.nio.charset.StandardCharsets; import static org.opensearch.index.seqno.SequenceNumbers.UNASSIGNED_PRIMARY_TERM; @@ -49,6 +58,7 @@ public void testBuildCreateRequest() { IndexRequest indexRequest = BulkRequestParserProtoUtils.buildCreateRequest( writeOperation, UnsafeByteOperations.unsafeWrap(document), + ExtraFieldValues.EMPTY, "default-index", "default-id", "default-routing", @@ -91,6 +101,7 @@ public void testBuildIndexRequest() { IndexRequest indexRequest = BulkRequestParserProtoUtils.buildIndexRequest( indexOperation, UnsafeByteOperations.unsafeWrap(document), + ExtraFieldValues.EMPTY, null, "default-index", "default-id", @@ -126,6 +137,7 @@ public void testBuildIndexRequestWithOpType() { IndexRequest indexRequest = BulkRequestParserProtoUtils.buildIndexRequest( indexOperation, UnsafeByteOperations.unsafeWrap(document), + ExtraFieldValues.EMPTY, opType, "default-index", "default-id", @@ -198,6 +210,7 @@ public void testBuildUpdateRequest() { UpdateRequest updateRequest = BulkRequestParserProtoUtils.buildUpdateRequest( updateOperation, UnsafeByteOperations.unsafeWrap(document), + ExtraFieldValues.EMPTY, bulkRequestBody, "default-index", "default-id", @@ -317,6 +330,7 @@ public void testBuildCreateRequestWithDefaults() { IndexRequest indexRequest = BulkRequestParserProtoUtils.buildCreateRequest( writeOperation, UnsafeByteOperations.unsafeWrap(document), + ExtraFieldValues.EMPTY, "default-index", "default-id", "default-routing", @@ -344,6 +358,7 @@ public void testBuildCreateRequestWithPipeline() { IndexRequest indexRequest = BulkRequestParserProtoUtils.buildCreateRequest( writeOperation, UnsafeByteOperations.unsafeWrap(document), + ExtraFieldValues.EMPTY, "default-index", "default-id", "default-routing", @@ -376,6 +391,7 @@ public void testBuildIndexRequestWithAllFields() { IndexRequest indexRequest = BulkRequestParserProtoUtils.buildIndexRequest( indexOperation, UnsafeByteOperations.unsafeWrap(document), + ExtraFieldValues.EMPTY, OpType.OP_TYPE_INDEX, "default-index", "default-id", @@ -409,6 +425,7 @@ public void testBuildIndexRequestWithNullOpType() { IndexRequest indexRequest = BulkRequestParserProtoUtils.buildIndexRequest( indexOperation, UnsafeByteOperations.unsafeWrap(document), + ExtraFieldValues.EMPTY, null, "default-index", "default-id", @@ -458,6 +475,7 @@ public void testBuildUpdateRequestWithScript() { UpdateRequest updateRequest = BulkRequestParserProtoUtils.buildUpdateRequest( updateOperation, UnsafeByteOperations.unsafeWrap(document), + ExtraFieldValues.EMPTY, bulkRequestBody, "default-index", "default-id", @@ -490,6 +508,7 @@ public void testBuildUpdateRequestWithUpsert() { UpdateRequest updateRequest = BulkRequestParserProtoUtils.buildUpdateRequest( updateOperation, UnsafeByteOperations.unsafeWrap(document), + ExtraFieldValues.EMPTY, bulkRequestBody, "default-index", "default-id", @@ -520,6 +539,7 @@ public void testBuildUpdateRequestWithScriptedUpsert() { UpdateRequest updateRequest = BulkRequestParserProtoUtils.buildUpdateRequest( updateOperation, UnsafeByteOperations.unsafeWrap(document), + ExtraFieldValues.EMPTY, bulkRequestBody, "default-index", "default-id", @@ -554,6 +574,7 @@ public void testBuildUpdateRequestWithFetchSource() { UpdateRequest updateRequest = BulkRequestParserProtoUtils.buildUpdateRequest( updateOperation, UnsafeByteOperations.unsafeWrap(document), + ExtraFieldValues.EMPTY, bulkRequestBody, "default-index", "default-id", @@ -583,6 +604,7 @@ public void testBuildUpdateRequestWithoutUpdateAction() { UpdateRequest updateRequest = BulkRequestParserProtoUtils.buildUpdateRequest( updateOperation, UnsafeByteOperations.unsafeWrap(document), + ExtraFieldValues.EMPTY, bulkRequestBody, "default-index", "default-id", @@ -736,6 +758,7 @@ public void testBuildCreateRequestWithSmileContent() throws Exception { IndexRequest indexRequest = BulkRequestParserProtoUtils.buildCreateRequest( writeOperation, UnsafeByteOperations.unsafeWrap(smileDocument), + ExtraFieldValues.EMPTY, "default-index", "default-id", null, @@ -764,6 +787,7 @@ public void testBuildCreateRequestWithCborContent() throws Exception { IndexRequest indexRequest = BulkRequestParserProtoUtils.buildCreateRequest( writeOperation, UnsafeByteOperations.unsafeWrap(cborDocument), + ExtraFieldValues.EMPTY, "default-index", "default-id", null, @@ -792,6 +816,7 @@ public void testBuildIndexRequestWithSmileContent() throws Exception { IndexRequest indexRequest = BulkRequestParserProtoUtils.buildIndexRequest( indexOperation, UnsafeByteOperations.unsafeWrap(smileDocument), + ExtraFieldValues.EMPTY, null, "default-index", "default-id", @@ -820,6 +845,7 @@ public void testBuildIndexRequestWithCborContent() throws Exception { IndexRequest indexRequest = BulkRequestParserProtoUtils.buildIndexRequest( indexOperation, UnsafeByteOperations.unsafeWrap(cborDocument), + ExtraFieldValues.EMPTY, null, "default-index", "default-id", @@ -872,6 +898,7 @@ public void testBuildCreateRequestWithEmptyDocument() { IndexRequest indexRequest = BulkRequestParserProtoUtils.buildCreateRequest( writeOperation, UnsafeByteOperations.unsafeWrap(emptyDocument), + ExtraFieldValues.EMPTY, "default-index", "default-id", null, @@ -898,6 +925,7 @@ public void testBuildCreateRequestWithJsonContent() throws Exception { IndexRequest indexRequest = BulkRequestParserProtoUtils.buildCreateRequest( writeOperation, UnsafeByteOperations.unsafeWrap(jsonDocument), + ExtraFieldValues.EMPTY, "default-index", "default-id", null, @@ -926,6 +954,7 @@ public void testBuildCreateRequestWithYamlContent() throws Exception { IndexRequest indexRequest = BulkRequestParserProtoUtils.buildCreateRequest( writeOperation, UnsafeByteOperations.unsafeWrap(yamlDocument), + ExtraFieldValues.EMPTY, "default-index", "default-id", null, @@ -954,6 +983,7 @@ public void testBuildIndexRequestWithJsonContent() throws Exception { IndexRequest indexRequest = BulkRequestParserProtoUtils.buildIndexRequest( indexOperation, UnsafeByteOperations.unsafeWrap(jsonDocument), + ExtraFieldValues.EMPTY, null, "default-index", "default-id", @@ -982,6 +1012,7 @@ public void testBuildIndexRequestWithYamlContent() throws Exception { IndexRequest indexRequest = BulkRequestParserProtoUtils.buildIndexRequest( indexOperation, UnsafeByteOperations.unsafeWrap(yamlDocument), + ExtraFieldValues.EMPTY, null, "default-index", "default-id", @@ -1072,6 +1103,7 @@ public void testBuildUpdateRequestWithUpsertAndPipeline() { UpdateRequest updateRequest = BulkRequestParserProtoUtils.buildUpdateRequest( updateOperation, UnsafeByteOperations.unsafeWrap(document), + ExtraFieldValues.EMPTY, bulkRequestBody, "default-index", "default-id", @@ -1273,6 +1305,7 @@ public void testByteStringToBytesReferenceZeroCopy() { IndexRequest indexRequest = BulkRequestParserProtoUtils.buildCreateRequest( createOp, byteString, + ExtraFieldValues.EMPTY, "default-index", "default-id", "default-routing", @@ -1303,6 +1336,7 @@ public void testByteStringToBytesReferenceCopy() { IndexRequest indexRequest = BulkRequestParserProtoUtils.buildCreateRequest( createOp, byteString, + ExtraFieldValues.EMPTY, "default-index", "default-id", "default-routing", @@ -1330,6 +1364,7 @@ public void testByteStringToBytesReferenceEmpty() { IndexRequest indexRequest = BulkRequestParserProtoUtils.buildCreateRequest( createOp, byteString, + ExtraFieldValues.EMPTY, "default-index", "default-id", "default-routing", @@ -1365,6 +1400,7 @@ public void testUpdateRequestDocFieldZeroCopy() { UpdateRequest updateRequest = BulkRequestParserProtoUtils.buildUpdateRequest( updateOp, docBytes, + ExtraFieldValues.EMPTY, bulkRequestBody, "default-index", "default-id", @@ -1404,6 +1440,7 @@ public void testUpdateRequestUpsertFieldZeroCopy() { UpdateRequest updateRequest = BulkRequestParserProtoUtils.buildUpdateRequest( updateOp, UnsafeByteOperations.unsafeWrap(docBytes), + ExtraFieldValues.EMPTY, bulkRequestBody, "default-index", "default-id", @@ -1447,6 +1484,7 @@ public void testUpdateRequestDocAndUpsertCopy() { UpdateRequest updateRequest = BulkRequestParserProtoUtils.buildUpdateRequest( updateOp, ByteString.copyFrom(docBytes), + ExtraFieldValues.EMPTY, bulkRequestBody, "default-index", "default-id", @@ -1487,6 +1525,7 @@ public void testIndexRequestLargeDocumentZeroCopy() { IndexRequest indexRequest = BulkRequestParserProtoUtils.buildIndexRequest( indexOp, UnsafeByteOperations.unsafeWrap(document), + ExtraFieldValues.EMPTY, null, "default-index", "default-id", @@ -1517,6 +1556,7 @@ public void testCreateRequestUtf8DocumentZeroCopy() { IndexRequest indexRequest = BulkRequestParserProtoUtils.buildCreateRequest( createOp, UnsafeByteOperations.unsafeWrap(document), + ExtraFieldValues.EMPTY, "default-index", "default-id", "default-routing", @@ -1532,4 +1572,267 @@ public void testCreateRequestUtf8DocumentZeroCopy() { assertNotNull("Source should not be null", indexRequest.source()); assertEquals("Source content should match UTF-8", jsonWithUnicode, indexRequest.source().utf8ToString()); } + + public void testGetDocWriteRequestsWithExtraFieldValuesOnIndex() { + byte[] rawBytes = new byte[] { 0, 1, 2, 127, -128, -1 }; + BulkRequestBody indexBody = indexBodyBuilder().putExtraFieldValues("raw_bytes", binaryBytesValue(rawBytes)) + .putExtraFieldValues("vector_values", binaryFloatValues(1.5f, 2.5f)) + .putExtraFieldValues("vector_packed", binaryPackedFloatValue(packFloatLE(3.5f, 4.5f), 2)) + .build(); + + IndexRequest indexRequest = parseSingleIndexRequest(indexBody); + + assertBytesValue(indexRequest.extraFieldValues().get("raw_bytes"), rawBytes); + assertFloatArrayValue(indexRequest.extraFieldValues().get("vector_values"), false, 1.5f, 2.5f); + assertFloatArrayValue(indexRequest.extraFieldValues().get("vector_packed"), true, 3.5f, 4.5f); + } + + public void testGetDocWriteRequestsWithExtraFieldValuesOnCreate() { + BulkRequestBody createBody = createBodyBuilder().putExtraFieldValues("vector", binaryPackedFloatValue(packFloatLE(5.5f, 6.5f))) + .build(); + + IndexRequest createRequest = parseSingleIndexRequest(createBody); + + assertEquals(DocWriteRequest.OpType.CREATE, createRequest.opType()); + assertFloatArrayValue(createRequest.extraFieldValues().get("vector"), true, 5.5f, 6.5f); + } + + public void testGetDocWriteRequestsWithExtraFieldValuesOnUpdateDocAndUpsert() { + org.opensearch.protobufs.UpdateAction updateAction = org.opensearch.protobufs.UpdateAction.newBuilder() + .setDoc(ByteString.copyFromUtf8("{\"field\":\"updated\"}")) + .setUpsert(ByteString.copyFromUtf8("{\"field\":\"created\"}")) + .build(); + + BulkRequestBody updateBody = updateBodyBuilder(updateAction).putExtraFieldValues("vector", binaryFloatValues(3.5f, 4.5f)).build(); + + UpdateRequest updateRequest = parseSingleUpdateRequest(updateBody); + + assertNotNull(updateRequest.doc()); + assertNotNull(updateRequest.upsertRequest()); + assertFloatArrayValue(updateRequest.doc().extraFieldValues().get("vector"), false, 3.5f, 4.5f); + assertFloatArrayValue(updateRequest.upsertRequest().extraFieldValues().get("vector"), false, 3.5f, 4.5f); + } + + public void testGetDocWriteRequestsWithUpdateDocOnlyExtraFieldValues() { + BulkRequestBody updateBody = updateBodyBuilder( + org.opensearch.protobufs.UpdateAction.newBuilder().setDoc(ByteString.copyFromUtf8("{\"field\":\"updated\"}")).build() + ).putExtraFieldValues("vector", binaryFloatValues(7.5f, 8.5f)).build(); + + UpdateRequest updateRequest = parseSingleUpdateRequest(updateBody); + + assertNotNull(updateRequest.doc()); + assertNull(updateRequest.upsertRequest()); + assertFloatArrayValue(updateRequest.doc().extraFieldValues().get("vector"), false, 7.5f, 8.5f); + } + + public void testGetDocWriteRequestsWithUpdateDocAsUpsertExtraFieldValues() { + org.opensearch.protobufs.UpdateAction updateAction = org.opensearch.protobufs.UpdateAction.newBuilder() + .setDoc(ByteString.copyFromUtf8("{\"field\":\"updated\"}")) + .setDocAsUpsert(true) + .build(); + + BulkRequestBody updateBody = updateBodyBuilder(updateAction).putExtraFieldValues("vector", binaryFloatValues(9.5f, 10.5f)).build(); + + UpdateRequest updateRequest = parseSingleUpdateRequest(updateBody); + + assertTrue(updateRequest.docAsUpsert()); + assertNotNull(updateRequest.doc()); + assertNull(updateRequest.upsertRequest()); + assertFloatArrayValue(updateRequest.doc().extraFieldValues().get("vector"), false, 9.5f, 10.5f); + } + + public void testGetDocWriteRequestsWithEmptyBytesExtraFieldValue() { + IndexRequest indexRequest = parseSingleIndexRequest(indexBodyWithExtraField("raw_bytes", binaryBytesValue())); + + assertBytesValue(indexRequest.extraFieldValues().get("raw_bytes")); + } + + public void testGetDocWriteRequestsWithEmptyFloatValuesExtraFieldValue() { + IndexRequest indexRequest = parseSingleIndexRequest(indexBodyWithExtraField("vector", binaryFloatValues())); + + assertFloatArrayValue(indexRequest.extraFieldValues().get("vector"), false); + } + + public void testGetDocWriteRequestsWithEmptyPackedFloatExtraFieldValue() { + IndexRequest indexRequest = parseSingleIndexRequest(indexBodyWithExtraField("vector", binaryPackedFloatValue(new byte[0]))); + + assertFloatArrayValue(indexRequest.extraFieldValues().get("vector"), true); + } + + public void testGetDocWriteRequestsRejectsPackedFloatBytesNotMultipleOfFour() { + assertRejectsIndexExtraFieldValue(binaryPackedFloatValue(new byte[] { 1, 2, 3 })); + } + + public void testGetDocWriteRequestsRejectsInvalidExtraFieldValueWithFieldPath() { + IllegalArgumentException e = expectThrows( + IllegalArgumentException.class, + () -> parseSingleDocWriteRequest(indexBodyWithExtraField("bad_vector", binaryPackedFloatValue(new byte[] { 1, 2, 3 }))) + ); + + assertTrue(e.getMessage(), e.getMessage().contains("Invalid extra_field_values entry [bad_vector]")); + assertTrue(e.getMessage(), e.getMessage().contains("packed_le byte length")); + assertNotNull(e.getCause()); + } + + public void testGetDocWriteRequestsRejectsPackedFloatDimensionMismatch() { + assertRejectsIndexExtraFieldValue(binaryPackedFloatValue(packFloatLE(1.5f, 2.5f), 3)); + } + + public void testGetDocWriteRequestsRejectsNegativePackedFloatDimension() { + assertRejectsIndexExtraFieldValue(binaryPackedFloatValue(packFloatLE(1.5f), -1)); + } + + public void testGetDocWriteRequestsRejectsEmptyBinaryFieldValue() { + assertRejectsIndexExtraFieldValue(BinaryFieldValue.newBuilder().build()); + } + + public void testGetDocWriteRequestsRejectsEmptyFloatArrayValue() { + BinaryFieldValue emptyFloatArray = BinaryFieldValue.newBuilder() + .setFloatArrayValue(org.opensearch.protobufs.FloatArrayValue.newBuilder().build()) + .build(); + + assertRejectsIndexExtraFieldValue(emptyFloatArray); + } + + public void testGetDocWriteRequestsRejectsExtraFieldValuesOnDelete() { + BulkRequestBody deleteBody = deleteBodyBuilder().putExtraFieldValues("raw_bytes", binaryBytesValue((byte) 1)).build(); + + expectThrows(IllegalArgumentException.class, () -> parseSingleDocWriteRequest(deleteBody)); + } + + public void testGetDocWriteRequestsRejectsUpdateExtraFieldValuesWithoutDocOrUpsert() { + BulkRequestBody updateBody = updateBodyBuilder(org.opensearch.protobufs.UpdateAction.newBuilder().setDetectNoop(false).build()) + .putExtraFieldValues("vector", binaryFloatValues(1.5f)) + .build(); + + expectThrows(IllegalArgumentException.class, () -> parseSingleDocWriteRequest(updateBody)); + } + + private void assertRejectsIndexExtraFieldValue(BinaryFieldValue value) { + expectThrows(IllegalArgumentException.class, () -> parseSingleDocWriteRequest(indexBodyWithExtraField("field", value))); + } + + private IndexRequest parseSingleIndexRequest(BulkRequestBody body) { + DocWriteRequest request = parseSingleDocWriteRequest(body); + assertTrue(request instanceof IndexRequest); + return (IndexRequest) request; + } + + private UpdateRequest parseSingleUpdateRequest(BulkRequestBody body) { + DocWriteRequest request = parseSingleDocWriteRequest(body); + assertTrue(request instanceof UpdateRequest); + return (UpdateRequest) request; + } + + private DocWriteRequest parseSingleDocWriteRequest(BulkRequestBody body) { + DocWriteRequest[] requests = BulkRequestParserProtoUtils.getDocWriteRequests( + BulkRequest.newBuilder().addBulkRequestBody(body).build(), + "default-index", + null, + null, + null, + false + ); + assertEquals(1, requests.length); + return requests[0]; + } + + private static BulkRequestBody indexBodyWithExtraField(String field, BinaryFieldValue value) { + return indexBodyBuilder().putExtraFieldValues(field, value).build(); + } + + private static BulkRequestBody.Builder indexBodyBuilder() { + return BulkRequestBody.newBuilder() + .setOperationContainer( + OperationContainer.newBuilder().setIndex(IndexOperation.newBuilder().setXIndex("test-index").build()).build() + ) + .setObject(ByteString.copyFromUtf8("{\"field\":\"value\"}")); + } + + private static BulkRequestBody.Builder createBodyBuilder() { + return BulkRequestBody.newBuilder() + .setOperationContainer( + OperationContainer.newBuilder() + .setCreate(WriteOperation.newBuilder().setXIndex("test-index").setXId("test-id").build()) + .build() + ) + .setObject(ByteString.copyFromUtf8("{\"field\":\"value\"}")); + } + + private static BulkRequestBody.Builder updateBodyBuilder(org.opensearch.protobufs.UpdateAction updateAction) { + return BulkRequestBody.newBuilder() + .setOperationContainer( + OperationContainer.newBuilder() + .setUpdate(UpdateOperation.newBuilder().setXIndex("test-index").setXId("test-id").build()) + .build() + ) + .setUpdateAction(updateAction); + } + + private static BulkRequestBody.Builder deleteBodyBuilder() { + return BulkRequestBody.newBuilder() + .setOperationContainer( + OperationContainer.newBuilder() + .setDelete(DeleteOperation.newBuilder().setXIndex("test-index").setXId("test-id").build()) + .build() + ); + } + + private static BinaryFieldValue binaryBytesValue(byte... values) { + return BinaryFieldValue.newBuilder() + .setBytesValue(org.opensearch.protobufs.BytesValue.newBuilder().setBytes(ByteString.copyFrom(values)).build()) + .build(); + } + + private static BinaryFieldValue binaryFloatValues(float... values) { + FloatList.Builder floatList = FloatList.newBuilder(); + for (float value : values) { + floatList.addValues(value); + } + return BinaryFieldValue.newBuilder() + .setFloatArrayValue(org.opensearch.protobufs.FloatArrayValue.newBuilder().setValues(floatList.build()).build()) + .build(); + } + + private static BinaryFieldValue binaryPackedFloatValue(byte[] bytes) { + return BinaryFieldValue.newBuilder() + .setFloatArrayValue( + org.opensearch.protobufs.FloatArrayValue.newBuilder() + .setBinaryLe(FloatBinaryLE.newBuilder().setBytesLe(ByteString.copyFrom(bytes)).build()) + .build() + ) + .build(); + } + + private static BinaryFieldValue binaryPackedFloatValue(byte[] bytes, int dimension) { + return BinaryFieldValue.newBuilder() + .setFloatArrayValue( + org.opensearch.protobufs.FloatArrayValue.newBuilder() + .setBinaryLe(FloatBinaryLE.newBuilder().setBytesLe(ByteString.copyFrom(bytes)).setDimension(dimension).build()) + .build() + ) + .build(); + } + + private static void assertBytesValue(ExtraFieldValue value, byte... expected) { + assertTrue(value instanceof BytesValue); + assertArrayEquals(expected, BytesReference.toBytes(((BytesValue) value).bytes())); + } + + private static void assertFloatArrayValue(ExtraFieldValue value, boolean expectedPackedLE, float... expected) { + assertTrue(value instanceof FloatArrayValue); + FloatArrayValue floatArrayValue = (FloatArrayValue) value; + assertEquals(expectedPackedLE, floatArrayValue.isPackedLE()); + assertEquals(expected.length, floatArrayValue.dimension()); + assertArrayEquals(expected, floatArrayValue.asFloatArray(), 0.0f); + } + + private static byte[] packFloatLE(float... values) { + ByteBuffer buffer = ByteBuffer.allocate(values.length * Float.BYTES).order(ByteOrder.LITTLE_ENDIAN); + for (float value : values) { + buffer.putFloat(value); + } + return buffer.array(); + } + } diff --git a/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/document/bulk/ExtraFieldValuesProtoUtilsTests.java b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/document/bulk/ExtraFieldValuesProtoUtilsTests.java new file mode 100644 index 0000000000000..b654b15dfa17d --- /dev/null +++ b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/document/bulk/ExtraFieldValuesProtoUtilsTests.java @@ -0,0 +1,116 @@ +/* + * 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.transport.grpc.proto.request.document.bulk; + +import com.google.protobuf.ByteString; +import org.opensearch.core.common.bytes.BytesReference; +import org.opensearch.index.mapper.extrasource.BytesValue; +import org.opensearch.index.mapper.extrasource.ExtraFieldValue; +import org.opensearch.index.mapper.extrasource.ExtraFieldValues; +import org.opensearch.index.mapper.extrasource.FloatArrayValue; +import org.opensearch.protobufs.BinaryFieldValue; +import org.opensearch.protobufs.BulkRequestBody; +import org.opensearch.protobufs.FloatBinaryLE; +import org.opensearch.protobufs.FloatList; +import org.opensearch.test.OpenSearchTestCase; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +public class ExtraFieldValuesProtoUtilsTests extends OpenSearchTestCase { + + public void testFromProtoReturnsEmptyForNoExtraFieldValues() { + assertSame(ExtraFieldValues.EMPTY, ExtraFieldValuesProtoUtils.fromProto(BulkRequestBody.newBuilder().build())); + } + + public void testFromProtoConvertsSupportedTypes() { + byte[] rawBytes = new byte[] { 0, 1, 2, 127, -128, -1 }; + BulkRequestBody body = BulkRequestBody.newBuilder() + .putExtraFieldValues("raw_bytes", binaryBytesValue(rawBytes)) + .putExtraFieldValues("vector_values", binaryFloatValues(1.5f, 2.5f)) + .putExtraFieldValues("vector_packed", binaryPackedFloatValue(packFloatLE(3.5f, 4.5f), 2)) + .build(); + + ExtraFieldValues extraFieldValues = ExtraFieldValuesProtoUtils.fromProto(body); + + assertEquals(3, extraFieldValues.values().size()); + assertBytesValue(extraFieldValues.get("raw_bytes"), rawBytes); + assertFloatArrayValue(extraFieldValues.get("vector_values"), false, 1.5f, 2.5f); + assertFloatArrayValue(extraFieldValues.get("vector_packed"), true, 3.5f, 4.5f); + } + + public void testFromProtoRejectsInvalidEntryWithFieldPath() { + BulkRequestBody body = BulkRequestBody.newBuilder() + .putExtraFieldValues("bad_vector", binaryPackedFloatValue(new byte[] { 1, 2, 3 })) + .build(); + + IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> ExtraFieldValuesProtoUtils.fromProto(body)); + + assertTrue(e.getMessage(), e.getMessage().contains("Invalid extra_field_values entry [bad_vector]")); + assertTrue(e.getMessage(), e.getMessage().contains("packed_le byte length")); + assertNotNull(e.getCause()); + } + + private static BinaryFieldValue binaryBytesValue(byte... values) { + return BinaryFieldValue.newBuilder() + .setBytesValue(org.opensearch.protobufs.BytesValue.newBuilder().setBytes(ByteString.copyFrom(values)).build()) + .build(); + } + + private static BinaryFieldValue binaryFloatValues(float... values) { + FloatList.Builder floatList = FloatList.newBuilder(); + for (float value : values) { + floatList.addValues(value); + } + return BinaryFieldValue.newBuilder() + .setFloatArrayValue(org.opensearch.protobufs.FloatArrayValue.newBuilder().setValues(floatList.build()).build()) + .build(); + } + + private static BinaryFieldValue binaryPackedFloatValue(byte[] bytes) { + return BinaryFieldValue.newBuilder() + .setFloatArrayValue( + org.opensearch.protobufs.FloatArrayValue.newBuilder() + .setBinaryLe(FloatBinaryLE.newBuilder().setBytesLe(ByteString.copyFrom(bytes)).build()) + .build() + ) + .build(); + } + + private static BinaryFieldValue binaryPackedFloatValue(byte[] bytes, int dimension) { + return BinaryFieldValue.newBuilder() + .setFloatArrayValue( + org.opensearch.protobufs.FloatArrayValue.newBuilder() + .setBinaryLe(FloatBinaryLE.newBuilder().setBytesLe(ByteString.copyFrom(bytes)).setDimension(dimension).build()) + .build() + ) + .build(); + } + + private static void assertBytesValue(ExtraFieldValue value, byte... expected) { + assertTrue(value instanceof BytesValue); + assertArrayEquals(expected, BytesReference.toBytes(((BytesValue) value).bytes())); + } + + private static void assertFloatArrayValue(ExtraFieldValue value, boolean expectedPackedLE, float... expected) { + assertTrue(value instanceof FloatArrayValue); + FloatArrayValue floatArrayValue = (FloatArrayValue) value; + assertEquals(expectedPackedLE, floatArrayValue.isPackedLE()); + assertEquals(expected.length, floatArrayValue.dimension()); + assertArrayEquals(expected, floatArrayValue.asFloatArray(), 0.0f); + } + + private static byte[] packFloatLE(float... values) { + ByteBuffer buffer = ByteBuffer.allocate(values.length * Float.BYTES).order(ByteOrder.LITTLE_ENDIAN); + for (float value : values) { + buffer.putFloat(value); + } + return buffer.array(); + } +} diff --git a/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/search/aggregation/bucket/terms/TermsAggregationBuilderConverterTests.java b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/search/aggregation/bucket/terms/TermsAggregationBuilderConverterTests.java index 76ab38967203c..95ee87ccf2fc9 100644 --- a/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/search/aggregation/bucket/terms/TermsAggregationBuilderConverterTests.java +++ b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/search/aggregation/bucket/terms/TermsAggregationBuilderConverterTests.java @@ -8,6 +8,7 @@ package org.opensearch.transport.grpc.proto.request.search.aggregation.bucket.terms; import org.opensearch.protobufs.AggregationContainer; +import org.opensearch.protobufs.FieldValue; import org.opensearch.protobufs.InlineScript; import org.opensearch.protobufs.Script; import org.opensearch.protobufs.SortOrder; @@ -95,6 +96,16 @@ public void testAllFieldsCombined() { assertNotNull(t.includeExclude()); } + public void testStringMissingValue() { + AggregationContainer container = buildContainer( + TermsAggregationFields.newBuilder().setField("status").setMissing(FieldValue.newBuilder().setString("unknown").build()).build() + ); + + TermsAggregationBuilder builder = (TermsAggregationBuilder) converter.fromProto("by_status", container); + + assertEquals("unknown", builder.missing()); + } + public void testOrderVariants() { // _count desc TermsAggregationBuilder countDesc = (TermsAggregationBuilder) converter.fromProto( diff --git a/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/search/aggregation/metrics/MaxAggregationProtoUtilsTests.java b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/search/aggregation/metrics/MaxAggregationProtoUtilsTests.java index 8c00a7f93dab8..47328f411c54a 100644 --- a/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/search/aggregation/metrics/MaxAggregationProtoUtilsTests.java +++ b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/search/aggregation/metrics/MaxAggregationProtoUtilsTests.java @@ -46,6 +46,17 @@ public void testFromProtoWithMissingValue() { assertEquals(0.0, result.missing()); } + public void testFromProtoWithStringMissingValue() { + MaxAggregation proto = MaxAggregation.newBuilder() + .setField("status") + .setMissing(FieldValue.newBuilder().setString("unknown").build()) + .build(); + + MaxAggregationBuilder result = MaxAggregationProtoUtils.fromProto("max_status", proto); + + assertEquals("unknown", result.missing()); + } + public void testFromProtoWithoutFieldOrScript() { MaxAggregation proto = MaxAggregation.newBuilder().build(); diff --git a/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/search/aggregation/metrics/MinAggregationProtoUtilsTests.java b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/search/aggregation/metrics/MinAggregationProtoUtilsTests.java index fbfc93cd733f2..465a706668900 100644 --- a/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/search/aggregation/metrics/MinAggregationProtoUtilsTests.java +++ b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/request/search/aggregation/metrics/MinAggregationProtoUtilsTests.java @@ -46,6 +46,17 @@ public void testFromProtoWithMissingValue() { assertEquals(0.0, result.missing()); } + public void testFromProtoWithStringMissingValue() { + MinAggregation proto = MinAggregation.newBuilder() + .setField("status") + .setMissing(FieldValue.newBuilder().setString("unknown").build()) + .build(); + + MinAggregationBuilder result = MinAggregationProtoUtils.fromProto("min_status", proto); + + assertEquals("unknown", result.missing()); + } + public void testFromProtoWithoutFieldOrScript() { MinAggregation proto = MinAggregation.newBuilder().build(); diff --git a/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/response/common/FieldValueProtoUtilsTests.java b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/response/common/FieldValueProtoUtilsTests.java index 2cd39c8624f09..227101d6ad949 100644 --- a/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/response/common/FieldValueProtoUtilsTests.java +++ b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/response/common/FieldValueProtoUtilsTests.java @@ -9,6 +9,7 @@ package org.opensearch.transport.grpc.proto.response.common; import org.opensearch.protobufs.FieldValue; +import org.opensearch.protobufs.NullValue; import org.opensearch.test.OpenSearchTestCase; import java.math.BigInteger; @@ -17,6 +18,14 @@ public class FieldValueProtoUtilsTests extends OpenSearchTestCase { + public void testToProtoWithNull() { + FieldValue fieldValue = FieldValueProtoUtils.toProto(null); + + assertNotNull("FieldValue should not be null", fieldValue); + assertTrue("FieldValue should have null value", fieldValue.hasNullValue()); + assertEquals("Null value should match proto enum", NullValue.NULL_VALUE_NULL, fieldValue.getNullValue()); + } + public void testToProtoWithInteger() { Integer intValue = 42; FieldValue fieldValue = FieldValueProtoUtils.toProto(intValue); diff --git a/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/response/search/SearchHitProtoUtilsTests.java b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/response/search/SearchHitProtoUtilsTests.java index 89aa185172c0d..4b5c223f4bd0d 100644 --- a/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/response/search/SearchHitProtoUtilsTests.java +++ b/modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/response/search/SearchHitProtoUtilsTests.java @@ -17,6 +17,8 @@ import org.opensearch.core.index.shard.ShardId; import org.opensearch.index.seqno.SequenceNumbers; import org.opensearch.protobufs.HitsMetadataHitsInner; +import org.opensearch.protobufs.NullValue; +import org.opensearch.search.DocValueFormat; import org.opensearch.search.SearchHit; import org.opensearch.search.SearchHits; import org.opensearch.search.SearchShardTarget; @@ -139,6 +141,32 @@ public void testToProtoWithNullFields() throws IOException { assertFalse("Source should not be set", hit.hasXSource()); } + public void testToProtoWithNullSortValue() throws IOException { + SearchHit searchHit = new SearchHit(1); + searchHit.sortValues(new Object[] { "first", null }, new DocValueFormat[] { DocValueFormat.RAW, DocValueFormat.RAW }); + + HitsMetadataHitsInner hit = SearchHitProtoUtils.toProto(searchHit); + + assertNotNull("Hit should not be null", hit); + assertEquals("Should have 2 sort values", 2, hit.getSortCount()); + assertTrue("First sort value should be a string", hit.getSort(0).hasString()); + assertEquals("First sort value should match", "first", hit.getSort(0).getString()); + assertTrue("Second sort value should be null", hit.getSort(1).hasNullValue()); + assertEquals("Null sort value should match proto enum", NullValue.NULL_VALUE_NULL, hit.getSort(1).getNullValue()); + } + + public void testToProtoWithOnlyNullSortValue() throws IOException { + SearchHit searchHit = new SearchHit(1); + searchHit.sortValues(new Object[] { null }, new DocValueFormat[] { DocValueFormat.RAW }); + + HitsMetadataHitsInner hit = SearchHitProtoUtils.toProto(searchHit); + + assertNotNull("Hit should not be null", hit); + assertEquals("Should have 1 sort value", 1, hit.getSortCount()); + assertTrue("Sort value should be null", hit.getSort(0).hasNullValue()); + assertEquals("Null sort value should match proto enum", NullValue.NULL_VALUE_NULL, hit.getSort(0).getNullValue()); + } + public void testToProtoWithDocumentFields() throws IOException { // Create a SearchHit with document fields SearchHit searchHit = new SearchHit(1); diff --git a/plugins/analysis-icu/licenses/lucene-analysis-icu-10.4.0.jar.sha1 b/plugins/analysis-icu/licenses/lucene-analysis-icu-10.4.0.jar.sha1 deleted file mode 100644 index 8f7a2f739cd27..0000000000000 --- a/plugins/analysis-icu/licenses/lucene-analysis-icu-10.4.0.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -50257f0d7d3fc805d596d6b842f0eeb099c72640 \ No newline at end of file diff --git a/plugins/analysis-icu/licenses/lucene-analysis-icu-10.5.0.jar.sha1 b/plugins/analysis-icu/licenses/lucene-analysis-icu-10.5.0.jar.sha1 new file mode 100644 index 0000000000000..b490479390839 --- /dev/null +++ b/plugins/analysis-icu/licenses/lucene-analysis-icu-10.5.0.jar.sha1 @@ -0,0 +1 @@ +cb67d073b767ed1d1d24a9b03117ef9bbac5c1aa \ No newline at end of file diff --git a/plugins/analysis-kuromoji/licenses/lucene-analysis-kuromoji-10.4.0.jar.sha1 b/plugins/analysis-kuromoji/licenses/lucene-analysis-kuromoji-10.4.0.jar.sha1 deleted file mode 100644 index 8d0bf21c63e21..0000000000000 --- a/plugins/analysis-kuromoji/licenses/lucene-analysis-kuromoji-10.4.0.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -71033f490b07f1f1784724ade66efaa181a69b92 \ No newline at end of file diff --git a/plugins/analysis-kuromoji/licenses/lucene-analysis-kuromoji-10.5.0.jar.sha1 b/plugins/analysis-kuromoji/licenses/lucene-analysis-kuromoji-10.5.0.jar.sha1 new file mode 100644 index 0000000000000..ec21a19de5bfa --- /dev/null +++ b/plugins/analysis-kuromoji/licenses/lucene-analysis-kuromoji-10.5.0.jar.sha1 @@ -0,0 +1 @@ +c205946d6a177bf516f590e89e4cda36a537c33f \ No newline at end of file diff --git a/plugins/analysis-nori/licenses/lucene-analysis-nori-10.4.0.jar.sha1 b/plugins/analysis-nori/licenses/lucene-analysis-nori-10.4.0.jar.sha1 deleted file mode 100644 index 70bc273c4d9e0..0000000000000 --- a/plugins/analysis-nori/licenses/lucene-analysis-nori-10.4.0.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -3781153d96b93e8f50c975bb5b60c7634687074a \ No newline at end of file diff --git a/plugins/analysis-nori/licenses/lucene-analysis-nori-10.5.0.jar.sha1 b/plugins/analysis-nori/licenses/lucene-analysis-nori-10.5.0.jar.sha1 new file mode 100644 index 0000000000000..7bd3c8c9a0b7c --- /dev/null +++ b/plugins/analysis-nori/licenses/lucene-analysis-nori-10.5.0.jar.sha1 @@ -0,0 +1 @@ +2a2db150e967984e18d293619142b2cd27f09f63 \ No newline at end of file diff --git a/plugins/analysis-phonetic/licenses/lucene-analysis-phonetic-10.4.0.jar.sha1 b/plugins/analysis-phonetic/licenses/lucene-analysis-phonetic-10.4.0.jar.sha1 deleted file mode 100644 index 9f19d7f83eba5..0000000000000 --- a/plugins/analysis-phonetic/licenses/lucene-analysis-phonetic-10.4.0.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -8bec2c8cba1dd2f16d6899cfa1a7de48c0454c09 \ No newline at end of file diff --git a/plugins/analysis-phonetic/licenses/lucene-analysis-phonetic-10.5.0.jar.sha1 b/plugins/analysis-phonetic/licenses/lucene-analysis-phonetic-10.5.0.jar.sha1 new file mode 100644 index 0000000000000..1655db0c3da6c --- /dev/null +++ b/plugins/analysis-phonetic/licenses/lucene-analysis-phonetic-10.5.0.jar.sha1 @@ -0,0 +1 @@ +2dfd6a62be5ed3822b01c96a0da805fa55e962ef \ No newline at end of file diff --git a/plugins/analysis-smartcn/licenses/lucene-analysis-smartcn-10.4.0.jar.sha1 b/plugins/analysis-smartcn/licenses/lucene-analysis-smartcn-10.4.0.jar.sha1 deleted file mode 100644 index d18455ff73b79..0000000000000 --- a/plugins/analysis-smartcn/licenses/lucene-analysis-smartcn-10.4.0.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -e05ae4af9a81735b4bf3c88449a2468331f5455e \ No newline at end of file diff --git a/plugins/analysis-smartcn/licenses/lucene-analysis-smartcn-10.5.0.jar.sha1 b/plugins/analysis-smartcn/licenses/lucene-analysis-smartcn-10.5.0.jar.sha1 new file mode 100644 index 0000000000000..520e448917a44 --- /dev/null +++ b/plugins/analysis-smartcn/licenses/lucene-analysis-smartcn-10.5.0.jar.sha1 @@ -0,0 +1 @@ +c6ef06ff204f4e370584a37674cd676b708d5ceb \ No newline at end of file diff --git a/plugins/analysis-stempel/licenses/lucene-analysis-stempel-10.4.0.jar.sha1 b/plugins/analysis-stempel/licenses/lucene-analysis-stempel-10.4.0.jar.sha1 deleted file mode 100644 index 6aa41cde5ce17..0000000000000 --- a/plugins/analysis-stempel/licenses/lucene-analysis-stempel-10.4.0.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -718244708f4ef6d6ba4133c7e71b11ced8ebaa7d \ No newline at end of file diff --git a/plugins/analysis-stempel/licenses/lucene-analysis-stempel-10.5.0.jar.sha1 b/plugins/analysis-stempel/licenses/lucene-analysis-stempel-10.5.0.jar.sha1 new file mode 100644 index 0000000000000..69ad1366e131f --- /dev/null +++ b/plugins/analysis-stempel/licenses/lucene-analysis-stempel-10.5.0.jar.sha1 @@ -0,0 +1 @@ +4bbed0d5695daefcbf6c380d72c1b54b99ddf397 \ No newline at end of file diff --git a/plugins/analysis-ukrainian/licenses/lucene-analysis-morfologik-10.4.0.jar.sha1 b/plugins/analysis-ukrainian/licenses/lucene-analysis-morfologik-10.4.0.jar.sha1 deleted file mode 100644 index 10984e7989aa7..0000000000000 --- a/plugins/analysis-ukrainian/licenses/lucene-analysis-morfologik-10.4.0.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -21d90ece2dbe5df22e98dc54d848c16e1ddc838a \ No newline at end of file diff --git a/plugins/analysis-ukrainian/licenses/lucene-analysis-morfologik-10.5.0.jar.sha1 b/plugins/analysis-ukrainian/licenses/lucene-analysis-morfologik-10.5.0.jar.sha1 new file mode 100644 index 0000000000000..9b81c5ae6ec75 --- /dev/null +++ b/plugins/analysis-ukrainian/licenses/lucene-analysis-morfologik-10.5.0.jar.sha1 @@ -0,0 +1 @@ +1ea877703aeefcd8109934c6894387ee6fde7163 \ No newline at end of file diff --git a/plugins/arrow-base/src/main/java/org/opensearch/arrow/allocator/ArrowBasePlugin.java b/plugins/arrow-base/src/main/java/org/opensearch/arrow/allocator/ArrowBasePlugin.java index 6a2c52a46f9ce..8c06116a1ee26 100644 --- a/plugins/arrow-base/src/main/java/org/opensearch/arrow/allocator/ArrowBasePlugin.java +++ b/plugins/arrow-base/src/main/java/org/opensearch/arrow/allocator/ArrowBasePlugin.java @@ -11,6 +11,7 @@ import org.opensearch.arrow.spi.NativeAllocatorPoolConfig; import org.opensearch.arrow.spi.PoolGroup; import org.opensearch.cluster.metadata.IndexNameExpressionResolver; +import org.opensearch.cluster.node.DiscoveryNode; import org.opensearch.cluster.node.DiscoveryNodes; import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.settings.ClusterSettings; @@ -107,6 +108,51 @@ public ArrowBasePlugin() {} Setting.Property.Dynamic ); + /** Default for {@link #OVERCOMMIT_ENABLED_SETTING}: the over-commit fallback is on by default. */ + static final boolean DEFAULT_OVERCOMMIT_ENABLED = true; + /** Default node native-memory pressure % at/above which over-commit is refused. */ + static final double DEFAULT_OVERCOMMIT_PRESSURE_THRESHOLD = 70.0; + /** Lower bound for the over-commit pressure threshold setting. */ + static final double OVERCOMMIT_PRESSURE_THRESHOLD_MIN = 0.0; + /** Upper bound for the over-commit pressure threshold setting. */ + static final double OVERCOMMIT_PRESSURE_THRESHOLD_MAX = 100.0; + /** Lower bound (and floor) for the max-concurrent over-commit setting. */ + static final int OVERCOMMIT_MAX_CONCURRENT_MIN = 1; + /** Default max concurrent over-commits: half the available processors (at least one). */ + static final int DEFAULT_OVERCOMMIT_MAX_CONCURRENT = Math.max( + OVERCOMMIT_MAX_CONCURRENT_MIN, + Runtime.getRuntime().availableProcessors() / 4 + ); + + /** Feature gate for the over-commit fallback when a pool is full. Default on. */ + public static final Setting OVERCOMMIT_ENABLED_SETTING = Setting.boolSetting( + "native.allocator.overcommit.enabled", + DEFAULT_OVERCOMMIT_ENABLED, + Setting.Property.NodeScope, + Setting.Property.Dynamic + ); + + /** Node native-memory pressure % at/above which over-commit is refused. */ + public static final Setting OVERCOMMIT_PRESSURE_THRESHOLD_SETTING = Setting.doubleSetting( + "native.allocator.overcommit.pressure_threshold", + DEFAULT_OVERCOMMIT_PRESSURE_THRESHOLD, + OVERCOMMIT_PRESSURE_THRESHOLD_MIN, + OVERCOMMIT_PRESSURE_THRESHOLD_MAX, + Setting.Property.NodeScope, + Setting.Property.Dynamic + ); + + /** + * Maximum number of concurrently over-committing operations. Static (node-scope, non-dynamic); + * defaults to half the available processors. + */ + public static final Setting OVERCOMMIT_MAX_CONCURRENT_SETTING = Setting.intSetting( + "native.allocator.overcommit.max_concurrent", + DEFAULT_OVERCOMMIT_MAX_CONCURRENT, + OVERCOMMIT_MAX_CONCURRENT_MIN, + Setting.Property.NodeScope + ); + /** Minimum guaranteed bytes for the Flight pool. Default is 2% of budget. */ public static final Setting FLIGHT_MIN_SETTING = new Setting<>( NativeAllocatorPoolConfig.SETTING_FLIGHT_MIN, @@ -125,19 +171,19 @@ public ArrowBasePlugin() {} Setting.Property.Dynamic ); - /** Minimum guaranteed bytes for the ingest pool. Default is 4% of budget. */ + /** Minimum guaranteed bytes for the ingest pool. Default is 1% of budget on warm nodes, 2% otherwise. */ public static final Setting INGEST_MIN_SETTING = new Setting<>( NativeAllocatorPoolConfig.SETTING_INGEST_MIN, - s -> derivePoolMinDefault(s, 4), + s -> derivePoolMinDefault(s, DiscoveryNode.isWarmNode(s) ? 1 : 2), s -> parseNonNegativeLong(s, NativeAllocatorPoolConfig.SETTING_INGEST_MIN), Setting.Property.NodeScope, Setting.Property.Dynamic ); - /** Maximum bytes the ingest pool can burst to. Default is 8% of budget. */ + /** Maximum bytes the ingest pool can burst to. Default is 3% of budget on warm nodes, 5% otherwise. */ public static final Setting INGEST_MAX_SETTING = new Setting<>( NativeAllocatorPoolConfig.SETTING_INGEST_MAX, - s -> derivePoolMaxDefault(s, 8), + s -> derivePoolMaxDefault(s, DiscoveryNode.isWarmNode(s) ? 3 : 5), s -> parseNonNegativeLong(s, NativeAllocatorPoolConfig.SETTING_INGEST_MAX), Setting.Property.NodeScope, Setting.Property.Dynamic @@ -195,7 +241,7 @@ public Collection createComponents( ArrowNativeAllocator a = this.allocator; return a != null ? a.stats() : null; }; - return List.of(built, new NativeAllocatorStatsRegistry(statsSupplier)); + return List.of(built, new NativeAllocatorStatsRegistry(statsSupplier, built::setNativeMemoryPressureSupplier)); } @Override @@ -211,7 +257,10 @@ public List> getSettings() { REBALANCER_ENABLED_SETTING, PRESSURE_THRESHOLD_SETTING, IDLE_THRESHOLD_SETTING, - SHRINK_FACTOR_SETTING + SHRINK_FACTOR_SETTING, + OVERCOMMIT_ENABLED_SETTING, + OVERCOMMIT_PRESSURE_THRESHOLD_SETTING, + OVERCOMMIT_MAX_CONCURRENT_SETTING ); } @@ -319,6 +368,14 @@ ArrowNativeAllocator buildAllocator(Settings settings, ClusterSettings cs, Suppl if (r != null) r.setShrinkFactor(value); }); + // Over-commit admission: apply initial values and register dynamic consumers. + allocator.setOverCommitEnabled(OVERCOMMIT_ENABLED_SETTING.get(settings)); + allocator.setOverCommitPressureThreshold(OVERCOMMIT_PRESSURE_THRESHOLD_SETTING.get(settings)); + // max_concurrent is a static (non-dynamic) setting applied once at startup. + allocator.setMaxConcurrentOverCommits(OVERCOMMIT_MAX_CONCURRENT_SETTING.get(settings)); + cs.addSettingsUpdateConsumer(OVERCOMMIT_ENABLED_SETTING, allocator::setOverCommitEnabled); + cs.addSettingsUpdateConsumer(OVERCOMMIT_PRESSURE_THRESHOLD_SETTING, allocator::setOverCommitPressureThreshold); + return allocator; } diff --git a/plugins/arrow-base/src/main/java/org/opensearch/arrow/allocator/ArrowNativeAllocator.java b/plugins/arrow-base/src/main/java/org/opensearch/arrow/allocator/ArrowNativeAllocator.java index baf5ff398b760..baa791282ef68 100644 --- a/plugins/arrow-base/src/main/java/org/opensearch/arrow/allocator/ArrowNativeAllocator.java +++ b/plugins/arrow-base/src/main/java/org/opensearch/arrow/allocator/ArrowNativeAllocator.java @@ -14,6 +14,7 @@ import org.apache.logging.log4j.Logger; import org.opensearch.arrow.spi.NativeAllocator; import org.opensearch.arrow.spi.PoolGroup; +import org.opensearch.common.SetOnce; import org.opensearch.common.settings.ClusterSettings; import org.opensearch.common.settings.Setting; import org.opensearch.common.settings.Settings; @@ -23,13 +24,21 @@ import java.util.Collections; import java.util.HashSet; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; +import java.util.function.DoubleSupplier; import java.util.function.Supplier; +import static org.opensearch.arrow.allocator.ArrowBasePlugin.DEFAULT_OVERCOMMIT_ENABLED; +import static org.opensearch.arrow.allocator.ArrowBasePlugin.DEFAULT_OVERCOMMIT_PRESSURE_THRESHOLD; + /** * Arrow-backed implementation of {@link NativeAllocator}. * @@ -49,6 +58,20 @@ public class ArrowNativeAllocator implements NativeAllocator { private volatile Supplier nativeMemoryStatsSupplier; private volatile long budget = Long.MAX_VALUE; + // ─── Over-commit admission (allocator-owned decision) ──────────────────────── + /** Node-level native memory utilization % (0–100), or negative when unavailable. Injected by the node. */ + private volatile DoubleSupplier nativeMemoryPressureSupplier; + /** Feature gate. When false, {@link #tryOverCommit()} always rejects (status-quo behavior). */ + private volatile boolean overCommitEnabled = DEFAULT_OVERCOMMIT_ENABLED; + /** Node native pressure % at/above which over-commit is refused. */ + private volatile double overCommitPressureThreshold = DEFAULT_OVERCOMMIT_PRESSURE_THRESHOLD; + /** Permit gate bounding the number of concurrently over-committing operations (sized once at startup). */ + private final SetOnce overCommitPermits = new SetOnce<>(); + /** Outstanding over-commit leases keyed by their token, so a token from the native side can be released. */ + private final ConcurrentMap outstandingOverCommits = new ConcurrentHashMap<>(); + /** Monotonic source of over-commit tokens. Starts at 0, which is reserved for "no grant". */ + private final AtomicLong overCommitTokenSeq = new AtomicLong(); + /** * Creates a new allocator with a fresh RootAllocator. */ @@ -65,6 +88,173 @@ public void setBudget(long budget) { this.budget = budget; } + // ─── Over-commit admission API (called by pool-full paths across all pool types) ───────────── + + /** + * Installs the node-level native-memory pressure supplier (percent 0–100, or negative if unavailable). + * + * @param supplier supplies the current node native-memory utilization percentage + */ + public void setNativeMemoryPressureSupplier(DoubleSupplier supplier) { + this.nativeMemoryPressureSupplier = supplier; + } + + /** + * Enables/disables the over-commit fallback (feature gate). + * + * @param enabled whether the over-commit fallback is enabled + */ + public void setOverCommitEnabled(boolean enabled) { + this.overCommitEnabled = enabled; + } + + /** + * Sets the node native-pressure % at/above which over-commit is refused. + * + * @param thresholdPercent the native-memory pressure percentage threshold + */ + public void setOverCommitPressureThreshold(double thresholdPercent) { + this.overCommitPressureThreshold = thresholdPercent; + } + + /** + * Sets the maximum number of concurrently over-committing operations. Applied once at startup. + * + * @param max the maximum number of concurrent over-commits + */ + public void setMaxConcurrentOverCommits(int max) { + this.overCommitPermits.set(new Semaphore(Math.max(ArrowBasePlugin.OVERCOMMIT_MAX_CONCURRENT_MIN, max))); + } + + /** Current node-level native memory pressure %, or -1 when unavailable (signal missing / not ready). */ + double currentNativePressurePercent() { + DoubleSupplier s = this.nativeMemoryPressureSupplier; + if (s == null) { + return -1.0; + } + try { + return s.getAsDouble(); + } catch (RuntimeException e) { + return -1.0; + } + } + + /** + * Allocator-owned decision: may a full pool over-commit right now? Grants only when the feature is + * enabled, node-level native memory pressure is below the threshold, and a concurrency permit is + * available. On a grant this returns a {@link OverCommitLease} that the caller MUST + * {@link OverCommitLease#close() close} exactly once when the over-committing operation completes + * (the permit is held until then); on rejection it returns {@link Optional#empty()}. + * + *

This is the safe, in-JVM entry point: the only way to release a permit is to close a lease + * that this method minted, so a permit can never be released without first being acquired. + * + *

Generic across pool types (Arrow-backed pools via allocation-failure hooks, virtual/native + * pools via the FFM upcall through {@link #tryOverCommitToken()}). Never throws. + * + * @return a lease on grant, or empty on rejection + */ + public Optional tryOverCommit() { + try { + if (overCommitEnabled == false) { + return Optional.empty(); + } + double pressure = currentNativePressurePercent(); + if (pressure < 0) { + logger.debug("Over-commit unavailable: native memory pressure signal not ready"); + return Optional.empty(); + } + if (pressure >= overCommitPressureThreshold) { + logger.debug("Over-commit refused: native memory pressure {}% >= threshold {}%", pressure, overCommitPressureThreshold); + return Optional.empty(); + } + logger.debug( + "Native memory pressure {}% within threshold {}%; attempting over-commit permit acquire", + pressure, + overCommitPressureThreshold + ); + // Permit gate: at most max_concurrent over-commits in flight. The permit is held + // until the lease is closed when the over-committing operation completes. + Semaphore permits = overCommitPermits.get(); + if (permits == null) { + logger.warn("Over-commit enabled but permit pool is not initialized; refusing over-commit"); + return Optional.empty(); + } + if (permits.tryAcquire()) { + long token = overCommitTokenSeq.incrementAndGet(); + OverCommitLease lease = new OverCommitLease(token); + outstandingOverCommits.put(token, lease); + logger.debug( + "Over-commit granted; acquired permit (token {}, {} permits now available)", + token, + permits.availablePermits() + ); + return Optional.of(lease); + } + logger.warn("Over-commit refused: concurrency limit reached (all max_concurrent permits in use)"); + return Optional.empty(); + } catch (Throwable t) { + return Optional.empty(); + } + } + + /** + * FFM upcall entry point for native pools: performs the same decision as {@link #tryOverCommit()} + * but returns an opaque token instead of a lease object (which cannot cross the native boundary). + * The native side stores the token and passes it back to {@link #releaseOverCommitToken(long)} on + * release. Never throws — returns {@code 0} on any rejection or error. + * + * @return a nonzero grant token, or {@code 0} if the over-commit was rejected + */ + public long tryOverCommitToken() { + return tryOverCommit().map(OverCommitLease::id).orElse(0L); + } + + /** + * FFM upcall entry point for native pools: releases the over-commit permit previously granted + * under {@code token}. An unknown, stale, or already-released token is a no-op, so a spurious + * native release can never over-release the permit gate. + * + * @param token the grant token returned by {@link #tryOverCommitToken()} + */ + public void releaseOverCommitToken(long token) { + OverCommitLease lease = outstandingOverCommits.get(token); + if (lease != null) { + lease.close(); + } + } + + /** + * A capability handle for a single granted over-commit. Minted only by + * {@link ArrowNativeAllocator#tryOverCommit()}; closing it releases the underlying permit exactly + * once (idempotent), so double-close and unpaired release are both harmless. + */ + public final class OverCommitLease implements AutoCloseable { + private final long id; + private final AtomicBoolean released = new AtomicBoolean(false); + + private OverCommitLease(long id) { + this.id = id; + } + + /** The opaque token identifying this grant (used to release across the native boundary). */ + public long id() { + return id; + } + + @Override + public void close() { + if (released.compareAndSet(false, true)) { + outstandingOverCommits.remove(id); + Semaphore permits = overCommitPermits.get(); + if (permits != null) { + permits.release(); + logger.debug("Released over-commit permit (token {}, {} permits now available)", id, permits.availablePermits()); + } + } + } + } + // ─── Public / SPI methods ─────────────────────────────────────────────────── @Override diff --git a/plugins/arrow-base/src/test/java/org/opensearch/arrow/allocator/ArrowBasePluginTests.java b/plugins/arrow-base/src/test/java/org/opensearch/arrow/allocator/ArrowBasePluginTests.java index df2673a5a8a7b..189cee5b33f39 100644 --- a/plugins/arrow-base/src/test/java/org/opensearch/arrow/allocator/ArrowBasePluginTests.java +++ b/plugins/arrow-base/src/test/java/org/opensearch/arrow/allocator/ArrowBasePluginTests.java @@ -28,12 +28,13 @@ public void testQuerySettingsExposeDefaults() { } public void testFlightAndIngestMinDerivedFromBudget() { - // With node.native_memory.limit set, mins derive as percentages + // With node.native_memory.limit set, mins derive as percentages. No warm role here, so + // ingest uses the non-warm default. Settings s = Settings.builder().put("node.native_memory.limit", "1gb").build(); long budget = 1024L * 1024 * 1024; - // flight min = 2% of budget, ingest min = 4% of budget + // flight min = 2% of budget, ingest min = 2% of budget (non-warm) assertEquals(Long.valueOf(budget * 2 / 100), ArrowBasePlugin.FLIGHT_MIN_SETTING.get(s)); - assertEquals(Long.valueOf(budget * 4 / 100), ArrowBasePlugin.INGEST_MIN_SETTING.get(s)); + assertEquals(Long.valueOf(budget * 2 / 100), ArrowBasePlugin.INGEST_MIN_SETTING.get(s)); } public void testPoolMaxDefaultsAreLongMaxValueWhenAcUnset() { @@ -47,14 +48,24 @@ public void testPoolMaxDefaultsScaleFromAcBudget() { Settings s = Settings.builder().put("node.native_memory.limit", "10gb").build(); long limit = 10L * 1024 * 1024 * 1024; assertEquals(Long.valueOf(limit * 5 / 100), ArrowBasePlugin.FLIGHT_MAX_SETTING.get(s)); - assertEquals(Long.valueOf(limit * 8 / 100), ArrowBasePlugin.INGEST_MAX_SETTING.get(s)); + // No warm role, so ingest uses the non-warm default (5%). + assertEquals(Long.valueOf(limit * 5 / 100), ArrowBasePlugin.INGEST_MAX_SETTING.get(s)); assertEquals(Long.valueOf(limit * 5 / 100), ArrowBasePlugin.QUERY_MAX_SETTING.get(s)); } + public void testIngestPoolDefaultsAreReducedOnWarmNodes() { + // Warm nodes shrink the ingest pool (min 1%, max 3%) to free budget for the metadata cache. + Settings s = Settings.builder().put("node.native_memory.limit", "10gb").putList("node.roles", "warm").build(); + long limit = 10L * 1024 * 1024 * 1024; + assertEquals(Long.valueOf(limit * 1 / 100), ArrowBasePlugin.INGEST_MIN_SETTING.get(s)); + assertEquals(Long.valueOf(limit * 3 / 100), ArrowBasePlugin.INGEST_MAX_SETTING.get(s)); + } + public void testPoolMaxDefaultsIgnoreBufferPercent() { Settings s = Settings.builder().put("node.native_memory.limit", "1000b").put("node.native_memory.buffer_percent", 20).build(); assertEquals(Long.valueOf(50L), ArrowBasePlugin.FLIGHT_MAX_SETTING.get(s)); - assertEquals(Long.valueOf(80L), ArrowBasePlugin.INGEST_MAX_SETTING.get(s)); + // No warm role, so ingest uses the non-warm default (5% of 1000 = 50). + assertEquals(Long.valueOf(50L), ArrowBasePlugin.INGEST_MAX_SETTING.get(s)); assertEquals(Long.valueOf(50L), ArrowBasePlugin.QUERY_MAX_SETTING.get(s)); } diff --git a/plugins/arrow-flight-rpc/docs/backpressure.md b/plugins/arrow-flight-rpc/docs/backpressure.md index 96bb69c78607c..6233b41d2181e 100644 --- a/plugins/arrow-flight-rpc/docs/backpressure.md +++ b/plugins/arrow-flight-rpc/docs/backpressure.md @@ -108,7 +108,7 @@ doesn't formally bound this. A byte-aware bounded queue at the eventloop entry — that parks (or rejects) the producer when the sum of queued batch sizes crosses a per-channel cap — -would close the gap. Open design questions: +would close the gap for the async path. Open design questions: - **Cap dimension**: byte-aware (sum of retained sizes) vs depth-aware (count). Bytes is correct for OOM protection. @@ -120,6 +120,21 @@ would close the gap. Open design questions: Tracked separately; not addressed in this change. +### Synchronous send (bounded, opt-in) + +A caller that needs a hard bound *today* can send synchronously: +`channel.sendResponseBatch(response, /* sync */ true)`. The batch is still +serialized and written on the channel's send executor (the same executor the +async path uses), but the calling thread **blocks until the batch has been +pushed to gRPC's per-stream outbound buffer**, so it cannot queue the next batch +ahead of this one — outstanding batches are bounded to one, with no eventloop +queue to grow. When that outbound buffer is full, the `isReady()` back-pressure +gate (above) throttles the producer. + +A caller that opts in **must drive a single stream from a single thread** and must +not call from the send-executor thread itself. The default +`sendResponseBatch(response)` is unchanged — async, via the eventloop. + ### ⚠️ Virtual threads for park-bound producers > **For workloads where the producer is mostly bottlenecked on diff --git a/plugins/arrow-flight-rpc/docs/channel-lifecycle-ownership.md b/plugins/arrow-flight-rpc/docs/channel-lifecycle-ownership.md new file mode 100644 index 0000000000000..162d0fcb3a639 --- /dev/null +++ b/plugins/arrow-flight-rpc/docs/channel-lifecycle-ownership.md @@ -0,0 +1,448 @@ +# FlightServerChannel Lifecycle & Buffer-Ownership Contract + +## Purpose + +Server-side Arrow Flight streaming frees off-heap Arrow buffers and terminates a gRPC +call from **multiple unsynchronized threads at once**. Without a single, explicit +ownership model this produces several classes of bug: + +1. **Double-close crash** — `close()` runs its teardown twice, double-firing the + TaskManager cancellable-channel untrack listener → `AssertionError` (with `-ea`) that + kills the node. +2. **Stream-root use-after-free / double-free** — a concurrent cancel frees the reused + stream root while the producer is mid-`transferRoot` or mid-`putNext` (gRPC still holds + a zero-copy reference). +3. **Stream-root / source-root leak** — buffers transferred into the reused root after a + concurrent close, or a source root for a batch whose send never runs, are owned by + nobody. +4. **Consumer hang** — a batch send fails, the batch is dropped, no schema frame ever + reaches the client, and the consumer's `FlightStream.getRoot()` blocks forever. + +This document is the **authoritative model**. It was validated by an exhaustive +case-sweep (98 enumerated cases across buffer / thread-interleaving / method-exit / +lifecycle-state axes; 64 confirmed defects in the current code, collapsing to **12 +distinct root-cause classes** — see §9). The implementation in `FlightServerChannel` and +`FlightOutboundHandler` MUST conform to it, and §9's coverage matrix MUST stay true. + +--- + +## 1. Threads + +All concurrency reduces to **three** thread classes per channel. The distinction between the +first two is load-bearing: the gap between them is where source roots can orphan (§6 S7). + +| Name | What it is | What it runs | +|------|------------|--------------| +| **`T_caller`** | The producer's calling thread (e.g. a `SEARCH` worker) inside `FlightOutboundHandler.sendResponseBatch`. | `awaitReadyOrThrow()` (the back-pressure gate) and `executor.execute(task)`. **This is where a batch is built but not yet handed to the channel.** | +| **`T_exec`** | The channel's **single-thread** flight executor (`FlightTransport.getNextFlightExecutor()`). | Everything the handler submits: `processBatchTask`→`sendBatch`, `processCompleteTask`→`completeStream`, `processErrorTask`→`sendError`, `failStream`. Also `ArrowFlightProducer.getStream`'s bootstrap. | +| **`T_grpc`** | gRPC's cancel-delivery thread (`cancelExecutor` in `JumpToApplicationThreadServerStreamListener`). | `onChannelCancelled()` → `close()`. | + +**Key consequences:** +- The flight executor is single-threaded, so **no two `T_exec` ops run concurrently with + each other.** Genuine concurrency is only `T_caller`/`T_exec`/bootstrap **vs** `T_grpc`. +- `T_caller` and `T_exec` are **different threads**: a batch can be parked in the gate on + `T_caller` while a *prior* batch runs on `T_exec`. The window between "batch built on + `T_caller`" and "`sendBatch` runs on `T_exec`" is where source roots orphan (§6 S7). + +### Single-writer model + +The stream root is the only state genuinely contended across threads: `T_exec` *uses* it +during transfer/`putNext`, while a cancel wants to *free* it. The channel resolves this by +**confining the root to a single thread** rather than synchronizing access to it: + +- **The stream `root` and every `serverStreamListener` call live only on `T_exec`** — created, + filled, and freed there. Because the executor is single-threaded, batch sends, terminal ops, + and the root free are serialized by the executor itself; none can interleave. +- **`close()` is a signal, not a buffer operation.** From any thread it flips `open` once + (CAS), fires close listeners, and **posts the root free onto `T_exec`**: + `executor.execute(() -> { if (root != null) { root.close(); root = null; } })`. Because the + executor is FIFO, that free runs *after* any in-flight send completes. `close()` touches no + Arrow buffers and no gRPC from the caller's (possibly `T_grpc`) thread. + +**This is deadlock-free by construction:** nothing is ever held across a gRPC/Arrow/blocking +call. The only mutex (`closeListenerMutex`, §4) is a leaf — its critical section only mutates an +`ArrayList`. `close()` from `T_grpc` never blocks: `execute()` enqueues and returns. There is no +waits-for cycle to construct. The single invariant a future change must preserve is the +self-evident **"`root` and `serverStreamListener` are touched only on the flight executor."** + +The one trade-off is that `close()` frees the root **asynchronously** (on the executor) rather +than inline — see §10. + +--- + +## 2. Roles & responsibilities + +| Component | Role | Owns | MUST NOT | +|-----------|------|------|----------| +| **`FlightServerChannel`** | **Sole owner** of all Arrow buffers and the gRPC stream lifecycle. The only place a root is freed and the only place a terminal gRPC op is issued. | stream `root`, gRPC listener, `terminalSent`, close listeners | leak/double-free a root; issue two terminal ops; act on the stream after terminal or after `cancelled` | +| **`FlightOutboundHandler`** | **Stateless translator/dispatcher.** Thread-switch onto `T_exec`, back-pressure gate, translate response→channel call, relay outcome. Owns the **hand-off discipline** (§3) for the source root until the channel takes it. | nothing persistent | touch/free the *stream* root; call `close()` directly; serialize a response itself | +| **`FlightTransportChannel`** | Thin framework adapter. `release()` → `channel.close()`. | nothing | contain its own close/idempotency logic | +| **`ArrowFlightProducer`** | Per-call bootstrap. May `close()` on early/inbound errors. | nothing | assume "double close is harmless" without the channel guaranteeing it | +| **`T_grpc` (cancel)** | Signals cancellation → `close()`. | — | — | + +**Pivotal rule:** the handler hands the channel a `TransportResponse` and a **header +supplier**, and receives back success or an exception. It never frees the *stream* root, +never serializes the header itself, and never closes the channel directly. Its one buffer +responsibility is the **source-root hand-off** (§3.1). + +--- + +## 3. Buffer ownership — every buffer freed exactly once + +| Buffer | Created by | Freed by | When | +|--------|-----------|----------|------| +| **Source root** — `ArrowBatchResponse.getRoot()`, the producer's per-batch native batch | the caller/producer | **the channel** — via `sendBatch`'s `finally` if handed off, else via `channel.releaseUnsent(response)` on the failed-handoff path (§3.1) | exactly once | +| **Stream root** — the single reused VSR (native *or* byte) sent on the wire | the channel, lazily on the first batch | **the channel**, in `close()` only | terminal, exactly once | +| **Metadata buf** — per-batch app metadata on the Flight frame | the channel, per batch | gRPC via `putNext(ArrowBuf)`, or the channel if `putNext` throws before hand-off (§3.3) | handoff | +| **Byte-serialization vector** — the `VarBinary` vector of the byte stream root | the channel (it *is* the stream root's vector) | **the channel**, in `close()` | terminal, exactly once — **never per-batch** (§3.2) | + +**Zero-copy retention:** native stream-root buffers are **not** freed right after `putNext` +— gRPC may still hold zero-copy refs. They are freed once, at `close()`, after the +framework's release boundary. The reused stream root lives from the first batch until +`close()`. + +### 3.1 Source-root hand-off discipline (closes S7 — the orphan class) + +The source root has **two** potential free sites on **two** threads, and the gap between +them is unguarded. Ownership transfers to the channel at **`sendResponseBatch` entry**, and +the handler guarantees the source is freed in **exactly one** of two mutually-exclusive +places via a `handedOff` flag: + +``` +sendResponseBatch(response, ...): // T_caller + handedOff = false + try: + if (!(channel instanceof FlightServerChannel)) { relay error; return; } // handedOff stays false + flightChannel.awaitReadyOrThrow() // may throw CANCELLED/TIMED_OUT + flightChannel.getExecutor().execute(task) // may throw RejectedExecutionException + handedOff = true // sendBatch now owns it; its finally frees it + finally: + if (!handedOff) flightChannel.releaseUnsent(response) // free here, exactly once +``` + +- **Handed off** → `sendBatch` runs on `T_exec` → its `finally` frees the source (§3.4). +- **Not handed off** (gate throws, `execute` rejects, or wrong-channel-type early return) + → handler frees via `releaseUnsent`. +- Mutually exclusive ⇒ **freed exactly once.** + +`releaseUnsent(response)` is a channel method (keeps "only the channel frees Arrow +buffers" intact): it closes `response.getRoot()` **iff** `response instanceof +ArrowBatchResponse` (the byte path has no off-heap source). It does not touch the stream +root or lifecycle. + +> A second hand-off gap is **inside** `processBatchTask` on `T_exec`: `getHeaderBuffer` +> (which `throws IOException`) and `VectorSchemaRoot.create`/`transferRoot` can throw +> **before** the channel adopts the source. This is handled inside `sendBatch` itself +> (§3.4), not by the handler — see the unified single-entry contract in §5. + +### 3.2 Byte path: stream-root vector freed once, never per-batch + +The byte-serialized `VectorStreamOutput.ByteSerialized` vector **is** the stream root's +`VarBinary` vector once adopted. Freeing it per batch (today's `try (out)`) frees buffers +gRPC may still be draining (zero-copy) and races `close()`. The channel reuses the vector +across batches (`reset()`/`setValueCount(0)`) and frees it once in `close()`. +`ByteSerialized.close()` must free **only** a vector it itself created (the first-batch, +`existingRoot==null` case); when adopting the channel's existing root it releases nothing. + +### 3.3 Metadata buf: freed if `putNext` throws before hand-off + +``` +buf = allocator.buffer(len); buf.writeBytes(metadata) +try { listener.putNext(buf) } catch (t) { if (buf.refCnt() > 0) buf.close(); throw t } +``` +Covers the windows where Flight never adopts the buffer (`waitUntilStreamReady` throw, +`ArrowMessage` ctor throw). The `refCnt()` guard tolerates the narrow `onNext`-throw +subwindow where Flight's `ArrowMessage.close()` already released it. + +### 3.4 Source-root free inside `sendBatch` (covers transfer/create/header throws) + +``` +finally: if (native && sourceRoot != null) sourceRoot.close() +``` +The native source root is owned exclusively by this `sendBatch` call, so it is freed +**unconditionally** in the `finally` on every exit: after a successful transfer it is empty +(close is a buffer no-op), and on any throw before the transfer — empty-field-vectors check, +`VectorSchemaRoot.create` OOM, `transferRoot` mid-loop, the header build — it still holds its +buffers (close frees them). **A bare `producerRoot.close()` on the happy path plus a catch-only +cleanup is insufficient** (today's bug): the free must be in a `finally` covering every exit. +On the native **first batch**, the freshly-created stream root is freed by +`transferIntoStreamRoot`'s own try/finally if transfer fails before adoption, so an un-adopted +stream root cannot leak before becoming `root`. + +### 3.5 Adopted stream root after a non-`Exception` `Throwable` in `sendBatch` + +`serverStreamListener.start`/`putNext` and `transferRoot` can throw a non-`Exception` +`Throwable` — `OutOfMemoryError` under memory pressure, or an `AssertionError` from Arrow/gRPC +under `-ea`. If this happens **after** the stream root is adopted (`root != null`), +`sendBatch`'s `finally` frees only the *source* root; the *stream* root is freed only by +`close()`. The batch task is non-terminal, so `BatchTask.close()` does not release the channel — +nothing would call `close()` and the adopted stream root would leak. `processBatchTask` therefore +catches `Throwable` (after `FlightRuntimeException`/`Exception`) and routes it through `failStream` +(which releases the channel → `close()` posts the stream-root free) and reports it to the message +listener (wrapped to honour the listener's `Exception` contract) rather than letting it escape and +kill the executor worker. `failStream` closes the channel directly when there is no transport +channel, so `close()` runs on every variant. + +```java +private final AtomicBoolean open; // "resources not yet freed"; flips true→false ONCE via CAS +private volatile boolean cancelled; // set once by T_grpc in onChannelCancelled +private volatile VectorSchemaRoot root; // reused native|byte stream root; T_exec-confined; nulled after free +private boolean terminalSent; // completed() XOR error() issued; T_exec-confined +private final Object closeListenerMutex; // leaf mutex: guards closeListeners + closeListenersFired only +private final List> closeListeners; +private boolean closeListenersFired; // guarded by closeListenerMutex +``` + +- **`open`** — the teardown gate. `compareAndSet(true,false)` admits **exactly one** thread + into teardown; every other `close()` caller returns immediately. The CAS winner is the sole + poster of the root free and sole firer of close listeners. **Thread-agnostic.** +- **`cancelled`** — "client gone; gRPC already terminated the call." Read by `sendBatch`, + `completeStream`, `sendError` to reject/no-op. +- **`root`** — touched **only on `T_exec`** (created, filled, freed there). `volatile` only so the + test-only `getRoot()` reads a consistent reference; correctness rests on executor confinement, + not volatility. Nulled immediately after free so nothing re-frees or re-fills it. +- **`terminalSent`** — `T_exec`-confined; at most one terminal gRPC op per stream. +- **`closeListenerMutex`** — a leaf mutex; its critical section only mutates the listener list + + `closeListenersFired`, never a gRPC/Arrow/blocking call. It is the *only* mutex in the class. + +### Invariants (must hold under every interleaving) + +1. `notifyCloseListeners()` runs **exactly once** (CAS winner only) → TaskManager untrack + fires once → no `AssertionError`, any thread, any order. +2. `root.close()` runs **exactly once** — only in the free task the CAS-winning `close()` posts + to `T_exec`; `root` is nulled there. (If a send adopted no root, the task is a no-op.) +3. **At most one terminal gRPC op** (`completed` XOR `error`) per stream, and **never after + `cancelled`** (`completeStream`/`sendError` check `cancelled`, both on `T_exec`). +4. **No root freed under an in-flight send** — the free runs on `T_exec` behind any in-flight + send by the executor's FIFO ordering; root use and root free never overlap (single-writer). +5. **No source root leaked or double-freed** — freed exactly once via §3.1 (handoff) + + §3.4 (in-send `finally`), mutually exclusive. +6. **No deadlock** — no lock is held across any gRPC/Arrow/blocking call; the only mutex + (`closeListenerMutex`) is a leaf; `close()` only `execute()`s and returns. No waits-for cycle. +7. **Close-listener registration is safe** — `addCloseListener` either fires immediately + (listeners already fired) or enqueues under `closeListenerMutex`, with no lost/duplicate fire. +8. **A throwing close listener cannot skip the rest** — `notifyCloseListeners` isolates each + listener in try/catch so one failure can't strand TaskManager untrack. +9. **Single-writer invariant (the property future changes must preserve):** `root` and every + `serverStreamListener` call are touched **only on `T_exec`**. + +--- + +## 5. Method contracts + +All of `sendBatch`/`completeStream`/`sendError` run on `T_exec` and take **no lock** — the +single-threaded executor serializes them with each other and with the `close()`-posted root free. + +### `sendBatch(TransportResponse response, CheckedSupplier header)` — `T_exec`, no lock + +Single entry; native vs byte decided **inside** the channel. The header is built via a +supplier invoked **before any root is mutated**, so a header-build `IOException` fails fast — +the source is freed by the `finally` and no stream root is adopted. The handler never +serializes. The native source root is freed once on every exit by the outer `finally` +(unconditional: empty after a successful transfer → close is a no-op; full on an early throw → +close frees it). + +``` +sourceRoot = (response instanceof ArrowBatchResponse) ? response.getRoot() : null +try: + if (cancelled) throw StreamException.cancelled(...) // reject → finally frees source + if (!open.get()) throw IllegalStateException(...) // reject → finally frees source + firstBatch = (root == null) + hdr = header.get() // built FIRST; throw → finally, no root mutated + if (native): + if (firstBatch) root = create VSR from sourceRoot's allocator // create+transfer guarded so an + VectorTransfer.transferRoot(sourceRoot -> root) // un-adopted first root can't leak + else (byte): + if (firstBatch) root = new VarBinary VSR + else reset root's vector + response.writeTo(serializer over root) + if (firstBatch): middleware.setHeader(hdr); listener.start(root) + if (metadata != null): buf=alloc; writeBytes; try{ listener.putNext(buf) } catch(t){ buf.close(); throw t } + else: listener.putNext() + batchNumber++; record stats +finally: + if (native && sourceRoot != null) sourceRoot.close() // every exit frees source ONCE +``` + +### `completeStream(CheckedSupplier header)` — terminal, idempotent, `T_exec`, no lock + +``` +if (!open.get() || cancelled || terminalSent) { record/log; return; } // benign no-op +if (root == null) middleware.setHeader(header.get()) // empty stream +listener.completed(); terminalSent = true +recordCallEnd(OK) +``` +Does **not** free `root` (free deferred to `close()`, §3 retention). The `cancelled` guard +closes the "terminal-after-cancel" race. + +### `sendError(CheckedSupplier header, Exception error)` — terminal, idempotent, `T_exec`, no lock + +``` +if (!open.get() || cancelled || terminalSent) { record/log; return; } // benign no-op +flightExc = map(error); middleware.setHeader(header.get()) +listener.error(flightExc); terminalSent = true +recordCallEnd(mapped) +``` + +### `releaseUnsent(TransportResponse response)` — `T_caller`/`T_exec` failed-handoff (§3.1) + +``` +if (response instanceof ArrowBatchResponse r && r.getRoot() != null) r.getRoot().close(); +// never touches stream root, terminalSent, or open +``` + +### `close()` — fire-once, idempotent, ANY thread + +``` +if (!open.compareAndSet(true, false)) return; // losers exit at the door +executor.execute(() -> { if (root != null) { root.close(); root = null; } }) // free ON T_exec, behind any send +// (catch RejectedExecutionException on shutdown — see §10) +notifyCloseListeners(); // runs exactly once; touches no Arrow buffers +``` +`close()` posts the root free to `T_exec` rather than freeing inline, so it never touches Arrow +buffers or gRPC from the (possibly `T_grpc`) caller thread, and the free runs behind any +in-flight send by the executor's FIFO ordering. + +### `addCloseListener(listener)` — registration atomic vs `notifyCloseListeners` + +``` +synchronized (closeListenerMutex): + alreadyFired = closeListenersFired + if (!alreadyFired) closeListeners.add(listener) +if (alreadyFired) listener.onResponse(null) // fire outside the mutex +``` +Prevents the register-vs-fire race (lost fire / double fire). + +### `notifyCloseListeners()` — fault-isolated + +``` +synchronized (closeListenerMutex): snapshot = copy(closeListeners); clear(); closeListenersFired = true +for (l : snapshot) try { l.onResponse(null) } catch (e) { log; continue } // one throw can't strand others +``` + +### `onChannelCancelled()` — `T_grpc` + +``` +if (cancelled) return; +cancelled = true; recordCallEnd(CANCELLED); close(); +``` + +--- + +## 6. Scenario walk-through + +`✓` = happens exactly once / as intended. + +- **S1 Normal multi-batch success** — `sendBatch×N` (each frees its source ✓) → + `completeStream` (`completed()` ✓) → `release()→close()` (free root ✓, notify ✓). +- **S2 App error / batch-send failure** — send throws → `sendError` (`error()` ✓) → + `release()→close()`. Consumer gets an error frame, no hang. +- **S3 Client cancel, idle channel** — `T_grpc close()` frees root ✓, notify ✓. Later queued + `sendBatch` sees `cancelled` → throws → `finally` frees source ✓. Later + `completeStream`/`sendError` → `cancelled` no-op ✓. +- **S4 Cancel races an in-flight `sendBatch` (UAF/double-free)** — `T_exec` is mid + transfer+`putNext` (occupying the single executor thread); `T_grpc close()` wins the CAS, + **posts the root free onto the busy executor**, and returns without touching buffers. The + free runs only after `sendBatch` finishes (FIFO) → frees root ✓ strictly after `putNext`. No + UAF, freed once, notify once. +- **S5 Cancel races a `release()`-driven close (the crash)** — both call `close()`; + `compareAndSet` admits one; the other returns. notify once ✓ → no `AssertionError`. +- **S6 Early/inbound error in `getStream`** — `listener.error` then `channel.close()`; CAS + admits one closer even if `T_grpc` also cancels ✓. +- **S7 Cancel/timeout while a batch is parked in the gate (the orphan)** — + `awaitReadyOrThrow()` throws on `T_caller` **before** `execute`; `handedOff==false` → + handler's `finally` calls `releaseUnsent` → source freed ✓. Same for `execute` rejection + and the wrong-channel early return. +- **S8 `getHeaderBuffer`/`transferRoot`/`create` throws inside `sendBatch`** — lands in the + `finally`; source freed once ✓; no stream root adopted (or freed by a later `close()` if + adopted). No orphan. +- **S9 Byte multi-batch + cancel** — vector reused across batches on `T_exec`; freed once at + `close()` (on `T_exec`, behind any send) ✓; no per-batch free, no write-vs-free race. +- **S10 Non-`Exception` `Throwable` after stream-root adoption** — `putNext`/`start`/transfer + throws OOM/`AssertionError` once `root != null`. `sendBatch`'s `finally` frees the source ✓; + `processBatchTask`'s `catch (Throwable)` runs `failStream` → channel released → `close()` posts + the stream-root free ✓; listener notified, worker survives. No leak (§3.5). + +--- + +## 7. Design decisions (explicit) + +1. **Terminal op separate from root-free.** `completeStream`/`sendError` issue the gRPC + terminal op and set `terminalSent`; `root` is freed later in `close()`, preserving the + zero-copy drain window. (Folding free into `completeStream` shrinks that window — not + chosen.) +2. **Single `sendBatch(response, headerSupplier)` entry.** Native/byte decided in the + channel; the handler never touches a stream root and never serializes the header. +3. **Header built by the channel via a supplier, before any root is mutated.** This is + correctness-critical (not cosmetic): `getHeaderBuffer` throws `IOException`, and building it + inside `sendBatch` puts that throw inside the source-freeing `finally` — and building it + before the transfer means a header failure adopts no stream root. +4. **Single-writer confinement, not synchronization.** The stream root and all + `serverStreamListener` calls are confined to the flight executor; `close()` posts the root + free there rather than coordinating concurrent access to it. This makes the design + deadlock-free by construction (nothing is held across a gRPC/Arrow call) at the cost of an + asynchronous root free (§10). The invariant future changes must keep is the self-evident + "root is touched only on the executor." + +--- + +## 8. Relationship to the consumer-hang fix (PR #22117) + +PR #22117 fixes **S2's symptom** (a failed batch send calls `sendError`+release so the +consumer gets an error, not a hang) and relocates the native transfer into the channel. +That behaviour is preserved here. What this contract adds is the **concurrency safety** PR +#22117 lacks: it leaves `close()` non-atomic and the stream root accessible from both the +producer and the gRPC cancel thread, so S4 (UAF/double-free) and S5 (crash) remain reachable — +and its new `failStream → releaseChannel → close()` adds *another* concurrent `close()` caller. +This model closes S4/S5/S7–S9 while keeping S2 fixed. + +--- + +## 9. Coverage matrix — the 12 distinct root-cause classes + +The case-sweep confirmed 64 defects in the current code; verified-and-deduplicated they are +12 classes. Each MUST map to a model mechanism. (`#` = confirmed-defect count folded in.) + +| # | Root-cause class | Sev | Bug type | Closed by | +|---|------------------|-----|----------|-----------| +| 1 | Non-atomic `close()` double-entry → double-free root + double `notifyCloseListeners` (crash) | blocker | crash/double-free | `open.compareAndSet` (inv 1,2); S5 | +| 2 | Cancel frees stream root mid-`transferRoot`/`putNext` (UAF) | blocker | UAF | single-writer: root free posted to `T_exec`, behind any send (inv 4); S4 | +| 3 | Cancel after transfer-into-reused-root, send rejects → transferred buffers leak; `root` never nulled | blocker | leak/UAF | gate **before** transfer + null-after-free; free serialized on `T_exec` (inv 2,4); S4 | +| 4 | Source root orphaned when send never runs: gate throw / `execute` reject / wrong-channel return | blocker | leak | `handedOff` + `releaseUnsent` (§3.1, inv 5); S7 | +| 5 | Source root orphaned inside `sendBatch`: `getHeaderBuffer`/`create`/`transferRoot` throw | major | leak | header built before transfer + `finally` frees source (§3.4, §5); S8 | +| 6 | Byte path: per-batch `out.close()` frees reused vector (UAF vs zero-copy & vs cancel) | blocker | UAF | channel owns vector, freed once in `close()` (§3.2); S9 | +| 7 | Terminal-after-cancel: `completeStream`/`sendError` issue terminal op after cancel | major | illegal transition | `cancelled` guard (inv 3) | +| 8 | Metadata buf leaked when `putNext` throws before hand-off | minor | leak | try/catch + `refCnt` close (§3.3) | +| 9 | `addCloseListener` races `close()` flip+clear → lost fire / CME / tracker-map leak | major | leak/visibility | atomic registration (inv 7, §5) | +| 10 | Throwing close listener strands remaining listeners (TaskManager untrack skipped) | minor | leak | per-listener try/catch (inv 8, §5) | +| 11 | `shutdownNow` drops queued tasks undrained → source roots leak | minor | leak (shutdown-only) | **documented limitation** — node teardown; OS reclaims on exit (§10) | +| 12 | Wrong-channel-type / NOOP-listener early returns (defensive `instanceof`) | info | n/a | covered by §3.1 handoff (handedOff stays false → `releaseUnsent`) | +| 13 | Non-`Exception` `Throwable` (OOM / `AssertionError`) from `start`/`putNext`/transfer **after** stream-root adoption → `processBatchTask` only caught `Exception`, so the channel was never released → adopted stream root leaks | major | leak | `catch (Throwable)` in `processBatchTask` routes through `failStream` (releases channel → `close()` frees the root) + notifies the listener; `failStream` closes the channel directly when there is no transport channel (§3.5); S10 | + +--- + +## 10. Known limitations (not closed by this model) + +- **Asynchronous stream-root free (a consequence of the single-writer choice).** Because + `close()` posts the root free onto the flight executor, the buffers are freed slightly after + `close()` returns, not inline. Functionally this is the point (it serializes the free behind + any in-flight send), but two implications follow: (a) tests asserting allocator state after + `close()` must drain the executor first; (b) if the executor is already shut down when + `close()` runs, the `execute()` is rejected and the stream root is not freed — see the next + bullet. This is the deliberate trade the single-writer model makes for being deadlock-free by + construction (§1, §7 decision 4). +- **Node-shutdown drain gap.** On `executor.shutdownNow()`, queued tasks — including a + `close()`-posted root free, and any never-run `BatchTask`s — are dropped without running, so + their roots are not freed. Likewise, if `close()`'s `executor.execute(...)` is rejected + (executor already shut down), the root free never runs; `close()` logs this at **WARN** (not + debug) so the shutdown-time leak is visible. We deliberately do **not** free the root inline on + rejection: a rejection from `shutdown()` refuses new tasks while an already-running send may + still be touching the root on the executor thread, so freeing from the (possibly gRPC) caller + thread would reintroduce the use-after-free this model prevents. This is **shutdown-only** (the + node is stopping; the OS reclaims off-heap memory on process exit), so impact is limited to + noisy Arrow leak assertions / test flakiness, not a sustained production leak. Closing it fully + would require draining queued tasks before `shutdownNow`; tracked separately. +- **Final-frame zero-copy drain.** Whether gRPC can still hold a reference to the *last* + frame's buffers when `close()` frees `root` is a pre-existing concern with the same shape + in today's code; out of scope here. +``` diff --git a/plugins/arrow-flight-rpc/src/main/java/org/opensearch/arrow/flight/bootstrap/ServerConfig.java b/plugins/arrow-flight-rpc/src/main/java/org/opensearch/arrow/flight/bootstrap/ServerConfig.java index 73bab9a8cddf9..71ed5ea537e56 100644 --- a/plugins/arrow-flight-rpc/src/main/java/org/opensearch/arrow/flight/bootstrap/ServerConfig.java +++ b/plugins/arrow-flight-rpc/src/main/java/org/opensearch/arrow/flight/bootstrap/ServerConfig.java @@ -22,7 +22,9 @@ import java.security.PrivilegedAction; import java.util.ArrayList; import java.util.Arrays; +import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.function.Function; import io.netty.channel.Channel; @@ -187,6 +189,55 @@ public ServerConfig() {} Setting.Property.NodeScope ); + // gRPC server keepalive. Defaults match gRPC's own server defaults (time 2h, timeout 20s) so + // behaviour is unchanged from before this setting existed; lower the time for faster + // dead-connection detection (releases resources held by a stalled stream on a half-open connection). + static final Setting FLIGHT_KEEPALIVE_TIME = Setting.timeSetting( + "flight.keepalive.time", + TimeValue.timeValueHours(2), + TimeValue.timeValueSeconds(1), + Setting.Property.NodeScope + ); + + // Must be strictly less than FLIGHT_KEEPALIVE_TIME (else a slow-but-alive peer is + // false-disconnected); enforced by the validator below. + static final Setting FLIGHT_KEEPALIVE_TIMEOUT = new Setting<>( + "flight.keepalive.timeout", + TimeValue.timeValueSeconds(20).getStringRep(), + s -> TimeValue.parseTimeValue(s, "flight.keepalive.timeout"), + new KeepAliveTimeoutValidator(), + Setting.Property.NodeScope + ); + + /** Enforces {@code flight.keepalive.timeout < flight.keepalive.time}. */ + static final class KeepAliveTimeoutValidator implements Setting.Validator { + @Override + public void validate(final TimeValue value) {} + + @Override + public void validate(final TimeValue timeout, final Map, Object> settings) { + final TimeValue time = (TimeValue) settings.get(FLIGHT_KEEPALIVE_TIME); + if (timeout.nanos() >= time.nanos()) { + throw new IllegalArgumentException( + "[" + + FLIGHT_KEEPALIVE_TIMEOUT.getKey() + + "] (" + + timeout + + ") must be less than [" + + FLIGHT_KEEPALIVE_TIME.getKey() + + "] (" + + time + + ")" + ); + } + } + + @Override + public Iterator> settings() { + return List.>of(FLIGHT_KEEPALIVE_TIME).iterator(); + } + } + /** * The thread pool name for the Flight producer handling */ @@ -207,6 +258,8 @@ public ServerConfig() {} private static int threadPoolMax; private static TimeValue keepAlive; private static int eventLoopThreads; + private static TimeValue grpcKeepAliveTime; + private static TimeValue grpcKeepAliveTimeout; /** * Initializes the server configuration with the provided settings. @@ -230,6 +283,18 @@ public static void init(Settings settings) { threadPoolMax = FLIGHT_THREAD_POOL_MAX_SIZE.get(settings); keepAlive = FLIGHT_THREAD_POOL_KEEP_ALIVE.get(settings); eventLoopThreads = FLIGHT_EVENT_LOOP_THREADS.get(settings); + grpcKeepAliveTime = FLIGHT_KEEPALIVE_TIME.get(settings); + grpcKeepAliveTimeout = FLIGHT_KEEPALIVE_TIMEOUT.get(settings); + } + + /** gRPC keepalive PING interval on the Flight server (connection-level liveness). */ + public static TimeValue getGrpcKeepAliveTime() { + return grpcKeepAliveTime; + } + + /** gRPC keepalive ack timeout: kill the connection if a PING is unacked for this long. */ + public static TimeValue getGrpcKeepAliveTimeout() { + return grpcKeepAliveTimeout; } /** @@ -290,6 +355,8 @@ public static List> getSettings() { ARROW_ENABLE_DEBUG_ALLOCATOR, ARROW_ENABLE_UNSAFE_MEMORY_ACCESS, ARROW_SSL_ENABLE, + FLIGHT_KEEPALIVE_TIME, + FLIGHT_KEEPALIVE_TIMEOUT, FLIGHT_EVENT_LOOP_THREADS, FLIGHT_THREAD_POOL_MIN_SIZE, FLIGHT_READY_TIMEOUT, diff --git a/plugins/arrow-flight-rpc/src/main/java/org/opensearch/arrow/flight/transport/FlightErrorMapper.java b/plugins/arrow-flight-rpc/src/main/java/org/opensearch/arrow/flight/transport/FlightErrorMapper.java index b8f77a49313bf..2ca5501ff442c 100644 --- a/plugins/arrow-flight-rpc/src/main/java/org/opensearch/arrow/flight/transport/FlightErrorMapper.java +++ b/plugins/arrow-flight-rpc/src/main/java/org/opensearch/arrow/flight/transport/FlightErrorMapper.java @@ -55,10 +55,13 @@ public static FlightRuntimeException toFlightException(StreamException exception // TODO insert all entries and not just the first one flightMetadata.insert(entry.getKey(), entry.getValue().getFirst()); } - status.withMetadata(flightMetadata); + status = status.withMetadata(flightMetadata); } - status.withDescription(exception.getMessage()); - status.withCause(exception.getCause()); + // CallStatus is an immutable builder — withDescription returns a NEW instance. Without reassignment + // the description is silently dropped and the caller sees gRPC's placeholder + // ("Internal error [task_id=N]") with no message. The cause is already set by mapToCallStatus + // (the StreamException itself), so no withCause needed here. + status = status.withDescription(exception.getMessage() != null ? exception.getMessage() : "Stream error"); return status.toRuntimeException(); } diff --git a/plugins/arrow-flight-rpc/src/main/java/org/opensearch/arrow/flight/transport/FlightOutboundHandler.java b/plugins/arrow-flight-rpc/src/main/java/org/opensearch/arrow/flight/transport/FlightOutboundHandler.java index 9841a3b376a6d..713674b7702cc 100644 --- a/plugins/arrow-flight-rpc/src/main/java/org/opensearch/arrow/flight/transport/FlightOutboundHandler.java +++ b/plugins/arrow-flight-rpc/src/main/java/org/opensearch/arrow/flight/transport/FlightOutboundHandler.java @@ -9,11 +9,10 @@ package org.opensearch.arrow.flight.transport; import org.apache.arrow.flight.FlightRuntimeException; -import org.apache.arrow.vector.FieldVector; -import org.apache.arrow.vector.VectorSchemaRoot; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.message.ParameterizedMessage; import org.opensearch.Version; -import org.opensearch.arrow.transport.ArrowBatchResponse; -import org.opensearch.arrow.transport.VectorTransfer; import org.opensearch.cluster.node.DiscoveryNode; import org.opensearch.common.io.stream.BytesStreamOutput; import org.opensearch.core.common.bytes.BytesReference; @@ -27,19 +26,34 @@ import org.opensearch.transport.TransportRequest; import org.opensearch.transport.TransportRequestOptions; import org.opensearch.transport.nativeprotocol.NativeOutboundMessage; +import org.opensearch.transport.stream.StreamErrorCode; import org.opensearch.transport.stream.StreamException; import java.io.IOException; import java.nio.ByteBuffer; -import java.util.List; import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; /** * Outbound handler for Arrow Flight streaming responses. - * It must invoke messageListener and relay any exception back to the caller and not supress them + * + *

This handler is a stateless translator: it switches onto the channel's flight executor, gates + * on back-pressure, and hands each {@link TransportResponse} to {@link FlightServerChannel}. It does + * not own or free the stream root and never closes the channel directly — the channel is the sole + * owner of all Arrow buffers (see {@code docs/channel-lifecycle-ownership.md}). + * + *

Its one buffer responsibility is the source-root hand-off: ownership of a native + * response's root transfers to the channel when {@link #sendResponseBatch} is called, and the handler + * guarantees it is freed exactly once — by {@link FlightServerChannel#sendBatch} once the batch is + * handed off, or by {@link FlightServerChannel#releaseUnsent} if the batch never reaches the channel + * (the back-pressure gate threw or the executor rejected the task). + * + *

It must invoke the messageListener and relay any exception back to the caller and not suppress them. * @opensearch.internal */ class FlightOutboundHandler extends ProtocolOutboundHandler { + private static final Logger logger = LogManager.getLogger(FlightOutboundHandler.class); private volatile TransportMessageListener messageListener = TransportMessageListener.NOOP_LISTENER; private final String nodeName; private final Version version; @@ -109,7 +123,8 @@ public void sendResponseBatch( final String action, final TransportResponse response, final boolean compress, - final boolean isHandshake + final boolean isHandshake, + final boolean sync ) throws IOException { BatchTask task = new BatchTask( nodeVersion, @@ -127,23 +142,75 @@ public void sendResponseBatch( ); if (!(channel instanceof FlightServerChannel flightChannel)) { + // Defensive: this transport always uses FlightServerChannel. There is no channel to take + // ownership, so free the source here so it cannot leak. + FlightServerChannel.releaseUnsentSource(response); messageListener.onResponseSent(requestId, action, new IllegalStateException("Expected FlightServerChannel")); return; } - // Block the producer thread before queuing the batch so a slow consumer throttles - // allocation rather than letting the eventloop's queue grow. Note: isReady() - // reflects only gRPC's outbound buffer, not our own queue depth — see - // docs/backpressure.md "Known limitation: unbounded eventloop queue". - flightChannel.awaitReadyOrThrow(); + // The channel owns the response's buffers from this point. The batch is freed exactly once: + // either sendBatch runs on the executor (its finally frees the source), or it never does + // (gate throws / executor rejects) and releaseUnsent frees it here. The handedOff flag keeps + // these two paths mutually exclusive. + boolean handedOff = false; + try { + // Block the producer thread before queuing the batch so a slow consumer throttles + // allocation rather than letting the eventloop's queue grow. Note: isReady() + // reflects only gRPC's outbound buffer, not our own queue depth — see + // docs/backpressure.md "Known limitation: unbounded eventloop queue". + flightChannel.awaitReadyOrThrow(); - flightChannel.getExecutor().execute(threadPool.getThreadContext().preserveContext(() -> { - try (BatchTask ignored = task) { - processBatchTask(task); - } catch (Exception e) { - messageListener.onResponseSent(requestId, action, e); + final Runnable sendBatch = threadPool.getThreadContext().preserveContext(() -> { + try (BatchTask ignored = task) { + processBatchTask(task); + } catch (Exception e) { + messageListener.onResponseSent(requestId, action, e); + } + }); + // sync blocks the caller until the batch has been pushed to gRPC's outbound buffer (bounding + // outstanding batches to one; a full buffer is throttled by the readiness gate above); async + // returns once it is queued. Both run on the channel executor, and both transfer source + // ownership to that task the moment it is accepted — so handedOff is set before any blocking + // wait, ensuring the finally's releaseUnsent never double-frees a source the task already owns. + if (sync) { + Future future = flightChannel.getExecutor().submit(sendBatch); + handedOff = true; // task accepted; it now owns (and will free) the source + awaitSend(future); + } else { + flightChannel.getExecutor().execute(sendBatch); + handedOff = true; } - })); + } finally { + if (handedOff == false) { + flightChannel.releaseUnsent(response); + } + } + } + + /** + * Blocks the caller until the executor has run the submitted send, so the caller cannot submit the + * next batch until this one has been pushed to gRPC's outbound buffer (a full buffer is throttled by + * the readiness back-pressure gate). The send itself runs on the channel's flight executor (so it is + * serialized with the stream-root free that {@code close()} posts to the same executor); this only + * parks the caller for the result. The caller must be a producer thread, not the flight executor + * thread itself (blocking on the result from that thread would deadlock the single-threaded executor). + */ + private void awaitSend(Future future) { + try { + future.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new StreamException(StreamErrorCode.INTERNAL, "Interrupted while sending batch synchronously", e); + } catch (ExecutionException e) { + // The work body catches its own exceptions and routes them to the listener, so this is not + // expected; surface it rather than swallow if it ever happens. + Throwable cause = e.getCause() != null ? e.getCause() : e; + if (cause instanceof StreamException se) { + throw se; + } + throw new StreamException(StreamErrorCode.INTERNAL, "Error sending batch synchronously", cause); + } } private void processBatchTask(BatchTask task) { @@ -154,38 +221,60 @@ private void processBatchTask(BatchTask task) { } try { - VectorStreamOutput out; - byte[] metadata = null; - if (task.response() instanceof ArrowBatchResponse arrowResponse) { - metadata = arrowResponse.getMetadata(); - // Native Arrow path: zero-copy transfer producer's vectors into stream root - VectorSchemaRoot streamRoot = flightChannel.getRoot(); - if (streamRoot == null) { - // Create stream root using the producer's allocator for same-allocator transfer. - // This avoids an Arrow bug where cross-allocator transferOwnership of foreign-backed - // buffers (from C data import) doesn't properly free the ArrowArray C struct. - // The producer's allocator must be long-lived (not closed per-request). - List fieldVectors = arrowResponse.getRoot().getFieldVectors(); - if (fieldVectors.isEmpty()) { - throw new IllegalStateException("Native Arrow batch has no field vectors"); - } - streamRoot = VectorSchemaRoot.create(arrowResponse.getRoot().getSchema(), fieldVectors.getFirst().getAllocator()); - } - VectorTransfer.transferRoot(arrowResponse.getRoot(), streamRoot); - arrowResponse.getRoot().close(); - out = VectorStreamOutput.forNativeArrow(streamRoot); - } else { - out = VectorStreamOutput.create(flightChannel.getAllocator(), flightChannel.getRoot()); - task.response().writeTo(out); - } - try (out) { - flightChannel.sendBatch(getHeaderBuffer(task.requestId(), task.nodeVersion(), task.features()), out, metadata); - messageListener.onResponseSent(task.requestId(), task.action(), task.response()); - } + // The channel owns the stream root end to end (create/transfer/adopt/free) and builds the + // header itself before mutating any root, so a header-serialization failure cannot orphan the source. + flightChannel.sendBatch(task.response(), () -> getHeaderBuffer(task.requestId(), task.nodeVersion(), task.features())); + messageListener.onResponseSent(task.requestId(), task.action(), task.response()); } catch (FlightRuntimeException e) { + // Fail the stream before notifying the listener so the consumer surfaces an error instead + // of blocking forever on a never-arriving first frame. + failStream(flightChannel, task, e); messageListener.onResponseSent(task.requestId(), task.action(), FlightErrorMapper.fromFlightException(e)); } catch (Exception e) { + failStream(flightChannel, task, e); messageListener.onResponseSent(task.requestId(), task.action(), e); + } catch (Throwable t) { + // A non-Exception Throwable (e.g. OutOfMemoryError, or an AssertionError from Arrow/gRPC + // under -ea) can be thrown by start()/putNext()/transfer AFTER the channel adopted the + // stream root. The batch task is non-terminal, so BatchTask.close() does not release the + // channel; without failing the stream here, the adopted stream root would never be freed. + // Route it through the same fail path (which releases the channel -> close() frees the + // root) and report it to the listener rather than letting it kill the executor worker and + // strand the caller. Wrapped so the listener's Exception contract is honoured. + Exception wrapped = new RuntimeException("Fatal error sending Arrow batch", t); + failStream(flightChannel, task, wrapped); + messageListener.onResponseSent(task.requestId(), task.action(), wrapped); + } + } + + /** + * Fails the stream after a batch send error: sends the error so the consumer's + * {@code FlightStream.getRoot()} surfaces an exception instead of hanging on a never-arriving first + * frame, then releases the channel so a later {@code completeStream} no-ops on its open guard. The + * stream root and the source root are owned and freed by the channel, so there is nothing to free + * here. {@code sendError} and {@code releaseChannel} are both idempotent / best-effort. + */ + private void failStream(FlightServerChannel flightChannel, BatchTask task, Exception cause) { + try { + Exception flightError = cause instanceof StreamException se ? FlightErrorMapper.toFlightException(se) : cause; + flightChannel.sendError(getHeaderBuffer(task.requestId(), task.nodeVersion(), task.features()), flightError); + } catch (Exception suppressed) { + // sendError is a no-op when the channel is already closed/cancelled/terminal, so reaching + // here means it genuinely failed (e.g. header serialization). Attach to the original cause + // so the second failure's context travels with the exception the listener receives, and log + // at WARN — this is a cascading failure, not routine. + cause.addSuppressed(suppressed); + logger.warn(new ParameterizedMessage("failStream: could not send error for requestId [{}]", task.requestId()), suppressed); + } finally { + // Make the channel terminal so a later completeStream can't double-terminate the listener, + // and so close() runs to free the adopted stream root. Prefer releasing via the transport + // channel (also untracks the framework task); fall back to closing the channel directly when + // there is no transport channel. Both are idempotent (close() is fire-once). + if (task.transportChannel() != null) { + task.transportChannel().releaseChannel(true); + } else { + flightChannel.close(); + } } } diff --git a/plugins/arrow-flight-rpc/src/main/java/org/opensearch/arrow/flight/transport/FlightServerChannel.java b/plugins/arrow-flight-rpc/src/main/java/org/opensearch/arrow/flight/transport/FlightServerChannel.java index 116895dbfec0b..6c105b1364c90 100644 --- a/plugins/arrow-flight-rpc/src/main/java/org/opensearch/arrow/flight/transport/FlightServerChannel.java +++ b/plugins/arrow-flight-rpc/src/main/java/org/opensearch/arrow/flight/transport/FlightServerChannel.java @@ -14,24 +14,31 @@ import org.apache.arrow.flight.FlightRuntimeException; import org.apache.arrow.memory.ArrowBuf; import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.vector.FieldVector; import org.apache.arrow.vector.VectorSchemaRoot; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.message.ParameterizedMessage; import org.opensearch.OpenSearchException; import org.opensearch.arrow.flight.stats.FlightCallTracker; +import org.opensearch.arrow.transport.ArrowBatchResponse; +import org.opensearch.arrow.transport.VectorTransfer; +import org.opensearch.common.CheckedSupplier; import org.opensearch.core.action.ActionListener; import org.opensearch.core.common.bytes.BytesReference; +import org.opensearch.core.transport.TransportResponse; import org.opensearch.transport.TcpChannel; import org.opensearch.transport.stream.StreamErrorCode; import org.opensearch.transport.stream.StreamException; +import java.io.IOException; import java.net.InetAddress; import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.concurrent.ExecutorService; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -41,11 +48,42 @@ * TcpChannel implementation for Arrow Flight. Created per call in {@link ArrowFlightProducer}. * *

Honours gRPC's {@code isReady()} contract via {@link CompositeBackpressureStrategy}: - * producer threads call {@link #awaitReadyOrThrow()} before {@code sendBatch} is queued - * and park until gRPC's outbound buffer drains below {@code setOnReadyThreshold}. + * producer threads call {@link #awaitReadyOrThrow()} before a batch is submitted and park + * until gRPC's outbound buffer drains below {@code setOnReadyThreshold}. * - *

This implementation is not thread safe; the producer must invoke {@code sendBatch} - * serially and call {@code completeStream()} at the end. + *

Ownership & concurrency — single-writer model. This channel is the sole owner of + * every Arrow buffer it sends and of the gRPC stream lifecycle. + * + *

Invariant: this channel is NOT thread-safe. All send-side mutation — the stream {@link #root} + * (create/transfer/serialize/free), {@link #terminalSent}, and every {@code serverStreamListener} call + * — MUST run on the channel's single {@link #getExecutor() flight-executor thread}, and never + * concurrently from more than one thread. The executor is single-threaded, so it serializes those + * mutations and the buffer frees among themselves. Violating this (e.g. mutating the root from a + * producer thread while {@code close()} frees it on the executor) is a data race that corrupts Arrow + * reference counts and can orphan or double-free buffers (an off-heap leak). It keeps that ownership + * safe by confining the stream root to that single thread: + *

    + *
  • The stream {@link #root} and every {@code serverStreamListener} call + * ({@code start}/{@code putNext}/{@code completed}/{@code error}) happen only on the channel's + * single-threaded flight executor ({@link #getExecutor()}). The executor serializes batch + * sends, {@code completeStream}/{@code sendError}, and the root free among themselves, so none + * can interleave.
  • + *
  • {@link #close()} may be called from any thread (the gRPC cancel callback, the flight executor + * via {@code release()}, or producer bootstrap). It is a signal, not a buffer + * operation: it flips {@link #open} exactly once via {@code compareAndSet}, fires close + * listeners, and posts the root free onto the flight executor. Because the executor is + * FIFO and single-threaded, that free runs only after any in-flight send completes. + * {@code close()} never touches Arrow buffers or gRPC from the caller's thread, so it cannot + * race an in-flight {@code putNext} and never blocks the cancel thread.
  • + *
  • A native source root is freed exactly once by {@link #sendBatch} (in a {@code finally} on + * every exit), or by {@link #releaseUnsent} when the batch never reached {@code sendBatch}.
  • + *
+ * The only cross-thread state is the close-listener list, guarded by a small leaf mutex + * ({@link #closeListenerMutex}) whose critical section only mutates that list. See + * {@code docs/channel-lifecycle-ownership.md} for the full contract. + * + *

The producer must invoke {@link #sendBatch} serially and finish with {@link #completeStream} + * (success) or {@link #sendError} (failure). */ class FlightServerChannel implements TcpChannel, ArrowFlightChannel { private static final String PROFILE_NAME = "flight"; @@ -56,9 +94,7 @@ class FlightServerChannel implements TcpChannel, ArrowFlightChannel { private final AtomicBoolean open = new AtomicBoolean(true); private final InetSocketAddress localAddress; private final InetSocketAddress remoteAddress; - private final List> closeListeners = Collections.synchronizedList(new ArrayList<>()); private final ServerHeaderMiddleware middleware; - private volatile VectorSchemaRoot root = null; private final FlightCallTracker callTracker; private volatile boolean cancelled = false; private final ExecutorService executor; @@ -67,6 +103,20 @@ class FlightServerChannel implements TcpChannel, ArrowFlightChannel { private final CompositeBackpressureStrategy bp; private final long readyTimeoutMillis; + /** + * The reused stream root (native or byte-serialized). Confined to the flight-executor thread: + * created, filled, and freed only there. {@code volatile} so the test-only {@link #getRoot()} + * sees a consistent reference; correctness relies on executor confinement, not on volatility. + */ + private volatile VectorSchemaRoot root = null; + /** Whether a terminal gRPC op ({@code completed()} XOR {@code error()}) was issued. Flight-executor confined. */ + private boolean terminalSent = false; + + /** Leaf mutex guarding {@link #closeListeners} and {@link #closeListenersFired}; its critical section only mutates them. */ + private final Object closeListenerMutex = new Object(); + private final List> closeListeners = new ArrayList<>(); + private boolean closeListenersFired = false; + public FlightServerChannel( ServerStreamListener serverStreamListener, BufferAllocator allocator, @@ -138,6 +188,12 @@ public BufferAllocator getAllocator() { return allocator; } + /** Whether the client cancelled the gRPC stream (onChannelCancelled fired). Read by FlightTransportChannel. */ + public boolean isCancelled() { + return cancelled; + } + + /** Returns the current stream root. Package-private; intended for tests/assertions only. */ VectorSchemaRoot getRoot() { return root; } @@ -149,109 +205,248 @@ public ExecutorService getExecutor() { return executor; } - public void sendBatch(ByteBuffer header, VectorStreamOutput output) { - sendBatch(header, output, null); + /** + * Sends one streaming batch. Must run on the flight executor thread (it is dispatched there + * by {@link FlightOutboundHandler}). The channel takes ownership of the response's buffers and is + * the sole place they are transferred onto the wire and freed. + * + *

For an {@link ArrowBatchResponse} (native path) the producer's root is zero-copy transferred + * into the reused stream root and the producer root is freed here exactly once (on every exit, via + * the {@code finally}). For any other response (byte-serialized path) the response is written into + * the reused {@code VarBinary} stream root. The stream root itself is freed once, at {@link #close()} + * — never per batch — to preserve gRPC's zero-copy retention window. + * + *

This method, the terminal ops, and the {@link #close()} root-free all run on the + * single-threaded executor and are serialized by it. The header is built before any root is + * mutated, so a header-serialization failure fails fast (the source is freed by the {@code finally}) + * without adopting a stream root. + * + * @param response the batch to send; the channel takes ownership of its buffers + * @param headerSupplier builds the response header + * @throws IOException if header serialization or byte-path serialization fails + */ + public void sendBatch(TransportResponse response, CheckedSupplier headerSupplier) throws IOException { + final boolean isNative = response instanceof ArrowBatchResponse; + final VectorSchemaRoot sourceRoot = isNative ? ((ArrowBatchResponse) response).getRoot() : null; + final byte[] metadata = isNative ? ((ArrowBatchResponse) response).getMetadata() : null; + try { + if (cancelled) { + throw StreamException.cancelled("Cannot flush more batches. Stream cancelled by the client"); + } + if (!open.get()) { + throw new IllegalStateException("FlightServerChannel already closed."); + } + + final boolean firstBatch = (root == null); + final long batchStartTime = System.nanoTime(); + + // Build the header before mutating any root so a header-serialization failure fails fast + // (the source root is still freed by the finally) without adopting a stream root. + final ByteBuffer header = headerSupplier.get(); + + if (isNative) { + transferIntoStreamRoot(sourceRoot, firstBatch); + } else { + serializeIntoStreamRoot(response, firstBatch); + } + + batchNumber.incrementAndGet(); + if (firstBatch) { + middleware.setHeader(header); + serverStreamListener.start(root); + } + + logger.debug("Sending batch #{} for correlation ID: {}", batchNumber, correlationId); + if (metadata != null) { + // Flight takes ownership of metadataBuf via putNext(ArrowBuf); free it ourselves + // only if putNext never adopted it. + ArrowBuf metadataBuf = allocator.buffer(metadata.length); + metadataBuf.writeBytes(metadata); + try { + serverStreamListener.putNext(metadataBuf); + } catch (Throwable t) { + if (metadataBuf.refCnt() > 0) { + metadataBuf.close(); + } + throw t; + } + } else { + serverStreamListener.putNext(); + } + + long putNextTime = (System.nanoTime() - batchStartTime) / 1_000_000; + if (callTracker != null) { + long rootSize = FlightUtils.calculateVectorSchemaRootSize(root); + callTracker.recordBatchSent(rootSize, System.nanoTime() - batchStartTime); + logger.debug( + "Batch #{} sent for correlation ID: {}, size: {} bytes, putNext: {}ms", + batchNumber, + correlationId, + rootSize, + putNextTime + ); + } else { + logger.debug("Batch #{} sent for correlation ID: {}, putNext: {}ms", batchNumber, correlationId, putNextTime); + } + } finally { + // The native source root is exclusively owned by this call. Free it exactly once on every + // exit: after a successful transfer it is empty (close is a no-op); on any early throw it + // still holds its buffers (close frees them). + if (isNative && sourceRoot != null) { + sourceRoot.close(); + } + } } /** - * Sends a batch, optionally with application metadata attached to the same Flight - * frame via {@code putNext(ArrowBuf)}. Metadata is opaque to the transport. + * Native path: zero-copy transfers {@code sourceRoot}'s vectors into the reused stream root, + * creating it on the first batch. On the first batch, if transfer fails before the channel adopts + * the freshly-created root, that root is freed here so it cannot leak (it has not yet become + * {@link #root}, so {@link #close()} would not free it). Flight-executor confined. */ - public void sendBatch(ByteBuffer header, VectorStreamOutput output, byte[] metadata) { - if (cancelled) { - throw StreamException.cancelled("Cannot flush more batches. Stream cancelled by the client"); - } - if (!open.get()) { - throw new IllegalStateException("FlightServerChannel already closed."); - } - batchNumber.incrementAndGet(); - long batchStartTime = System.nanoTime(); - if (root == null) { - middleware.setHeader(header); - root = output.getRoot(); - serverStreamListener.start(root); - } else { - root = output.getRoot(); - } - logger.debug("Sending batch #{} for correlation ID: {}", batchNumber, correlationId); - // Roots are not closed right after putNext: gRPC may still hold zero-copy refs. - // They're released at completeStream. TODO: optimize. - if (metadata != null) { - // Flight takes ownership of metadataBuf via putNext(ArrowBuf). - ArrowBuf metadataBuf = allocator.buffer(metadata.length); - metadataBuf.writeBytes(metadata); - serverStreamListener.putNext(metadataBuf); - } else { - serverStreamListener.putNext(); - } - long putNextTime = (System.nanoTime() - batchStartTime) / 1_000_000; - if (callTracker != null) { - long rootSize = FlightUtils.calculateVectorSchemaRootSize(root); - callTracker.recordBatchSent(rootSize, System.nanoTime() - batchStartTime); - logger.debug( - "Batch #{} sent for correlation ID: {}, size: {} bytes, putNext: {}ms", - batchNumber, - correlationId, - rootSize, - putNextTime - ); + private void transferIntoStreamRoot(VectorSchemaRoot sourceRoot, boolean firstBatch) { + if (firstBatch) { + List fieldVectors = sourceRoot.getFieldVectors(); + if (fieldVectors.isEmpty()) { + throw new IllegalStateException("Native Arrow batch has no field vectors"); + } + // Create using the producer's allocator: cross-allocator transferOwnership of foreign-backed + // buffers (from C data import) does not properly free the ArrowArray C struct. The producer's + // allocator must be long-lived (not closed per-request). + VectorSchemaRoot created = VectorSchemaRoot.create(sourceRoot.getSchema(), fieldVectors.getFirst().getAllocator()); + boolean adopted = false; + try { + VectorTransfer.transferRoot(sourceRoot, created); + root = created; + adopted = true; + } finally { + if (!adopted) { + created.close(); + } + } } else { - logger.debug("Batch #{} sent for correlation ID: {}, putNext: {}ms", batchNumber, correlationId, putNextTime); + VectorTransfer.transferRoot(sourceRoot, root); } } /** - * Completes the streaming response and closes all pending roots. - * + * Byte-serialized path: writes {@code response} into the reused {@code VarBinary} stream root, + * creating it on the first batch and clearing it for reuse on later batches. On the first batch, + * if serialization fails before the channel adopts the freshly-created root, the output's vector is + * freed here so it cannot leak. Flight-executor confined. */ - public void completeStream(ByteBuffer header) { + private void serializeIntoStreamRoot(TransportResponse response, boolean firstBatch) throws IOException { + VectorStreamOutput out = VectorStreamOutput.create(allocator, root); + boolean adopted = false; try { - if (!open.get()) { - throw new IllegalStateException("FlightServerChannel already closed."); - } - if (root == null) { - // Set header if no batches were sent - middleware.setHeader(header); - logger.debug("Completing empty stream for correlation ID: {}", correlationId); - } else { - logger.debug("Completing stream for correlation ID: {} after {} batches", correlationId, batchNumber); + if (!firstBatch) { + out.reset(); } - serverStreamListener.completed(); + response.writeTo(out); + root = out.getRoot(); + adopted = true; } finally { - callTracker.recordCallEnd(StreamErrorCode.OK.name()); + // Only the first-batch output owns a freshly-allocated vector; on reuse the output wraps the + // channel-owned root, which close() frees. + if (firstBatch && !adopted) { + try { + out.close(); + } catch (IOException ignore) { + // best-effort cleanup of the un-adopted first-batch vector + } + } } } /** - * Sends an error and closes the channel. + * Frees the source buffers of a batch that was built but never handed to {@link #sendBatch} + * (the back-pressure gate threw, the executor rejected the task, or the channel was the wrong + * type). The channel owns the source root from the moment the producer enqueues the batch, so on a + * failed hand-off it is freed here — the mutually-exclusive counterpart to {@link #sendBatch}'s own + * {@code finally}. No-op for the byte-serialized path (no off-heap source root). + */ + public void releaseUnsent(TransportResponse response) { + releaseUnsentSource(response); + } + + /** + * Static variant of {@link #releaseUnsent} for the defensive path where the channel is the wrong + * type and no instance is available. Frees the native source root of an unsent batch; no-op for the + * byte-serialized path (no off-heap source root). + */ + static void releaseUnsentSource(TransportResponse response) { + if (response instanceof ArrowBatchResponse arrowResponse) { + VectorSchemaRoot sourceRoot = arrowResponse.getRoot(); + if (sourceRoot != null) { + sourceRoot.close(); + } + } + } + + /** + * Completes the streaming response. Must run on the flight executor thread. Terminal and + * idempotent: a no-op if the channel is already closed, cancelled, or terminal. Does not free the + * stream root — that is deferred to {@link #close()} to preserve gRPC's zero-copy retention window. + */ + public void completeStream(ByteBuffer header) { + if (!open.get() || cancelled || terminalSent) { + logger.debug( + "completeStream is a no-op (open={}, cancelled={}, terminalSent={}) for correlation ID: {}", + open.get(), + cancelled, + terminalSent, + correlationId + ); + return; + } + if (root == null) { + // Set header if no batches were sent + middleware.setHeader(header); + logger.debug("Completing empty stream for correlation ID: {}", correlationId); + } else { + logger.debug("Completing stream for correlation ID: {} after {} batches", correlationId, batchNumber); + } + serverStreamListener.completed(); + terminalSent = true; + callTracker.recordCallEnd(StreamErrorCode.OK.name()); + } + + /** + * Sends a terminal error to the consumer. Must run on the flight executor thread. Terminal + * and idempotent: a no-op if the channel is already closed, cancelled, or terminal (the consumer is + * then already done). Does not free the stream root — that is deferred to {@link #close()}. * * @param error the error to send */ public void sendError(ByteBuffer header, Exception error) { - FlightRuntimeException flightExc = null; - try { - if (!open.get()) { - throw new IllegalStateException("FlightServerChannel already closed."); - } - if (error instanceof FlightRuntimeException fre) { - flightExc = fre; - } else { - flightExc = CallStatus.INTERNAL.withCause(error) - .withDescription(error.getMessage() != null ? error.getMessage() : "Stream error") - .toRuntimeException(); - } - middleware.setHeader(header); - if (error instanceof OpenSearchException) { - logger.debug("Error in Flight stream: {}", error.getMessage()); - } else { - logger.error("Unexpected error in Flight stream", error); - } - logger.debug("Sending error for correlation ID: {} after {} batches: {}", correlationId, batchNumber, error.getMessage()); - serverStreamListener.error(flightExc); - } finally { - StreamErrorCode errorCode = flightExc != null ? mapFromCallStatus(flightExc) : StreamErrorCode.UNKNOWN; - callTracker.recordCallEnd(errorCode.name()); + if (!open.get() || cancelled || terminalSent) { + logger.debug( + "sendError is a no-op (open={}, cancelled={}, terminalSent={}) for correlation ID: {}", + open.get(), + cancelled, + terminalSent, + correlationId + ); + return; + } + FlightRuntimeException flightExc; + if (error instanceof FlightRuntimeException fre) { + flightExc = fre; + } else { + flightExc = CallStatus.INTERNAL.withCause(error) + .withDescription(error.getMessage() != null ? error.getMessage() : "Stream error") + .toRuntimeException(); } + middleware.setHeader(header); + if (error instanceof OpenSearchException) { + logger.debug("Error in Flight stream: {}", error.getMessage()); + } else { + logger.error("Unexpected error in Flight stream", error); + } + logger.debug("Sending error for correlation ID: {} after {} batches: {}", correlationId, batchNumber, error.getMessage()); + serverStreamListener.error(flightExc); + terminalSent = true; + callTracker.recordCallEnd(mapFromCallStatus(flightExc).name()); } @Override @@ -292,22 +487,56 @@ public ChannelStats getChannelStats() { @Override public void close() { - if (!open.get()) { + // Fire-once: exactly one caller wins the CAS, regardless of thread (gRPC cancel, the flight + // executor via release, or producer bootstrap) or interleaving. + if (!open.compareAndSet(true, false)) { return; } - open.set(false); - if (root != null) { - root.close(); + // The stream root is owned exclusively by the flight-executor thread (created, filled, and + // freed there). Post the free onto that executor so it is serialized behind any in-flight send + // by the executor's own FIFO ordering; we never touch Arrow buffers from this (possibly gRPC) + // thread. + try { + executor.execute(() -> { + if (root != null) { + root.close(); + root = null; + } + }); + } catch (RejectedExecutionException e) { + // Executor shut down (node shutdown): the posted free will not run, so the stream root is + // reclaimed by the OS on process exit (documented limitation, channel-lifecycle-ownership + // §10). We deliberately do NOT free inline: a rejection from ExecutorService.shutdown() + // means new tasks are refused while an already-running send may still be touching the root + // on the executor thread, so freeing from this (possibly gRPC) thread would reintroduce the + // use-after-free this single-writer model exists to prevent. Logged at WARN so the + // shutdown-time leak is visible rather than buried. + logger.warn( + new ParameterizedMessage( + "flight executor rejected stream-root release for correlation ID: {}; root reclaimed at process exit", + correlationId + ), + e + ); } notifyCloseListeners(); } @Override public void addCloseListener(ActionListener listener) { - if (!open.get()) { + // Register atomically against notifyCloseListeners under the leaf mutex: either the listeners + // have not fired yet and we enqueue (notifyCloseListeners will fire it), or they have already + // fired and we fire immediately. Never both, never neither. The mutex's critical section only + // mutates the list. + boolean alreadyFired; + synchronized (closeListenerMutex) { + alreadyFired = closeListenersFired; + if (!alreadyFired) { + closeListeners.add(listener); + } + } + if (alreadyFired) { listener.onResponse(null); - } else { - closeListeners.add(listener); } } @@ -317,9 +546,22 @@ public boolean isOpen() { } private void notifyCloseListeners() { - for (ActionListener listener : closeListeners) { - listener.onResponse(null); + // Snapshot+mark-fired under the leaf mutex so a concurrent addCloseListener cannot be lost or + // double-fired, then fire outside the mutex so foreign listener code never runs while we hold + // it. Isolate each listener so one failure cannot strand the rest (e.g. the TaskManager untrack + // listener). + final List> toFire; + synchronized (closeListenerMutex) { + toFire = new ArrayList<>(closeListeners); + closeListeners.clear(); + closeListenersFired = true; + } + for (ActionListener listener : toFire) { + try { + listener.onResponse(null); + } catch (Exception e) { + logger.warn(new ParameterizedMessage("close listener failed for correlation ID: {}", correlationId), e); + } } - closeListeners.clear(); } } diff --git a/plugins/arrow-flight-rpc/src/main/java/org/opensearch/arrow/flight/transport/FlightTransport.java b/plugins/arrow-flight-rpc/src/main/java/org/opensearch/arrow/flight/transport/FlightTransport.java index 49572a01ddce6..663aeb543011d 100644 --- a/plugins/arrow-flight-rpc/src/main/java/org/opensearch/arrow/flight/transport/FlightTransport.java +++ b/plugins/arrow-flight-rpc/src/main/java/org/opensearch/arrow/flight/transport/FlightTransport.java @@ -65,8 +65,10 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; import java.util.stream.Collectors; +import io.grpc.netty.NettyServerBuilder; import io.netty.channel.EventLoopGroup; import io.netty.channel.MultiThreadIoEventLoopGroup; import io.netty.channel.nio.NioIoHandler; @@ -251,6 +253,17 @@ private List bindToPort(InetAddress[] hostAddresses) { .backpressureThreshold((int) ServerConfig.FLIGHT_OUTBOUND_BUFFER_THRESHOLD.get(settings).getBytes()) .middleware(SERVER_HEADER_KEY, factory); + // Server-side gRPC keepalive (see ServerConfig.FLIGHT_KEEPALIVE_TIME). NOTE: only the + // server pings today; adding a client keepalive also requires + // permitKeepAliveTime/permitKeepAliveWithoutCalls here, else the server GOAWAYs the + // client with "too_many_pings". + final long keepAliveTimeMs = ServerConfig.getGrpcKeepAliveTime().millis(); + final long keepAliveTimeoutMs = ServerConfig.getGrpcKeepAliveTimeout().millis(); + builder.transportHint("grpc.builderConsumer", (Consumer) b -> { + b.keepAliveTime(keepAliveTimeMs, TimeUnit.MILLISECONDS); + b.keepAliveTimeout(keepAliveTimeoutMs, TimeUnit.MILLISECONDS); + }); + builder.location(locations.get(0)); for (int i = 1; i < locations.size(); i++) { builder.addListenAddress(locations.get(i)); diff --git a/plugins/arrow-flight-rpc/src/main/java/org/opensearch/arrow/flight/transport/FlightTransportChannel.java b/plugins/arrow-flight-rpc/src/main/java/org/opensearch/arrow/flight/transport/FlightTransportChannel.java index de950ba51ccc6..9b6d577491878 100644 --- a/plugins/arrow-flight-rpc/src/main/java/org/opensearch/arrow/flight/transport/FlightTransportChannel.java +++ b/plugins/arrow-flight-rpc/src/main/java/org/opensearch/arrow/flight/transport/FlightTransportChannel.java @@ -85,6 +85,11 @@ public void sendResponse(Exception exception) throws IOException { @Override public void sendResponseBatch(TransportResponse response) { + sendResponseBatch(response, false); + } + + @Override + public void sendResponseBatch(TransportResponse response, boolean sync) { if (!streamOpen.get()) { throw new StreamException(StreamErrorCode.UNAVAILABLE, "Stream is closed for requestId [" + requestId + "]"); } @@ -101,7 +106,8 @@ public void sendResponseBatch(TransportResponse response) { action, response, compressResponse, - isHandshake + isHandshake, + sync ); } catch (StreamException e) { // Cancelled: consumer is gone, release ends the call cleanly. For other @@ -155,4 +161,9 @@ public void releaseChannel(boolean isExceptionResponse) { public BufferAllocator getAllocator() { return ((FlightServerChannel) getChannel()).getAllocator(); } + + @Override + public boolean isCancelled() { + return getChannel() instanceof FlightServerChannel fsc && fsc.isCancelled(); + } } diff --git a/plugins/arrow-flight-rpc/src/main/java/org/opensearch/arrow/flight/transport/FlightTransportResponse.java b/plugins/arrow-flight-rpc/src/main/java/org/opensearch/arrow/flight/transport/FlightTransportResponse.java index 5587f13c073f5..1b08222a4ef9c 100644 --- a/plugins/arrow-flight-rpc/src/main/java/org/opensearch/arrow/flight/transport/FlightTransportResponse.java +++ b/plugins/arrow-flight-rpc/src/main/java/org/opensearch/arrow/flight/transport/FlightTransportResponse.java @@ -29,6 +29,7 @@ import java.io.IOException; import java.util.Objects; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; import static org.opensearch.arrow.flight.transport.ClientHeaderMiddleware.CORRELATION_ID_KEY; @@ -53,6 +54,7 @@ class FlightTransportResponse implements StreamTran private volatile long currentBatchSize; private volatile boolean firstBatchConsumed; private volatile boolean closed; + private final AtomicBoolean streamClosed = new AtomicBoolean(false); private volatile boolean prefetchStarted; private volatile Header initialHeader; @@ -93,6 +95,22 @@ void openAndPrefetchAsync(CompletableFuture

future) { try { long start = System.nanoTime(); flightStream = flightClient.getStream(ticket, new HeaderCallOption(callHeaders)); + // close() may have run while we were inside getStream() and missed the stream because + // flightStream was still null. Now that it is published, re-check the flag: if a close() + // already happened, self-close the stream we just opened so the prefetched first-batch + // root is not stranded, then abort. This check is performed *before* future.complete(), + // so once the future completes, any subsequent close() always observes flightStream != null + // and owns the close itself. There is no post-completion window in which both this thread + // and a racing close() could close the same stream. + if (closed) { + try { + closeStreamQuietly(); + } catch (StreamException e) { + logger.warn("Error closing flight stream after close() raced the prefetch", e); + } + future.completeExceptionally(new StreamException(StreamErrorCode.UNAVAILABLE, "Stream is closed")); + return; + } long elapsedMs = (System.nanoTime() - start) / 1_000_000; logger.debug("FlightClient.getStream() for correlationId: {} took {}ms", correlationId, elapsedMs); start = System.nanoTime(); @@ -188,10 +206,14 @@ public void cancel(String reason, Throwable cause) { public void close() { if (closed) return; closed = true; + closeStreamQuietly(); + } - if (flightStream != null) { + private void closeStreamQuietly() { + FlightStream stream = flightStream; + if (stream != null && streamClosed.compareAndSet(false, true)) { try { - flightStream.close(); + stream.close(); } catch (IllegalStateException ignore) {} catch (Exception e) { throw new StreamException(StreamErrorCode.INTERNAL, "Error closing flight stream", e); } diff --git a/plugins/arrow-flight-rpc/src/test/java/org/apache/arrow/flight/OSFlightServerKeepAliveTests.java b/plugins/arrow-flight-rpc/src/test/java/org/apache/arrow/flight/OSFlightServerKeepAliveTests.java new file mode 100644 index 0000000000000..5336440b87f60 --- /dev/null +++ b/plugins/arrow-flight-rpc/src/test/java/org/apache/arrow/flight/OSFlightServerKeepAliveTests.java @@ -0,0 +1,79 @@ +/* + * 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.apache.arrow.flight; + +import org.opensearch.common.SuppressForbidden; +import org.opensearch.test.OpenSearchTestCase; + +import java.lang.reflect.Field; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import io.grpc.netty.NettyServerBuilder; + +/** + * Proves the gRPC keepalive wiring actually reaches the {@link NettyServerBuilder}. + * + *

{@code FlightTransport} configures server keepalive by registering a + * {@code Consumer} under the {@code "grpc.builderConsumer"} transport hint, which + * {@link OSFlightServer.Builder#build()} applies to the live builder. The hint is best-effort ("not + * guaranteed to have any effect"), so this test guards that the key is honored on the pinned Arrow / + * gRPC versions: it registers the same consumer, recovers it from the builder's options, applies it to + * a real {@link NettyServerBuilder}, and reads back gRPC's own keepalive fields to confirm the exact + * values landed. If an Arrow upgrade ever dropped/renamed the hook, or the gRPC keepalive API changed, + * this fails instead of the keepalive silently becoming a no-op. + */ +public class OSFlightServerKeepAliveTests extends OpenSearchTestCase { + + @SuppressWarnings("unchecked") + @SuppressForbidden(reason = "reads private builderOptions + gRPC keepalive fields to verify the hint is applied") + public void testKeepAliveConsumerReachesNettyServerBuilder() throws Exception { + final long keepAliveMs = 60_000L; + final long keepAliveTimeoutMs = 20_000L; + + // Register the consumer exactly as FlightTransport does. + OSFlightServer.Builder builder = OSFlightServer.builder(); + builder.transportHint("grpc.builderConsumer", (Consumer) b -> { + b.keepAliveTime(keepAliveMs, TimeUnit.MILLISECONDS); + b.keepAliveTimeout(keepAliveTimeoutMs, TimeUnit.MILLISECONDS); + }); + + // Recover the consumer the same way OSFlightServer.build() does (builderOptions is private). + final Field optionsField = OSFlightServer.Builder.class.getDeclaredField("builderOptions"); + optionsField.setAccessible(true); + final Map options = (Map) optionsField.get(builder); + final Object hook = options.get("grpc.builderConsumer"); + assertNotNull("grpc.builderConsumer hint must be stored on the builder", hook); + + // Apply it to a real NettyServerBuilder. accept() not throwing already proves the hint key is + // honored and the gRPC keepAliveTime/keepAliveTimeout(long, TimeUnit) signatures still match. + NettyServerBuilder nettyBuilder = NettyServerBuilder.forPort(0); + ((Consumer) hook).accept(nettyBuilder); + + // And confirm the exact values landed by reading gRPC's own keepalive fields (nanos). + assertEquals( + "keepAliveTime must be applied to the gRPC builder", + TimeUnit.MILLISECONDS.toNanos(keepAliveMs), + readLongField(nettyBuilder, "keepAliveTimeInNanos") + ); + assertEquals( + "keepAliveTimeout must be applied to the gRPC builder", + TimeUnit.MILLISECONDS.toNanos(keepAliveTimeoutMs), + readLongField(nettyBuilder, "keepAliveTimeoutInNanos") + ); + } + + @SuppressForbidden(reason = "reads gRPC's private keepalive field to verify the value was applied") + private static long readLongField(NettyServerBuilder builder, String name) throws Exception { + final Field f = NettyServerBuilder.class.getDeclaredField(name); + f.setAccessible(true); + return f.getLong(builder); + } +} diff --git a/plugins/arrow-flight-rpc/src/test/java/org/opensearch/arrow/flight/bootstrap/ServerConfigTests.java b/plugins/arrow-flight-rpc/src/test/java/org/opensearch/arrow/flight/bootstrap/ServerConfigTests.java index 71a275338cd58..aeb6e53e6c710 100644 --- a/plugins/arrow-flight-rpc/src/test/java/org/opensearch/arrow/flight/bootstrap/ServerConfigTests.java +++ b/plugins/arrow-flight-rpc/src/test/java/org/opensearch/arrow/flight/bootstrap/ServerConfigTests.java @@ -7,6 +7,7 @@ */ package org.opensearch.arrow.flight.bootstrap; +import org.opensearch.common.settings.ClusterSettings; import org.opensearch.common.settings.Settings; import org.opensearch.common.unit.TimeValue; import org.opensearch.core.common.unit.ByteSizeUnit; @@ -14,6 +15,8 @@ import org.opensearch.test.OpenSearchTestCase; import org.opensearch.threadpool.ScalingExecutorBuilder; +import java.util.Set; + import static org.opensearch.arrow.flight.bootstrap.ServerConfig.SETTING_FLIGHT_PUBLISH_PORT; public class ServerConfigTests extends OpenSearchTestCase { @@ -97,6 +100,35 @@ public void testBackpressureSettingsParse() { assertEquals(16L * 1024 * 1024, ServerConfig.FLIGHT_OUTBOUND_BUFFER_THRESHOLD.get(overridden).getBytes()); } + public void testKeepAliveDefaultsMatchGrpc() { + assertEquals(TimeValue.timeValueHours(2), ServerConfig.FLIGHT_KEEPALIVE_TIME.get(Settings.EMPTY)); + assertEquals(TimeValue.timeValueSeconds(20), ServerConfig.FLIGHT_KEEPALIVE_TIMEOUT.get(Settings.EMPTY)); + } + + public void testKeepAliveTimeoutMustBeLessThanTime() { + ClusterSettings clusterSettings = new ClusterSettings( + Settings.EMPTY, + Set.of(ServerConfig.FLIGHT_KEEPALIVE_TIME, ServerConfig.FLIGHT_KEEPALIVE_TIMEOUT) + ); + Settings equal = Settings.builder() + .put(ServerConfig.FLIGHT_KEEPALIVE_TIME.getKey(), TimeValue.timeValueSeconds(30)) + .put(ServerConfig.FLIGHT_KEEPALIVE_TIMEOUT.getKey(), TimeValue.timeValueSeconds(30)) + .build(); + expectThrows(IllegalArgumentException.class, () -> clusterSettings.validate(equal, false)); + + Settings greater = Settings.builder() + .put(ServerConfig.FLIGHT_KEEPALIVE_TIME.getKey(), TimeValue.timeValueSeconds(30)) + .put(ServerConfig.FLIGHT_KEEPALIVE_TIMEOUT.getKey(), TimeValue.timeValueSeconds(40)) + .build(); + expectThrows(IllegalArgumentException.class, () -> clusterSettings.validate(greater, false)); + + Settings ok = Settings.builder() + .put(ServerConfig.FLIGHT_KEEPALIVE_TIME.getKey(), TimeValue.timeValueSeconds(60)) + .put(ServerConfig.FLIGHT_KEEPALIVE_TIMEOUT.getKey(), TimeValue.timeValueSeconds(20)) + .build(); + clusterSettings.validate(ok, false); + } + public void testReadyTimeoutMinimum() { // 100ms minimum is enforced; lower values must be rejected at parse. Settings tooLow = Settings.builder().put(ServerConfig.FLIGHT_READY_TIMEOUT.getKey(), TimeValue.timeValueMillis(50)).build(); diff --git a/plugins/arrow-flight-rpc/src/test/java/org/opensearch/arrow/flight/transport/FlightClientChannelTests.java b/plugins/arrow-flight-rpc/src/test/java/org/opensearch/arrow/flight/transport/FlightClientChannelTests.java index 2d513c9bd9cf9..f28820a6328ab 100644 --- a/plugins/arrow-flight-rpc/src/test/java/org/opensearch/arrow/flight/transport/FlightClientChannelTests.java +++ b/plugins/arrow-flight-rpc/src/test/java/org/opensearch/arrow/flight/transport/FlightClientChannelTests.java @@ -9,6 +9,7 @@ package org.opensearch.arrow.flight.transport; import org.apache.arrow.flight.FlightClient; +import org.opensearch.ExceptionsHelper; import org.opensearch.core.action.ActionListener; import org.opensearch.core.common.bytes.BytesArray; import org.opensearch.core.common.bytes.BytesReference; @@ -266,6 +267,85 @@ public TestResponse read(StreamInput in) throws IOException { assertEquals("Simulated handler exception", handlerException.get().getMessage()); } + /** + * Wire round-trip: a {@link StreamException} with a specific {@link StreamErrorCode} sent from the + * server handler must arrive at the client as a StreamException carrying the SAME error code and + * message. This is the leg analytics relies on — {@code AnalyticsTransportErrors.toWireError} tags a + * resource-exhaustion failure RESOURCE_EXHAUSTED on the data node, and the coordinator's + * {@code fromWireError} rebuilds a 429 from the code that survives here. Flight does not serialize the + * exception type, so the code is the only signal that crosses. + */ + public void testStreamErrorCodeSurvivesWire() throws InterruptedException { + assertErrorCodeRoundTrips(StreamErrorCode.RESOURCE_EXHAUSTED, "memory budget exhausted on shard"); + assertErrorCodeRoundTrips(StreamErrorCode.UNAVAILABLE, "Network closed for unknown reason"); + } + + private void assertErrorCodeRoundTrips(StreamErrorCode code, String message) throws InterruptedException { + String action = "internal:test/stream/errorcode/" + code.name(); + CountDownLatch latch = new CountDownLatch(1); + AtomicReference received = new AtomicReference<>(); + + streamTransportService.registerRequestHandler( + action, + ThreadPool.Names.SAME, + in -> new TestRequest(in), + (request, channel, task) -> { + try { + channel.sendResponse(new StreamException(code, message)); + } catch (IOException ignored) {} + } + ); + + TransportRequestOptions options = TransportRequestOptions.builder().withType(TransportRequestOptions.Type.STREAM).build(); + TransportResponseHandler responseHandler = new TransportResponseHandler<>() { + @Override + public void handleStreamResponse(StreamTransportResponse streamResponse) { + try { + while (streamResponse.nextResponse() != null) { + } + } catch (Exception e) { + received.set(e); + try { + streamResponse.close(); + } catch (IOException ignored) {} + latch.countDown(); + } + } + + @Override + public void handleResponse(TestResponse response) { + latch.countDown(); + } + + @Override + public void handleException(TransportException exp) { + received.set(exp); + latch.countDown(); + } + + @Override + public String executor() { + return ThreadPool.Names.SAME; + } + + @Override + public TestResponse read(StreamInput in) throws IOException { + return new TestResponse(in); + } + }; + + streamTransportService.sendRequest(remoteNode, action, new TestRequest(), options, responseHandler); + + assertTrue("no error surfaced for " + code, latch.await(TIMEOUT_SEC, TimeUnit.SECONDS)); + Exception e = received.get(); + assertNotNull("expected an error for " + code, e); + StreamException se = (StreamException) ExceptionsHelper.unwrapCausesAndSuppressed(e, t -> t instanceof StreamException) + .orElse(null); + assertNotNull("error must surface as a StreamException, got: " + e, se); + assertEquals("error code must survive the wire", code, se.getErrorCode()); + assertTrue("message must survive the wire, got: " + se.getMessage(), se.getMessage() != null && se.getMessage().contains(message)); + } + public void testThreadPoolExhaustion() throws InterruptedException { ThreadPool exhaustedThreadPool = mock(ThreadPool.class); when(exhaustedThreadPool.executor(any())).thenThrow(new RejectedExecutionException("Thread pool exhausted")); diff --git a/plugins/arrow-flight-rpc/src/test/java/org/opensearch/arrow/flight/transport/FlightErrorMapperTests.java b/plugins/arrow-flight-rpc/src/test/java/org/opensearch/arrow/flight/transport/FlightErrorMapperTests.java index 69c3093893302..f899eb7c70fb8 100644 --- a/plugins/arrow-flight-rpc/src/test/java/org/opensearch/arrow/flight/transport/FlightErrorMapperTests.java +++ b/plugins/arrow-flight-rpc/src/test/java/org/opensearch/arrow/flight/transport/FlightErrorMapperTests.java @@ -46,6 +46,24 @@ public void testFromFlightExceptionKeepsOnlyRelevantTrailers() { assertFalse(rendered, rendered.contains("application/grpc")); } + /** + * Round-trip: a StreamException's message + error code must survive toFlightException → back. The + * immutable-builder bug (CallStatus.withDescription's return value discarded) dropped the description, + * leaving callers with gRPC's placeholder ("Internal error [task_id=N]") and no message. + */ + public void testToFlightExceptionPreservesDescriptionAndErrorCode() { + StreamException original = new StreamException(StreamErrorCode.RESOURCE_EXHAUSTED, "memory budget exhausted on shard"); + + FlightRuntimeException flightException = FlightErrorMapper.toFlightException(original); + StreamException roundTripped = FlightErrorMapper.fromFlightException(flightException); + + assertEquals(StreamErrorCode.RESOURCE_EXHAUSTED, roundTripped.getErrorCode()); + assertTrue( + "description must survive the round-trip, got: " + roundTripped.getMessage(), + roundTripped.getMessage() != null && roundTripped.getMessage().contains("memory budget exhausted on shard") + ); + } + public void testFromFlightExceptionWithNoMetadata() { FlightRuntimeException flightException = CallStatus.UNAVAILABLE.withDescription("unavailable").toRuntimeException(); diff --git a/plugins/arrow-flight-rpc/src/test/java/org/opensearch/arrow/flight/transport/FlightOutboundHandlerTests.java b/plugins/arrow-flight-rpc/src/test/java/org/opensearch/arrow/flight/transport/FlightOutboundHandlerTests.java index 8fb6fc88059ed..11ce8b89c8786 100644 --- a/plugins/arrow-flight-rpc/src/test/java/org/opensearch/arrow/flight/transport/FlightOutboundHandlerTests.java +++ b/plugins/arrow-flight-rpc/src/test/java/org/opensearch/arrow/flight/transport/FlightOutboundHandlerTests.java @@ -8,6 +8,8 @@ package org.opensearch.arrow.flight.transport; +import org.apache.arrow.flight.CallStatus; +import org.apache.arrow.flight.FlightRuntimeException; import org.apache.arrow.memory.BufferAllocator; import org.apache.arrow.memory.RootAllocator; import org.apache.arrow.vector.IntVector; @@ -26,6 +28,8 @@ import org.opensearch.threadpool.ThreadPool; import org.opensearch.transport.StatsTracker; import org.opensearch.transport.TransportMessageListener; +import org.opensearch.transport.stream.StreamErrorCode; +import org.opensearch.transport.stream.StreamException; import org.junit.After; import org.junit.Before; @@ -36,7 +40,9 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import static org.mockito.ArgumentMatchers.any; @@ -45,6 +51,7 @@ import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -70,7 +77,6 @@ public void setUp() throws Exception { mockFlightChannel = mock(FlightServerChannel.class); when(mockFlightChannel.getExecutor()).thenReturn(executor); when(mockFlightChannel.getAllocator()).thenReturn(mock(BufferAllocator.class)); - when(mockFlightChannel.getRoot()).thenReturn(mock(VectorSchemaRoot.class)); mockListener = mock(TransportMessageListener.class); handler.setMessageListener(mockListener); @@ -100,6 +106,7 @@ public void testSendResponseBatchPreservesCallerThreadContext() throws Exception "test-action", mock(TransportResponse.class), false, + false, false ); @@ -172,6 +179,7 @@ public void testSendResponseBatchPropagatesContextToExecutorThread() throws Exce "test-action", mock(TransportResponse.class), false, + false, false ); @@ -197,6 +205,7 @@ public void testMultipleBatchesMaintainCallerContext() throws Exception { "test-action", mock(TransportResponse.class), false, + false, false ); @@ -213,40 +222,27 @@ public void testMultipleBatchesMaintainCallerContext() throws Exception { assertEquals("Caller's thread context should be preserved after completeStream", HEADER_VALUE, threadContext.getHeader(HEADER_KEY)); } - // --- Native Arrow branch in processBatchTask --- + // --- processBatchTask delegates to the channel --- + // The channel owns the stream root (create/transfer/adopt/free) and builds the header internally; + // these tests verify delegation + listener notification. Root ownership is covered in + // FlightServerChannelTests. - public void testProcessBatchTaskNativeArrowFirstBatch() throws Exception { + public void testProcessBatchTaskDelegatesToChannelSendBatch() throws Exception { try (RootAllocator allocator = new RootAllocator()) { - Schema schema = new Schema(List.of(new Field("val", FieldType.nullable(new ArrowType.Int(32, true)), null))); - VectorSchemaRoot producerRoot = VectorSchemaRoot.create(schema, allocator); - IntVector vec = (IntVector) producerRoot.getVector("val"); - vec.allocateNew(); - vec.setSafe(0, 42); - vec.setValueCount(1); - producerRoot.setRowCount(1); - - // First batch: streamRoot is null, so it should be created - when(mockFlightChannel.getRoot()).thenReturn(null); + VectorSchemaRoot producerRoot = newSingleIntRoot(allocator, 42); CountDownLatch latch = new CountDownLatch(1); - AtomicReference error = new AtomicReference<>(); - doAnswer(invocation -> { latch.countDown(); return null; }).when(mockListener).onResponseSent(anyLong(), anyString(), any(TransportResponse.class)); + // The channel takes the response; the handler must not touch its root. + AtomicReference sent = new AtomicReference<>(); doAnswer(invocation -> { - // Verify the output has a root with transferred data - VectorStreamOutput out = invocation.getArgument(1); - VectorSchemaRoot sentRoot = out.getRoot(); - assertNotNull(sentRoot); - assertEquals(1, sentRoot.getRowCount()); - assertEquals(42, ((IntVector) sentRoot.getVector("val")).get(0)); - // Clean up the stream root created by the handler - sentRoot.close(); + sent.set(invocation.getArgument(0)); return null; - }).when(mockFlightChannel).sendBatch(any(), any(VectorStreamOutput.class), any()); + }).when(mockFlightChannel).sendBatch(any(TransportResponse.class), any()); TestArrowResponse response = new TestArrowResponse(producerRoot); handler.sendResponseBatch( @@ -258,48 +254,61 @@ public void testProcessBatchTaskNativeArrowFirstBatch() throws Exception { "test-action", response, false, + false, false ); assertTrue("Task should complete", latch.await(5, TimeUnit.SECONDS)); - assertNull("No error expected", error.get()); + verify(mockFlightChannel).sendBatch(any(TransportResponse.class), any()); + assertSame("handler should pass the response straight to the channel", response, sent.get()); + // Channel mock did not consume buffers; free the producer root so teardown is clean. + producerRoot.close(); } } - public void testProcessBatchTaskNativeArrowWithExistingStreamRoot() throws Exception { - try (RootAllocator allocator = new RootAllocator()) { - Schema schema = new Schema(List.of(new Field("val", FieldType.nullable(new ArrowType.Int(32, true)), null))); + public void testProcessBatchTaskNonArrowResponseDelegatesToSendBatch() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + doAnswer(invocation -> { + latch.countDown(); + return null; + }).when(mockListener).onResponseSent(anyLong(), anyString(), any(TransportResponse.class)); - // Simulate existing stream root (second batch scenario) - VectorSchemaRoot streamRoot = VectorSchemaRoot.create(schema, allocator); - when(mockFlightChannel.getRoot()).thenReturn(streamRoot); + doAnswer(invocation -> null).when(mockFlightChannel).sendBatch(any(TransportResponse.class), any()); - VectorSchemaRoot producerRoot = VectorSchemaRoot.create(schema, allocator); - IntVector vec = (IntVector) producerRoot.getVector("val"); - vec.allocateNew(); - vec.setSafe(0, 99); - vec.setValueCount(1); - producerRoot.setRowCount(1); + handler.sendResponseBatch( + Version.CURRENT, + Collections.emptySet(), + mockFlightChannel, + mock(FlightTransportChannel.class), + 1L, + "test-action", + mock(TransportResponse.class), + false, + false, + false + ); - CountDownLatch latch = new CountDownLatch(1); + assertTrue("Task should complete", latch.await(5, TimeUnit.SECONDS)); + verify(mockFlightChannel).sendBatch(any(TransportResponse.class), any()); + } - doAnswer(invocation -> { - VectorStreamOutput out = invocation.getArgument(1); - VectorSchemaRoot sentRoot = out.getRoot(); - // Should reuse the existing stream root - assertSame(streamRoot, sentRoot); - assertEquals(1, sentRoot.getRowCount()); - assertEquals(99, ((IntVector) sentRoot.getVector("val")).get(0)); - return null; - }).when(mockFlightChannel).sendBatch(any(), any(VectorStreamOutput.class), any()); + // --- Hand-off discipline: a batch that never reaches the channel must be released (S7) --- - doAnswer(invocation -> { - latch.countDown(); - return null; - }).when(mockListener).onResponseSent(anyLong(), anyString(), any(TransportResponse.class)); + /** + * When the back-pressure gate throws (cancel/timeout), the batch is never submitted, so the + * handler must free the source root via releaseUnsent and must NOT submit the task. + */ + public void testSendResponseBatchReleasesSourceWhenGateThrows() { + ExecutorService submitTrap = mock(ExecutorService.class); + when(mockFlightChannel.getExecutor()).thenReturn(submitTrap); - TestArrowResponse response = new TestArrowResponse(producerRoot); - handler.sendResponseBatch( + StreamException cancelled = StreamException.cancelled("client gone"); + doThrow(cancelled).when(mockFlightChannel).awaitReadyOrThrow(); + + TransportResponse response = mock(TransportResponse.class); + StreamException thrown = expectThrows( + StreamException.class, + () -> handler.sendResponseBatch( Version.CURRENT, Collections.emptySet(), mockFlightChannel, @@ -308,12 +317,267 @@ public void testProcessBatchTaskNativeArrowWithExistingStreamRoot() throws Excep "test-action", response, false, + false, false - ); + ) + ); - assertTrue("Task should complete", latch.await(5, TimeUnit.SECONDS)); - streamRoot.close(); - } + assertSame(cancelled, thrown); + verify(mockFlightChannel).releaseUnsent(response); // freed on the failed-handoff path + verify(submitTrap, never()).execute(any()); // not submitted after the gate failed + } + + /** + * When the executor rejects the task (e.g. shutdown), the batch never runs, so the handler must + * free the source root via releaseUnsent. + */ + public void testSendResponseBatchReleasesSourceWhenExecutorRejects() { + ExecutorService rejectingExecutor = mock(ExecutorService.class); + when(mockFlightChannel.getExecutor()).thenReturn(rejectingExecutor); + doThrow(new RejectedExecutionException("shutting down")).when(rejectingExecutor).execute(any()); + + TransportResponse response = mock(TransportResponse.class); + expectThrows( + RejectedExecutionException.class, + () -> handler.sendResponseBatch( + Version.CURRENT, + Collections.emptySet(), + mockFlightChannel, + mock(FlightTransportChannel.class), + 1L, + "test-action", + response, + false, + false, + false + ) + ); + + verify(mockFlightChannel).releaseUnsent(response); + } + + /** A successful hand-off must NOT also release the source (it is owned by sendBatch's finally). */ + public void testSendResponseBatchDoesNotReleaseOnSuccessfulHandoff() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + doAnswer(invocation -> { + latch.countDown(); + return null; + }).when(mockListener).onResponseSent(anyLong(), anyString(), any(TransportResponse.class)); + doAnswer(invocation -> null).when(mockFlightChannel).sendBatch(any(TransportResponse.class), any()); + + TransportResponse response = mock(TransportResponse.class); + handler.sendResponseBatch( + Version.CURRENT, + Collections.emptySet(), + mockFlightChannel, + mock(FlightTransportChannel.class), + 1L, + "test-action", + response, + false, + false, + false + ); + + assertTrue("Task should complete", latch.await(5, TimeUnit.SECONDS)); + verify(mockFlightChannel, never()).releaseUnsent(any()); + } + + // --- failStream: a batch send failure fails the stream instead of hanging the consumer --- + + public void testProcessBatchTaskFailureSendsErrorAndReleasesChannel() throws Exception { + doThrow(new RuntimeException("send failed")).when(mockFlightChannel).sendBatch(any(TransportResponse.class), any()); + + FlightTransportChannel transportChannel = mock(FlightTransportChannel.class); + CountDownLatch notified = new CountDownLatch(1); + doAnswer(invocation -> { + notified.countDown(); + return null; + }).when(mockListener).onResponseSent(anyLong(), anyString(), any(Exception.class)); + + handler.sendResponseBatch( + Version.CURRENT, + Collections.emptySet(), + mockFlightChannel, + transportChannel, + 1L, + "test-action", + mock(TransportResponse.class), + false, + false, + false + ); + + assertTrue("failed batch send must terminate the stream", notified.await(5, TimeUnit.SECONDS)); + // Stream is failed BEFORE the listener is notified (consumer sees an error, not a hang). + org.mockito.InOrder inOrder = org.mockito.Mockito.inOrder(mockFlightChannel, transportChannel, mockListener); + inOrder.verify(mockFlightChannel).sendError(any(), any(Exception.class)); + inOrder.verify(transportChannel).releaseChannel(true); + inOrder.verify(mockListener).onResponseSent(anyLong(), anyString(), any(Exception.class)); + } + + public void testProcessBatchTaskFlightRuntimeExceptionFailsStreamWithMappedError() throws Exception { + FlightRuntimeException flightError = CallStatus.INTERNAL.withDescription("flight send failed").toRuntimeException(); + doThrow(flightError).when(mockFlightChannel).sendBatch(any(TransportResponse.class), any()); + + FlightTransportChannel transportChannel = mock(FlightTransportChannel.class); + CountDownLatch notified = new CountDownLatch(1); + AtomicReference notifiedArg = new AtomicReference<>(); + doAnswer(invocation -> { + notifiedArg.set(invocation.getArgument(2)); + notified.countDown(); + return null; + }).when(mockListener).onResponseSent(anyLong(), anyString(), any(Exception.class)); + + handler.sendResponseBatch( + Version.CURRENT, + Collections.emptySet(), + mockFlightChannel, + transportChannel, + 1L, + "test-action", + mock(TransportResponse.class), + false, + false, + false + ); + + assertTrue("FlightRuntimeException must terminate the stream", notified.await(5, TimeUnit.SECONDS)); + verify(mockFlightChannel).sendError(any(), any(Exception.class)); + verify(transportChannel).releaseChannel(true); + assertTrue("listener should receive the mapped StreamException", notifiedArg.get() instanceof StreamException); + } + + public void testFailStreamSwallowsSendErrorFailureAndStillReleases() throws Exception { + doThrow(new RuntimeException("send failed")).when(mockFlightChannel).sendBatch(any(TransportResponse.class), any()); + // sendError itself fails (consumer already gone) — must be swallowed. + doThrow(new RuntimeException("sendError failed too")).when(mockFlightChannel).sendError(any(), any(Exception.class)); + + FlightTransportChannel transportChannel = mock(FlightTransportChannel.class); + CountDownLatch released = new CountDownLatch(1); + doAnswer(invocation -> { + released.countDown(); + return null; + }).when(transportChannel).releaseChannel(true); + + CountDownLatch notified = new CountDownLatch(1); + AtomicReference notifiedArg = new AtomicReference<>(); + doAnswer(invocation -> { + notifiedArg.set(invocation.getArgument(2)); + notified.countDown(); + return null; + }).when(mockListener).onResponseSent(anyLong(), anyString(), any(Exception.class)); + + handler.sendResponseBatch( + Version.CURRENT, + Collections.emptySet(), + mockFlightChannel, + transportChannel, + 1L, + "test-action", + mock(TransportResponse.class), + false, + false, + false + ); + + assertTrue("channel must still be released after sendError fails", released.await(5, TimeUnit.SECONDS)); + assertTrue("listener must still be notified after sendError fails", notified.await(5, TimeUnit.SECONDS)); + verify(transportChannel).releaseChannel(true); + // The sendError failure must not be lost: it is attached as a suppressed exception on the + // original cause that the listener receives. + assertTrue("listener arg must be the original cause", notifiedArg.get() instanceof Exception); + Throwable[] suppressed = ((Throwable) notifiedArg.get()).getSuppressed(); + assertEquals("the sendError failure must be attached as suppressed", 1, suppressed.length); + assertEquals("sendError failed too", suppressed[0].getMessage()); + } + + public void testFailStreamWithNullTransportChannelDoesNotThrow() throws Exception { + doThrow(new RuntimeException("send failed")).when(mockFlightChannel).sendBatch(any(TransportResponse.class), any()); + + CountDownLatch notified = new CountDownLatch(1); + doAnswer(invocation -> { + notified.countDown(); + return null; + }).when(mockListener).onResponseSent(anyLong(), anyString(), any(Exception.class)); + + handler.sendResponseBatch( + Version.CURRENT, + Collections.emptySet(), + mockFlightChannel, + null, + 1L, + "test-action", + mock(TransportResponse.class), + false, + false, + false + ); + + assertTrue("stream must still be failed and the listener notified", notified.await(5, TimeUnit.SECONDS)); + verify(mockFlightChannel).sendError(any(), any(Exception.class)); + } + + /** + * A non-Exception {@code Throwable} from the send (e.g. OOM / AssertionError after the channel + * adopted the stream root) must still release the channel so its close()-posted stream-root free + * runs — the batch task is non-terminal, so {@code BatchTask.close()} would not release it. The + * {@code Throwable} must not be swallowed. + */ + public void testProcessBatchTaskThrowableReleasesChannel() throws Exception { + doThrow(new AssertionError("putNext boom")).when(mockFlightChannel).sendBatch(any(TransportResponse.class), any()); + + FlightTransportChannel transportChannel = mock(FlightTransportChannel.class); + CountDownLatch released = new CountDownLatch(1); + doAnswer(invocation -> { + released.countDown(); + return null; + }).when(transportChannel).releaseChannel(true); + + handler.sendResponseBatch( + Version.CURRENT, + Collections.emptySet(), + mockFlightChannel, + transportChannel, + 1L, + "test-action", + mock(TransportResponse.class), + false, + false, + false + ); + + // The Throwable path must release the channel (-> close() -> stream-root free) even though + // failStream is not invoked for a non-Exception Throwable. + assertTrue("channel must be released on a non-Exception Throwable", released.await(5, TimeUnit.SECONDS)); + verify(transportChannel).releaseChannel(true); + } + + /** With no transport channel, the Throwable path must close the channel directly (still frees the root). */ + public void testProcessBatchTaskThrowableClosesChannelWhenNoTransportChannel() throws Exception { + doThrow(new AssertionError("putNext boom")).when(mockFlightChannel).sendBatch(any(TransportResponse.class), any()); + + CountDownLatch closed = new CountDownLatch(1); + doAnswer(invocation -> { + closed.countDown(); + return null; + }).when(mockFlightChannel).close(); + + handler.sendResponseBatch( + Version.CURRENT, + Collections.emptySet(), + mockFlightChannel, + null, + 1L, + "test-action", + mock(TransportResponse.class), + false, + false, + false + ); + + assertTrue("channel must be closed directly when there is no transport channel", closed.await(5, TimeUnit.SECONDS)); + verify(mockFlightChannel).close(); } // --- processCompleteTask error path --- @@ -373,6 +637,7 @@ public void testSendResponseBatchGatesBeforeExecutor() throws Exception { "test-action", mock(TransportResponse.class), false, + false, false ); @@ -382,6 +647,144 @@ public void testSendResponseBatchGatesBeforeExecutor() throws Exception { verify(mockFlightChannel).awaitReadyOrThrow(); } + /** Async (sync=false): the batch send is handed to the channel executor, off the calling thread. */ + public void testAsyncSendUsesChannelExecutor() throws Exception { + ExecutorService submitTrap = mock(ExecutorService.class); + when(mockFlightChannel.getExecutor()).thenReturn(submitTrap); + + handler.sendResponseBatch( + Version.CURRENT, + Collections.emptySet(), + mockFlightChannel, + mock(FlightTransportChannel.class), + 1L, + "test-action", + mock(TransportResponse.class), + false, + false, + false // sync=false + ); + + verify(submitTrap).execute(any()); // dispatched to the executor + } + + /** + * Sync (sync=true): the batch send is submitted to the channel executor (so it is serialized against + * the root free that close() posts to the same executor) AND the caller blocks until it has run. + * Contrast with async, which dispatches via execute() and returns without waiting. + */ + public void testSyncSendRunsOnExecutorAndBlocks() throws Exception { + // Real named single-thread executor: the send must run ON it (not the caller thread), and the + // sync call must not return until the send has completed. + ExecutorService flightExecutor = Executors.newSingleThreadExecutor(r -> new Thread(r, "flight-eventloop-unittest")); + try { + when(mockFlightChannel.getExecutor()).thenReturn(flightExecutor); + AtomicBoolean sentBeforeReturn = new AtomicBoolean(false); + AtomicReference ranOnThread = new AtomicReference<>(); + doAnswer(invocation -> { + ranOnThread.set(Thread.currentThread().getName()); + sentBeforeReturn.set(true); + return null; + }).when(mockListener).onResponseSent(anyLong(), anyString(), any(TransportResponse.class)); + + handler.sendResponseBatch( + Version.CURRENT, + Collections.emptySet(), + mockFlightChannel, + mock(FlightTransportChannel.class), + 1L, + "test-action", + mock(TransportResponse.class), + false, + false, + true // sync=true + ); + + // sync blocks on the submitted task, so the send has completed by the time the call returns... + assertTrue("sync send must have run (and notified) before returning", sentBeforeReturn.get()); + // ...and it ran on the flight executor thread, not the calling test thread. + assertEquals("sync send must run on the flight executor, not the caller", "flight-eventloop-unittest", ranOnThread.get()); + } finally { + flightExecutor.shutdownNow(); + flightExecutor.awaitTermination(5, TimeUnit.SECONDS); + } + } + + /** + * A sync send whose executor work throws (e.g. onResponseSent itself fails) must surface the failure + * to the caller as a StreamException rather than hang or swallow it — the ExecutionException unwrap in + * runOnExecutorAndWait. Uses an executor that runs the task but lets its exception escape the Future. + */ + public void testSyncSendSurfacesWorkFailureAsStreamException() throws Exception { + ExecutorService flightExecutor = Executors.newSingleThreadExecutor(); + try { + when(mockFlightChannel.getExecutor()).thenReturn(flightExecutor); + // sendBatch's own catch routes processBatchTask failures to the listener; make that listener + // call itself throw so the exception escapes the submitted Runnable and reaches Future.get(). + doThrow(new RuntimeException("listener boom")).when(mockListener).onResponseSent(anyLong(), anyString(), any(Exception.class)); + doThrow(new RuntimeException("send failed")).when(mockFlightChannel).sendBatch(any(TransportResponse.class), any()); + + StreamException thrown = expectThrows( + StreamException.class, + () -> handler.sendResponseBatch( + Version.CURRENT, + Collections.emptySet(), + mockFlightChannel, + mock(FlightTransportChannel.class), + 1L, + "test-action", + mock(TransportResponse.class), + false, + false, + true // sync + ) + ); + assertEquals(StreamErrorCode.INTERNAL, thrown.getErrorCode()); + } finally { + flightExecutor.shutdownNow(); + flightExecutor.awaitTermination(5, TimeUnit.SECONDS); + } + } + + /** + * Regression: a sync send whose blocking wait throws AFTER the task was accepted must NOT also run + * releaseUnsent — the accepted task already owns and frees the source, so a second free would be a + * double free. Ownership (handedOff) is transferred at submit time, before the wait, so the handler's + * finally must skip releaseUnsent even though the wait threw. + */ + public void testSyncSendDoesNotReleaseUnsentAfterTaskAccepted() throws Exception { + ExecutorService flightExecutor = Executors.newSingleThreadExecutor(); + try { + when(mockFlightChannel.getExecutor()).thenReturn(flightExecutor); + // Force the blocking wait to throw AFTER submit succeeds: the task runs, its send fails, and + // routing that failure to the listener throws, so the exception escapes to Future.get(). + doThrow(new RuntimeException("send failed")).when(mockFlightChannel).sendBatch(any(TransportResponse.class), any()); + doThrow(new RuntimeException("listener boom")).when(mockListener).onResponseSent(anyLong(), anyString(), any(Exception.class)); + + expectThrows( + StreamException.class, + () -> handler.sendResponseBatch( + Version.CURRENT, + Collections.emptySet(), + mockFlightChannel, + mock(FlightTransportChannel.class), + 1L, + "test-action", + mock(TransportResponse.class), + false, + false, + true // sync + ) + ); + + // The accepted task owns the source; releaseUnsent must NOT run (that would be the double free). + verify(mockFlightChannel, never()).releaseUnsent(any()); + } finally { + flightExecutor.shutdownNow(); + flightExecutor.awaitTermination(5, TimeUnit.SECONDS); + } + } + /** * If awaitReadyOrThrow throws (timeout / cancellation), the StreamException must * propagate to the caller — sendResponseBatch must NOT submit the BatchTask, and @@ -391,14 +794,11 @@ public void testSendResponseBatchPropagatesAwaitReadyException() { ExecutorService submitTrap = mock(ExecutorService.class); when(mockFlightChannel.getExecutor()).thenReturn(submitTrap); - org.opensearch.transport.stream.StreamException timeoutEx = new org.opensearch.transport.stream.StreamException( - org.opensearch.transport.stream.StreamErrorCode.TIMED_OUT, - "consumer not ready" - ); + StreamException timeoutEx = new StreamException(StreamErrorCode.TIMED_OUT, "consumer not ready"); doThrow(timeoutEx).when(mockFlightChannel).awaitReadyOrThrow(); - org.opensearch.transport.stream.StreamException thrown = expectThrows( - org.opensearch.transport.stream.StreamException.class, + StreamException thrown = expectThrows( + StreamException.class, () -> handler.sendResponseBatch( Version.CURRENT, Collections.emptySet(), @@ -408,12 +808,13 @@ public void testSendResponseBatchPropagatesAwaitReadyException() { "test-action", mock(TransportResponse.class), false, + false, false ) ); assertSame(timeoutEx, thrown); // Crucially: we must not have submitted the BatchTask after the gate failed. - verify(submitTrap, org.mockito.Mockito.never()).execute(any()); + verify(submitTrap, never()).execute(any()); } public void testBatchTaskCloseWithIsErrorCallsReleaseChannelWithTrue() { @@ -439,7 +840,18 @@ public void testBatchTaskCloseWithIsErrorCallsReleaseChannelWithTrue() { verify(mockTransportChannel).releaseChannel(true); } - // --- Test helper --- + // --- Test helpers --- + + private static VectorSchemaRoot newSingleIntRoot(BufferAllocator allocator, int value) { + Schema schema = new Schema(List.of(new Field("val", FieldType.nullable(new ArrowType.Int(32, true)), null))); + VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator); + IntVector vec = (IntVector) root.getVector("val"); + vec.allocateNew(); + vec.setSafe(0, value); + vec.setValueCount(1); + root.setRowCount(1); + return root; + } static class TestArrowResponse extends ArrowBatchResponse { TestArrowResponse(VectorSchemaRoot root) { diff --git a/plugins/arrow-flight-rpc/src/test/java/org/opensearch/arrow/flight/transport/FlightServerChannelTests.java b/plugins/arrow-flight-rpc/src/test/java/org/opensearch/arrow/flight/transport/FlightServerChannelTests.java index b5e19d6de3710..bb1434383206a 100644 --- a/plugins/arrow-flight-rpc/src/test/java/org/opensearch/arrow/flight/transport/FlightServerChannelTests.java +++ b/plugins/arrow-flight-rpc/src/test/java/org/opensearch/arrow/flight/transport/FlightServerChannelTests.java @@ -19,10 +19,15 @@ import org.apache.arrow.vector.types.pojo.FieldType; import org.apache.arrow.vector.types.pojo.Schema; import org.opensearch.arrow.flight.stats.FlightCallTracker; +import org.opensearch.arrow.transport.ArrowBatchResponse; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.transport.TransportResponse; import org.opensearch.test.OpenSearchTestCase; import org.opensearch.transport.stream.StreamErrorCode; import org.opensearch.transport.stream.StreamException; +import java.io.IOException; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -32,10 +37,12 @@ import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -92,6 +99,19 @@ private FlightServerChannel newChannel(long readyTimeoutMillis) { return new FlightServerChannel(listener, allocator, middleware, callTracker, executor, readyTimeoutMillis); } + private static ByteBuffer emptyHeader() { + return ByteBuffer.allocate(0); + } + + /** + * close() posts the stream-root free onto the flight executor (single-writer model). Drain the + * executor so the free has run before asserting allocator state — submitting a no-op and waiting + * for it guarantees the earlier-queued free task completed (FIFO, single-threaded). + */ + private void drainExecutor() throws Exception { + executor.submit(() -> {}).get(5, TimeUnit.SECONDS); + } + public void testAwaitReadyFastPath() { ready.set(true); FlightServerChannel ch = newChannel(5_000); @@ -262,6 +282,8 @@ public void testAwaitReadyAfterCancelStaysCancelled() { assertEquals(StreamErrorCode.CANCELLED, ex.getErrorCode()); } + // ── Native send: metadata framing ── + /** * sendBatch with non-null metadata must call {@code putNext(ArrowBuf)} (not {@code putNext()}) * with a buffer carrying the exact bytes the producer attached. @@ -285,14 +307,15 @@ public void testSendBatchWithMetadataCallsPutNextWithBuf() throws Exception { return null; }).when(listener).putNext(any(ArrowBuf.class)); - try (VectorStreamOutput out = VectorStreamOutput.forNativeArrow(root)) { - ch.sendBatch(ByteBuffer.allocate(0), out, metadata); - } + ch.sendBatch(new TestArrowResponse(root, metadata), FlightServerChannelTests::emptyHeader); verify(listener, times(1)).putNext(any(ArrowBuf.class)); verify(listener, never()).putNext(); assertArrayEquals("metadata bytes must round-trip into the ArrowBuf", metadata, capturedMetadata.get()); - root.close(); + // Channel owns the (transferred) stream root; close() posts the free to the executor. + ch.close(); + drainExecutor(); + assertEquals("all buffers must be freed", 0, realAllocator.getAllocatedMemory()); } } @@ -302,13 +325,381 @@ public void testSendBatchWithoutMetadataCallsPutNextNoArg() throws Exception { FlightServerChannel ch = new FlightServerChannel(listener, realAllocator, middleware, callTracker, executor, 5_000); VectorSchemaRoot root = newSingleIntRoot(realAllocator, 1); - try (VectorStreamOutput out = VectorStreamOutput.forNativeArrow(root)) { - ch.sendBatch(ByteBuffer.allocate(0), out); - } + ch.sendBatch(new TestArrowResponse(root), FlightServerChannelTests::emptyHeader); verify(listener, times(1)).putNext(); verify(listener, never()).putNext(any(ArrowBuf.class)); - root.close(); + ch.close(); + drainExecutor(); + assertEquals("all buffers must be freed", 0, realAllocator.getAllocatedMemory()); + } + } + + // ── Source-root ownership (the channel frees it exactly once) ── + + /** A successful native send empties+frees the source root and retains the stream root until close(). */ + public void testSendBatchFreesSourceRootRetainsStreamRoot() throws Exception { + try (RootAllocator realAllocator = new RootAllocator()) { + FlightServerChannel ch = new FlightServerChannel(listener, realAllocator, middleware, callTracker, executor, 5_000); + VectorSchemaRoot source = newSingleIntRoot(realAllocator, 5); + + ch.sendBatch(new TestArrowResponse(source), FlightServerChannelTests::emptyHeader); + + assertEquals("source root must be empty after transfer", 0, source.getRowCount()); + assertNotNull("channel must retain the stream root", ch.getRoot()); + assertTrue("stream root buffers must still be alive before close", realAllocator.getAllocatedMemory() > 0); + + ch.close(); + drainExecutor(); + assertEquals("close must free the stream root", 0, realAllocator.getAllocatedMemory()); + } + } + + /** When the channel is cancelled, sendBatch rejects and frees the source root exactly once (no leak). */ + public void testSendBatchAfterCancelRejectsAndFreesSource() throws Exception { + try (RootAllocator realAllocator = new RootAllocator()) { + FlightServerChannel ch = new FlightServerChannel(listener, realAllocator, middleware, callTracker, executor, 5_000); + listenerCancelled.set(true); + capturedCancelHandler.get().run(); // cancel -> close + + VectorSchemaRoot source = newSingleIntRoot(realAllocator, 9); + StreamException ex = expectThrows( + StreamException.class, + () -> ch.sendBatch(new TestArrowResponse(source), FlightServerChannelTests::emptyHeader) + ); + assertEquals(StreamErrorCode.CANCELLED, ex.getErrorCode()); + verify(listener, never()).start(any()); + assertEquals("rejected source must be freed (no leak, no stream root created)", 0, realAllocator.getAllocatedMemory()); + } + } + + /** When the channel is closed, sendBatch rejects and frees the source root exactly once. */ + public void testSendBatchAfterCloseRejectsAndFreesSource() throws Exception { + try (RootAllocator realAllocator = new RootAllocator()) { + FlightServerChannel ch = new FlightServerChannel(listener, realAllocator, middleware, callTracker, executor, 5_000); + ch.close(); + + VectorSchemaRoot source = newSingleIntRoot(realAllocator, 3); + expectThrows( + IllegalStateException.class, + () -> ch.sendBatch(new TestArrowResponse(source), FlightServerChannelTests::emptyHeader) + ); + assertEquals("rejected source must be freed", 0, realAllocator.getAllocatedMemory()); + } + } + + /** An empty-field-vector native batch throws but still frees the source (S8 early-throw path). */ + public void testSendBatchEmptyFieldVectorsFreesSource() throws Exception { + try (RootAllocator realAllocator = new RootAllocator()) { + FlightServerChannel ch = new FlightServerChannel(listener, realAllocator, middleware, callTracker, executor, 5_000); + VectorSchemaRoot empty = new VectorSchemaRoot(new ArrayList<>(), new ArrayList<>(), 0); + + expectThrows( + IllegalStateException.class, + () -> ch.sendBatch(new TestArrowResponse(empty), FlightServerChannelTests::emptyHeader) + ); + assertEquals("source must be freed even on the empty-vectors throw", 0, realAllocator.getAllocatedMemory()); + } + } + + /** If the header supplier throws (S8), the source root is still freed and no stream root leaks. */ + public void testSendBatchHeaderThrowsFreesSource() throws Exception { + try (RootAllocator realAllocator = new RootAllocator()) { + FlightServerChannel ch = new FlightServerChannel(listener, realAllocator, middleware, callTracker, executor, 5_000); + VectorSchemaRoot source = newSingleIntRoot(realAllocator, 2); + + expectThrows(IOException.class, () -> ch.sendBatch(new TestArrowResponse(source), () -> { throw new IOException("boom"); })); + + verify(listener, never()).start(any()); + assertEquals("header-throw must not leak source or created stream root", 0, realAllocator.getAllocatedMemory()); + } + } + + /** + * If {@code putNext} throws a non-Exception {@code Throwable} (e.g. OOM / AssertionError) AFTER the + * channel adopted the stream root, the source root is still freed by sendBatch's finally, the stream + * root is retained (owned by the channel), and a subsequent close() frees it — no leak. (The handler + * is responsible for invoking close() on this path; see FlightOutboundHandlerTests.) + */ + public void testSendBatchPutNextThrowableFreesSourceAndStreamRootClosable() throws Exception { + try (RootAllocator realAllocator = new RootAllocator()) { + FlightServerChannel ch = new FlightServerChannel(listener, realAllocator, middleware, callTracker, executor, 5_000); + VectorSchemaRoot source = newSingleIntRoot(realAllocator, 15); + doThrow(new AssertionError("putNext boom")).when(listener).putNext(); + + expectThrows(AssertionError.class, () -> ch.sendBatch(new TestArrowResponse(source), FlightServerChannelTests::emptyHeader)); + + assertEquals("source root must be empty/freed after the throw", 0, source.getRowCount()); + assertNotNull("stream root was adopted before putNext threw", ch.getRoot()); + assertTrue("stream root buffers are still alive (owned by the channel)", realAllocator.getAllocatedMemory() > 0); + + ch.close(); + drainExecutor(); + assertEquals("close() must free the adopted stream root after a putNext Throwable", 0, realAllocator.getAllocatedMemory()); + } + } + + /** releaseUnsent frees the source root of a batch that never reached sendBatch (failed hand-off). */ + public void testReleaseUnsentFreesSource() throws Exception { + try (RootAllocator realAllocator = new RootAllocator()) { + FlightServerChannel ch = new FlightServerChannel(listener, realAllocator, middleware, callTracker, executor, 5_000); + VectorSchemaRoot source = newSingleIntRoot(realAllocator, 11); + + ch.releaseUnsent(new TestArrowResponse(source)); + assertEquals("releaseUnsent must free the unsent source root", 0, realAllocator.getAllocatedMemory()); + } + } + + // ── Byte-serialized path: stream root reused, freed once ── + + /** Multi-batch byte path reuses one VarBinary stream root and frees it exactly once at close(). */ + public void testByteMultiBatchReusesStreamRootFreedOnce() throws Exception { + try (RootAllocator realAllocator = new RootAllocator()) { + FlightServerChannel ch = new FlightServerChannel(listener, realAllocator, middleware, callTracker, executor, 5_000); + + ch.sendBatch(new BytesResponse(new byte[] { 1, 2, 3 }), FlightServerChannelTests::emptyHeader); + VectorSchemaRoot afterFirst = ch.getRoot(); + assertNotNull("byte path must create a stream root", afterFirst); + + ch.sendBatch(new BytesResponse(new byte[] { 4, 5 }), FlightServerChannelTests::emptyHeader); + assertSame("byte path must reuse the same stream root across batches", afterFirst, ch.getRoot()); + + verify(listener, times(1)).start(any()); // start only on the first batch + verify(listener, times(2)).putNext(); + + ch.close(); + drainExecutor(); + assertEquals("byte stream root must be freed exactly once at close", 0, realAllocator.getAllocatedMemory()); + } + } + + // ── Terminal-op contracts ── + + /** completeStream is a no-op after the channel was cancelled (no terminal op after cancel). */ + public void testCompleteStreamNoOpAfterCancel() { + FlightServerChannel ch = newChannel(5_000); + listenerCancelled.set(true); + capturedCancelHandler.get().run(); // cancel -> close + + ch.completeStream(emptyHeader()); + verify(listener, never()).completed(); + } + + /** sendError is a no-op after the channel was cancelled. */ + public void testSendErrorNoOpAfterCancel() { + FlightServerChannel ch = newChannel(5_000); + listenerCancelled.set(true); + capturedCancelHandler.get().run(); + + ch.sendError(emptyHeader(), new RuntimeException("late")); + verify(listener, never()).error(any()); + } + + /** At most one terminal gRPC op: a second completeStream is a no-op. */ + public void testCompleteStreamOnlyOnce() { + FlightServerChannel ch = newChannel(5_000); + ch.completeStream(emptyHeader()); + ch.completeStream(emptyHeader()); + verify(listener, times(1)).completed(); + } + + /** Once completed(), a later sendError must not also issue error() (single terminal op). */ + public void testSendErrorNoOpAfterComplete() { + FlightServerChannel ch = newChannel(5_000); + ch.completeStream(emptyHeader()); + ch.sendError(emptyHeader(), new RuntimeException("after complete")); + verify(listener, times(1)).completed(); + verify(listener, never()).error(any()); + } + + // ── close() fire-once + listener semantics ── + + /** Concurrent close() callers must each fire every close listener exactly once (no double-fire). */ + public void testCloseIsFireOnceUnderConcurrentCallers() throws Exception { + FlightServerChannel ch = newChannel(5_000); + AtomicInteger fires = new AtomicInteger(0); + int listeners = 3; + for (int i = 0; i < listeners; i++) { + ch.addCloseListener(org.opensearch.core.action.ActionListener.wrap(r -> fires.incrementAndGet(), e -> {})); + } + + int threads = 8; + CountDownLatch start = new CountDownLatch(1); + List ts = new ArrayList<>(); + for (int i = 0; i < threads; i++) { + Thread t = new Thread(() -> { + try { + start.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + ch.close(); + }); + ts.add(t); + t.start(); + } + start.countDown(); + for (Thread t : ts) { + t.join(5_000); + } + assertEquals("each close listener must fire exactly once across all concurrent close() callers", listeners, fires.get()); + } + + /** A close listener registered after close() fires immediately. */ + public void testAddCloseListenerAfterCloseFiresImmediately() { + FlightServerChannel ch = newChannel(5_000); + ch.close(); + AtomicBoolean fired = new AtomicBoolean(false); + ch.addCloseListener(org.opensearch.core.action.ActionListener.wrap(r -> fired.set(true), e -> {})); + assertTrue("listener added after close must fire immediately", fired.get()); + } + + /** A throwing close listener must not strand the remaining listeners. */ + public void testThrowingCloseListenerDoesNotStrandOthers() { + FlightServerChannel ch = newChannel(5_000); + AtomicBoolean secondFired = new AtomicBoolean(false); + ch.addCloseListener(org.opensearch.core.action.ActionListener.wrap(r -> { throw new RuntimeException("listener boom"); }, e -> {})); + ch.addCloseListener(org.opensearch.core.action.ActionListener.wrap(r -> secondFired.set(true), e -> {})); + + ch.close(); + assertTrue("a throwing listener must not prevent later listeners from firing", secondFired.get()); + } + + // ── S4: cancel races an in-flight send; the root free must run behind the send (no use-after-free) ── + + /** + * Single-writer model: the send runs on the flight executor and {@code close()} posts the + * stream-root free onto that same executor. While a send is mid-{@code putNext} (occupying the + * single executor thread), a concurrent cancel -> {@code close()} must NOT free the stream root — + * the free is queued behind the in-flight send and runs only after it returns. The close listener + * fires immediately on {@code close()} (it touches no Arrow buffers), but the buffers stay alive + * until the send completes, then are freed exactly once. + */ + public void testCancelDuringInFlightSendDefersRootFree() throws Exception { + try (RootAllocator realAllocator = new RootAllocator()) { + FlightServerChannel ch = new FlightServerChannel(listener, realAllocator, middleware, callTracker, executor, 5_000); + VectorSchemaRoot source = newSingleIntRoot(realAllocator, 13); + + CountDownLatch inPutNext = new CountDownLatch(1); + CountDownLatch releasePutNext = new CountDownLatch(1); + doAnswer(inv -> { + inPutNext.countDown(); + assertTrue("test timed out waiting to release putNext", releasePutNext.await(5, TimeUnit.SECONDS)); + return null; + }).when(listener).putNext(); + + // Run the send ON the executor, exactly as production does — this is what serializes the + // close()-posted free behind it. + AtomicReference sendError = new AtomicReference<>(); + executor.execute(() -> { + try { + ch.sendBatch(new TestArrowResponse(source), FlightServerChannelTests::emptyHeader); + } catch (Throwable t) { + sendError.set(t); + } + }); + + assertTrue("send must reach putNext", inPutNext.await(5, TimeUnit.SECONDS)); + + // Fire the cancel -> close() from this (gRPC-like) thread. It must NOT block, and must post + // the root free onto the busy executor rather than freeing inline. + capturedCancelHandler.get().run(); + assertFalse("channel must be marked closed immediately", ch.isOpen()); + + // The executor thread is still inside putNext, so the queued free has not run: buffers live. + assertTrue("stream root must still be alive while the send occupies the executor", realAllocator.getAllocatedMemory() > 0); + + // Let the send finish; the executor then runs the queued free task. drainExecutor() waits + // behind both the send and the free. + releasePutNext.countDown(); + drainExecutor(); + + assertNull("send must complete without error", sendError.get()); + assertEquals("stream root must be freed exactly once after the send drains", 0, realAllocator.getAllocatedMemory()); + } + } + + /** + * Cross-thread close() never frees buffers inline: when close() is called from a non-executor + * thread, the stream root is freed on the executor (single-writer), so it stays alive until the + * executor runs the posted free. + */ + public void testCloseFreesStreamRootOnExecutor() throws Exception { + try (RootAllocator realAllocator = new RootAllocator()) { + FlightServerChannel ch = new FlightServerChannel(listener, realAllocator, middleware, callTracker, executor, 5_000); + // Send a batch on the executor so the channel adopts a stream root, then drain. + executor.submit(() -> { + ch.sendBatch(new TestArrowResponse(newSingleIntRoot(realAllocator, 8)), FlightServerChannelTests::emptyHeader); + return null; + }).get(5, TimeUnit.SECONDS); + assertTrue("stream root should be alive after a batch", realAllocator.getAllocatedMemory() > 0); + + ch.close(); // from the test thread (not the executor) + drainExecutor(); + assertEquals("close() must free the stream root via the executor", 0, realAllocator.getAllocatedMemory()); + } + } + + /** + * Batches queued behind a cancel must not leak. While the executor is busy, we enqueue several + * native batches, then fire the cancel (which sets cancelled/closed and posts the root free behind + * them). When the executor drains: each queued batch hits the {@code cancelled} guard in + * {@code sendBatch}, rejects before touching the stream root, and frees its own source root in the + * {@code finally}; the posted root-free task (last in FIFO order) frees the stream root. Nothing is + * dropped, leaked, or double-freed. + */ + public void testBatchesQueuedBehindCancelAreRejectedAndFreed() throws Exception { + try (RootAllocator realAllocator = new RootAllocator()) { + FlightServerChannel ch = new FlightServerChannel(listener, realAllocator, middleware, callTracker, executor, 5_000); + + // Occupy the single executor thread so the batches below sit in the queue, not run. + CountDownLatch blockExecutor = new CountDownLatch(1); + CountDownLatch executorBusy = new CountDownLatch(1); + executor.execute(() -> { + executorBusy.countDown(); + try { + assertTrue(blockExecutor.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + assertTrue("executor must be busy", executorBusy.await(5, TimeUnit.SECONDS)); + + // Queue several native batches behind the blocked task. Each carries its own source root. + int queued = 4; + List sendErrors = new ArrayList<>(); + for (int i = 0; i < queued; i++) { + VectorSchemaRoot source = newSingleIntRoot(realAllocator, i); + executor.execute(() -> { + try { + ch.sendBatch(new TestArrowResponse(source), FlightServerChannelTests::emptyHeader); + } catch (Throwable t) { + synchronized (sendErrors) { + sendErrors.add(t); + } + } + }); + } + assertTrue("all queued sources must be allocated", realAllocator.getAllocatedMemory() > 0); + + // Cancel now (from the gRPC-like thread): sets cancelled, flips open, posts the root free + // to the BACK of the executor queue (after the queued batches). + capturedCancelHandler.get().run(); + assertFalse("channel must be marked closed immediately", ch.isOpen()); + + // Release the executor and let it drain the blocked task, the queued batches, and the free. + blockExecutor.countDown(); + drainExecutor(); + + assertEquals("every queued batch must have rejected", queued, sendErrors.size()); + for (Throwable t : sendErrors) { + assertTrue("queued batches must reject with CANCELLED, got " + t, t instanceof StreamException); + assertEquals(StreamErrorCode.CANCELLED, ((StreamException) t).getErrorCode()); + } + // No stream root was ever created (every batch rejected before transfer), and each queued + // source was freed by its own sendBatch finally. + verify(listener, never()).start(any()); + assertEquals("no queued source root may leak after cancel", 0, realAllocator.getAllocatedMemory()); } } @@ -322,4 +713,33 @@ private static VectorSchemaRoot newSingleIntRoot(BufferAllocator allocator, int root.setRowCount(1); return root; } + + /** Minimal send-side ArrowBatchResponse for tests. */ + static final class TestArrowResponse extends ArrowBatchResponse { + TestArrowResponse(VectorSchemaRoot root) { + super(root); + } + + TestArrowResponse(VectorSchemaRoot root, byte[] metadata) { + super(root, metadata); + } + + TestArrowResponse(StreamInput in) throws IOException { + super(in); + } + } + + /** Minimal non-Arrow response that drives the byte-serialization path. */ + static final class BytesResponse extends TransportResponse { + private final byte[] data; + + BytesResponse(byte[] data) { + this.data = data; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeByteArray(data); + } + } } diff --git a/plugins/arrow-flight-rpc/src/test/java/org/opensearch/arrow/flight/transport/FlightTransportChannelTests.java b/plugins/arrow-flight-rpc/src/test/java/org/opensearch/arrow/flight/transport/FlightTransportChannelTests.java index e4c42c6064e3a..05ef4faf55c65 100644 --- a/plugins/arrow-flight-rpc/src/test/java/org/opensearch/arrow/flight/transport/FlightTransportChannelTests.java +++ b/plugins/arrow-flight-rpc/src/test/java/org/opensearch/arrow/flight/transport/FlightTransportChannelTests.java @@ -89,7 +89,8 @@ public void testSendResponseBatchSuccess() throws IOException, InterruptedExcept doAnswer(invocation -> { latch.countDown(); return null; - }).when(mockOutboundHandler).sendResponseBatch(any(), any(), any(), any(), anyLong(), any(), any(), anyBoolean(), anyBoolean()); + }).when(mockOutboundHandler) + .sendResponseBatch(any(), any(), any(), any(), anyLong(), any(), any(), anyBoolean(), anyBoolean(), anyBoolean()); channel.sendResponseBatch(response); @@ -103,6 +104,45 @@ public void testSendResponseBatchSuccess() throws IOException, InterruptedExcept eq("test-action"), eq(response), eq(false), + eq(false), + eq(false) + ); + } + + public void testExplicitSyncIsPropagatedToHandler() throws IOException { + TransportResponse response = mock(TransportResponse.class); + + // sync is opt-in and passed straight through to the handler. + channel.sendResponseBatch(response, true); + verify(mockOutboundHandler).sendResponseBatch( + any(), + any(), + any(), + any(), + anyLong(), + any(), + eq(response), + anyBoolean(), + anyBoolean(), + eq(true) + ); + } + + public void testDefaultSendResponseBatchIsAsync() throws IOException { + TransportResponse response = mock(TransportResponse.class); + + // The 1-arg form defaults to async (sync=false); no response type is auto-synced. + channel.sendResponseBatch(response); + verify(mockOutboundHandler).sendResponseBatch( + any(), + any(), + any(), + any(), + anyLong(), + any(), + eq(response), + anyBoolean(), + anyBoolean(), eq(false) ); } @@ -122,7 +162,7 @@ public void testSendResponseBatchWithCancellationException() throws IOException StreamException cancellationException = new StreamException(StreamErrorCode.CANCELLED, "cancelled"); doThrow(cancellationException).when(mockOutboundHandler) - .sendResponseBatch(any(), any(), any(), any(), anyLong(), any(), any(), anyBoolean(), anyBoolean()); + .sendResponseBatch(any(), any(), any(), any(), anyLong(), any(), any(), anyBoolean(), anyBoolean(), anyBoolean()); StreamException thrown = assertThrows(StreamException.class, () -> channel.sendResponseBatch(response)); assertEquals(StreamErrorCode.CANCELLED, thrown.getErrorCode()); @@ -139,7 +179,7 @@ public void testSendResponseBatchWithGenericException() throws IOException { RuntimeException genericException = new RuntimeException("generic error"); doThrow(genericException).when(mockOutboundHandler) - .sendResponseBatch(any(), any(), any(), any(), anyLong(), any(), any(), anyBoolean(), anyBoolean()); + .sendResponseBatch(any(), any(), any(), any(), anyLong(), any(), any(), anyBoolean(), anyBoolean(), anyBoolean()); StreamException thrown = assertThrows(StreamException.class, () -> channel.sendResponseBatch(response)); assertEquals(StreamErrorCode.INTERNAL, thrown.getErrorCode()); @@ -156,7 +196,7 @@ public void testSendResponseBatchWithNonCancelStreamExceptionDoesNotRelease() th StreamException timedOut = new StreamException(StreamErrorCode.TIMED_OUT, "consumer not ready"); doThrow(timedOut).when(mockOutboundHandler) - .sendResponseBatch(any(), any(), any(), any(), anyLong(), any(), any(), anyBoolean(), anyBoolean()); + .sendResponseBatch(any(), any(), any(), any(), anyLong(), any(), any(), anyBoolean(), anyBoolean(), anyBoolean()); StreamException thrown = assertThrows(StreamException.class, () -> channel.sendResponseBatch(response)); assertSame(timedOut, thrown); diff --git a/plugins/arrow-flight-rpc/src/test/java/org/opensearch/arrow/flight/transport/FlightTransportResponseTests.java b/plugins/arrow-flight-rpc/src/test/java/org/opensearch/arrow/flight/transport/FlightTransportResponseTests.java index 07b720c0cf2a0..1de82736079a3 100644 --- a/plugins/arrow-flight-rpc/src/test/java/org/opensearch/arrow/flight/transport/FlightTransportResponseTests.java +++ b/plugins/arrow-flight-rpc/src/test/java/org/opensearch/arrow/flight/transport/FlightTransportResponseTests.java @@ -8,18 +8,37 @@ package org.opensearch.arrow.flight.transport; +import org.apache.arrow.flight.FlightClient; +import org.apache.arrow.flight.FlightStream; +import org.apache.arrow.flight.Ticket; import org.apache.arrow.memory.ArrowBuf; import org.apache.arrow.memory.RootAllocator; import org.opensearch.arrow.transport.ArrowBatchResponse; import org.opensearch.arrow.transport.ArrowBatchResponseHandler; +import org.opensearch.core.common.io.stream.NamedWriteableRegistry; import org.opensearch.core.transport.TransportResponse; import org.opensearch.test.OpenSearchTestCase; +import org.opensearch.transport.Header; import org.opensearch.transport.StreamTransportResponseHandler; import org.opensearch.transport.TransportException; import org.opensearch.transport.TransportResponseHandler; +import org.opensearch.transport.stream.StreamException; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; public class FlightTransportResponseTests extends OpenSearchTestCase { @@ -45,6 +64,135 @@ public void testRealMetricsTrackingWrapperForwards() { assertTrue(wrapped.skipsDeserialization()); } + // ── close() / prefetch-race stream lifecycle ───────────────────────────── + + /** + * Builds a response wired to the given client. Uses package-private collaborators + * directly (the test lives in the same package). + */ + private FlightTransportResponse newResponse(FlightClient client, HeaderContext headerContext) { + return new FlightTransportResponse<>( + new TestArrowHandler(), + 1L, + client, + headerContext, + new Ticket(new byte[0]), + new NamedWriteableRegistry(List.of()), + new FlightTransportConfig() + ); + } + + /** Normal path: once the stream is published, close() closes it. */ + public void testCloseClosesPublishedStream() throws Exception { + FlightClient client = mock(FlightClient.class); + FlightStream stream = mock(FlightStream.class); + when(client.getStream(any(Ticket.class), any())).thenReturn(stream); + when(stream.next()).thenReturn(true); + + FlightTransportResponse response = newResponse(client, new HeaderContext()); + CompletableFuture
future = new CompletableFuture<>(); + response.openAndPrefetchAsync(future); + future.get(5, TimeUnit.SECONDS); // prefetch finished → flightStream published + + response.close(); + // close() ran after publish; the prefetch had already passed its closed-check, so close() owns the close. + verify(stream, timeout(5_000).atLeastOnce()).close(); + } + + /** + * The race the fix targets: close() runs while the prefetch thread is still inside getStream(), + * so close() sees flightStream==null and closes nothing. When the prefetch then publishes the + * stream, its synchronous closed-check must self-close it (closed already true) so the first-batch + * root is not stranded on the client allocator. + */ + public void testCloseDuringPrefetchSelfClosesStream() throws Exception { + FlightStream stream = mock(FlightStream.class); + when(stream.next()).thenReturn(true); + + CountDownLatch enteredGetStream = new CountDownLatch(1); + CountDownLatch proceed = new CountDownLatch(1); + FlightClient client = mock(FlightClient.class); + when(client.getStream(any(Ticket.class), any())).thenAnswer(inv -> { + enteredGetStream.countDown(); + assertTrue("test must release getStream", proceed.await(5, TimeUnit.SECONDS)); + return stream; + }); + + FlightTransportResponse response = newResponse(client, new HeaderContext()); + CompletableFuture
future = new CompletableFuture<>(); + response.openAndPrefetchAsync(future); + + // Prefetch thread is parked inside getStream(); flightStream is still null. + assertTrue(enteredGetStream.await(5, TimeUnit.SECONDS)); + response.close(); // sees flightStream==null → closes nothing itself + verify(stream, never()).close(); + + // Let the prefetch publish the stream; its closed-check must self-close it. + proceed.countDown(); + verify(stream, timeout(5_000)).close(); + } + + /** close() before any prefetch has a null stream — must be a no-op, no NPE. */ + public void testCloseBeforePrefetchIsNoOp() { + FlightClient client = mock(FlightClient.class); + FlightTransportResponse response = newResponse(client, new HeaderContext()); + response.close(); // no stream yet + // A second close is still safe. + response.close(); + } + + /** close() is idempotent: a second call short-circuits and does not re-close the stream. */ + public void testCloseIsIdempotent() throws Exception { + FlightClient client = mock(FlightClient.class); + FlightStream stream = mock(FlightStream.class); + when(client.getStream(any(Ticket.class), any())).thenReturn(stream); + when(stream.next()).thenReturn(true); + + FlightTransportResponse response = newResponse(client, new HeaderContext()); + CompletableFuture
future = new CompletableFuture<>(); + response.openAndPrefetchAsync(future); + future.get(5, TimeUnit.SECONDS); + + response.close(); + response.close(); + response.close(); + // The prefetch passed its closed-check before publishing the future, so it never self-closes; + // only the first close() closes the stream and the later calls short-circuit. + verify(stream, times(1)).close(); + } + + /** A benign already-closed error from the stream is swallowed. */ + public void testCloseSwallowsAlreadyClosedError() throws Exception { + FlightClient client = mock(FlightClient.class); + FlightStream stream = mock(FlightStream.class); + when(client.getStream(any(Ticket.class), any())).thenReturn(stream); + when(stream.next()).thenReturn(true); + doThrow(new IllegalStateException("already closed")).when(stream).close(); + + FlightTransportResponse response = newResponse(client, new HeaderContext()); + CompletableFuture
future = new CompletableFuture<>(); + response.openAndPrefetchAsync(future); + future.get(5, TimeUnit.SECONDS); + + response.close(); // must not propagate the IllegalStateException + } + + /** An unexpected error from the stream is wrapped as a StreamException. */ + public void testCloseRethrowsUnexpectedErrorAsStreamException() throws Exception { + FlightClient client = mock(FlightClient.class); + FlightStream stream = mock(FlightStream.class); + when(client.getStream(any(Ticket.class), any())).thenReturn(stream); + when(stream.next()).thenReturn(true); + doThrow(new RuntimeException("boom")).when(stream).close(); + + FlightTransportResponse response = newResponse(client, new HeaderContext()); + CompletableFuture
future = new CompletableFuture<>(); + response.openAndPrefetchAsync(future); + future.get(5, TimeUnit.SECONDS); + + expectThrows(StreamException.class, response::close); + } + public void testCopyMetadataNullBuffer() { assertNull(FlightTransportResponse.copyMetadata(null)); } diff --git a/plugins/repository-hdfs/build.gradle b/plugins/repository-hdfs/build.gradle index 715b4faed9417..deba95792d827 100644 --- a/plugins/repository-hdfs/build.gradle +++ b/plugins/repository-hdfs/build.gradle @@ -349,12 +349,9 @@ thirdPartyAudit { 'org.apache.hadoop.hdfs.shortcircuit.ShortCircuitShm$Slot', // internal java api: sun.misc.SignalHandler - 'org.apache.hadoop.util.SignalLogger$Handler', 'org.apache.hadoop.hdfs.server.datanode.checker.AbstractFuture$UnsafeAtomicHelper', 'org.apache.hadoop.hdfs.server.datanode.checker.AbstractFuture$UnsafeAtomicHelper$1', - 'org.apache.hadoop.service.launcher.InterruptEscalator', - 'org.apache.hadoop.service.launcher.IrqHandler', 'com.google.common.cache.Striped64', 'com.google.common.cache.Striped64$1', @@ -368,19 +365,11 @@ thirdPartyAudit { 'com.google.common.util.concurrent.AbstractFuture$UnsafeAtomicHelper', 'com.google.common.util.concurrent.AbstractFuture$UnsafeAtomicHelper$1', - 'org.apache.hadoop.thirdparty.com.google.common.cache.Striped64', - 'org.apache.hadoop.thirdparty.com.google.common.cache.Striped64$1', - 'org.apache.hadoop.thirdparty.com.google.common.cache.Striped64$Cell', 'org.apache.hadoop.thirdparty.com.google.common.hash.LittleEndianByteArray$UnsafeByteArray', 'org.apache.hadoop.thirdparty.com.google.common.hash.LittleEndianByteArray$UnsafeByteArray$1', 'org.apache.hadoop.thirdparty.com.google.common.hash.LittleEndianByteArray$UnsafeByteArray$2', - 'org.apache.hadoop.thirdparty.com.google.common.hash.Striped64', - 'org.apache.hadoop.thirdparty.com.google.common.hash.Striped64$1', - 'org.apache.hadoop.thirdparty.com.google.common.hash.Striped64$Cell', 'org.apache.hadoop.thirdparty.com.google.common.primitives.UnsignedBytes$LexicographicalComparatorHolder$UnsafeComparator', - 'org.apache.hadoop.thirdparty.com.google.common.primitives.UnsignedBytes$LexicographicalComparatorHolder$UnsafeComparator$1', - 'org.apache.hadoop.thirdparty.com.google.common.util.concurrent.AbstractFuture$UnsafeAtomicHelper', - 'org.apache.hadoop.thirdparty.com.google.common.util.concurrent.AbstractFuture$UnsafeAtomicHelper$1', + 'org.apache.hadoop.thirdparty.com.google.common.util.concurrent.AbstractFutureState$UnsafeAtomicHelper', 'org.apache.hadoop.thirdparty.protobuf.UnsafeUtil', 'org.apache.hadoop.thirdparty.protobuf.UnsafeUtil$1', 'org.apache.hadoop.thirdparty.protobuf.UnsafeUtil$JvmMemoryAccessor', @@ -388,20 +377,11 @@ thirdPartyAudit { 'org.apache.hadoop.thirdparty.protobuf.MessageSchema', 'org.apache.hadoop.thirdparty.protobuf.UnsafeUtil$Android32MemoryAccessor', 'org.apache.hadoop.thirdparty.protobuf.UnsafeUtil$Android64MemoryAccessor', - 'org.apache.hadoop.shaded.com.google.common.cache.Striped64', - 'org.apache.hadoop.shaded.com.google.common.cache.Striped64$1', - 'org.apache.hadoop.shaded.com.google.common.cache.Striped64$Cell', 'org.apache.hadoop.shaded.com.google.common.hash.LittleEndianByteArray$UnsafeByteArray', 'org.apache.hadoop.shaded.com.google.common.hash.LittleEndianByteArray$UnsafeByteArray$1', 'org.apache.hadoop.shaded.com.google.common.hash.LittleEndianByteArray$UnsafeByteArray$2', - 'org.apache.hadoop.shaded.com.google.common.hash.LittleEndianByteArray$UnsafeByteArray$3', - 'org.apache.hadoop.shaded.com.google.common.hash.Striped64', - 'org.apache.hadoop.shaded.com.google.common.hash.Striped64$1', - 'org.apache.hadoop.shaded.com.google.common.hash.Striped64$Cell', 'org.apache.hadoop.shaded.com.google.common.primitives.UnsignedBytes$LexicographicalComparatorHolder$UnsafeComparator', - 'org.apache.hadoop.shaded.com.google.common.primitives.UnsignedBytes$LexicographicalComparatorHolder$UnsafeComparator$1', - 'org.apache.hadoop.shaded.com.google.common.util.concurrent.AbstractFuture$UnsafeAtomicHelper', - 'org.apache.hadoop.shaded.com.google.common.util.concurrent.AbstractFuture$UnsafeAtomicHelper$1', + 'org.apache.hadoop.shaded.com.google.common.util.concurrent.AbstractFutureState$UnsafeAtomicHelper', 'org.apache.hadoop.shaded.org.apache.avro.reflect.FieldAccessUnsafe', 'org.apache.hadoop.shaded.org.apache.avro.reflect.FieldAccessUnsafe$UnsafeBooleanField', 'org.apache.hadoop.shaded.org.apache.avro.reflect.FieldAccessUnsafe$UnsafeByteField', diff --git a/plugins/repository-hdfs/licenses/hadoop-client-api-3.4.2.jar.sha1 b/plugins/repository-hdfs/licenses/hadoop-client-api-3.4.2.jar.sha1 deleted file mode 100644 index c426a8f969c13..0000000000000 --- a/plugins/repository-hdfs/licenses/hadoop-client-api-3.4.2.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -d49afafdccb52bddde866ea0f341e6b31edc97fe \ No newline at end of file diff --git a/plugins/repository-hdfs/licenses/hadoop-client-api-3.5.0.jar.sha1 b/plugins/repository-hdfs/licenses/hadoop-client-api-3.5.0.jar.sha1 new file mode 100644 index 0000000000000..bd82dee374b2e --- /dev/null +++ b/plugins/repository-hdfs/licenses/hadoop-client-api-3.5.0.jar.sha1 @@ -0,0 +1 @@ +ea75bd5b918d328e1255ec6e2db2955f2c3f55f0 \ No newline at end of file diff --git a/plugins/repository-hdfs/licenses/hadoop-client-runtime-3.4.2.jar.sha1 b/plugins/repository-hdfs/licenses/hadoop-client-runtime-3.4.2.jar.sha1 deleted file mode 100644 index 39180a3bb4114..0000000000000 --- a/plugins/repository-hdfs/licenses/hadoop-client-runtime-3.4.2.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -3e9508f154ac9f085f3f0400c696175dce771d2d \ No newline at end of file diff --git a/plugins/repository-hdfs/licenses/hadoop-client-runtime-3.5.0.jar.sha1 b/plugins/repository-hdfs/licenses/hadoop-client-runtime-3.5.0.jar.sha1 new file mode 100644 index 0000000000000..2d4c55e326e0e --- /dev/null +++ b/plugins/repository-hdfs/licenses/hadoop-client-runtime-3.5.0.jar.sha1 @@ -0,0 +1 @@ +f4e41b696b6e49be562df9df78f1e67659951870 \ No newline at end of file diff --git a/plugins/repository-hdfs/licenses/hadoop-hdfs-3.4.2.jar.sha1 b/plugins/repository-hdfs/licenses/hadoop-hdfs-3.4.2.jar.sha1 deleted file mode 100644 index ac9494c186654..0000000000000 --- a/plugins/repository-hdfs/licenses/hadoop-hdfs-3.4.2.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -850d93a5a566ff19f3d77e67cb79c0de28d3ab78 \ No newline at end of file diff --git a/plugins/repository-hdfs/licenses/hadoop-hdfs-3.5.0.jar.sha1 b/plugins/repository-hdfs/licenses/hadoop-hdfs-3.5.0.jar.sha1 new file mode 100644 index 0000000000000..9d1715ff9d3ff --- /dev/null +++ b/plugins/repository-hdfs/licenses/hadoop-hdfs-3.5.0.jar.sha1 @@ -0,0 +1 @@ +a56a58f1df53f7dbfa920427b0bae53ba2a391eb \ No newline at end of file diff --git a/plugins/repository-s3/src/main/java/org/opensearch/repositories/s3/async/AsyncPartsHandler.java b/plugins/repository-s3/src/main/java/org/opensearch/repositories/s3/async/AsyncPartsHandler.java index e278f12bc6a26..3514b0933f164 100644 --- a/plugins/repository-s3/src/main/java/org/opensearch/repositories/s3/async/AsyncPartsHandler.java +++ b/plugins/repository-s3/src/main/java/org/opensearch/repositories/s3/async/AsyncPartsHandler.java @@ -119,9 +119,53 @@ public static List> uploadParts( if (semaphore != null) { semaphore.release(); } + // provideStream() threw before futures.add() was reached, so futures.size() + // is now less than streamContext.getNumberOfParts(). This mismatch has two + // consequences if left unaddressed: + // + // 1. NPE in mergeAndVerifyChecksum: allOfExceptionForwarded() sees only the + // shorter futures list and completes successfully (no failure signal for the + // missing parts). It then calls mergeAndVerifyChecksum(), which iterates the + // full-length inputStreamContainers array and dereferences the null slot left + // by the failed part — throwing NPE instead of propagating the real cause. + // + // 2. Race: indexInput closed while the uploadParts() loop is still running. + // allOfExceptionForwarded() on the shorter list completes while the for-loop + // is still calling provideStream() (and indexInput.clone()) for later parts. + // The completion triggers completionListener → indexInput.close(), which + // closes the Arena backing the MemorySegmentIndexInput. Any subsequent + // clone() call in the still-running loop then hits AlreadyClosedException, + // masking the original failure with a confusing secondary error. + // + // Fix: add a pre-failed future so futures.size() == numberOfParts always. + // allOfExceptionForwarded() then waits for all parts, the chain fails cleanly, + // cleanUpParts() aborts the multipart upload, and the original exception + // propagates to the caller. + final int failedPartNumber = partIdx + 1; + log.warn( + () -> new ParameterizedMessage( + "provideStream failed for part {} of file [{}] (total parts: {}); " + + "marking part as failed so the multipart upload is aborted cleanly.", + failedPartNumber, + uploadRequest.getKey(), + streamContext.getNumberOfParts() + ), + ex + ); + CompletableFuture failedFuture = new CompletableFuture<>(); + failedFuture.completeExceptionally(ex); + futures.add(failedFuture); } } + assert futures.size() == streamContext.getNumberOfParts() : "futures list size [" + + futures.size() + + "] must equal numberOfParts [" + + streamContext.getNumberOfParts() + + "];" + + " a size mismatch means allOfExceptionForwarded will complete before all parts are accounted for," + + " allowing mergeAndVerifyChecksum to dereference a null inputStreamContainers slot"; + return futures; } diff --git a/plugins/repository-s3/src/main/java/org/opensearch/repositories/s3/async/AsyncTransferManager.java b/plugins/repository-s3/src/main/java/org/opensearch/repositories/s3/async/AsyncTransferManager.java index b0bd9694d0e84..1ae8346fff138 100644 --- a/plugins/repository-s3/src/main/java/org/opensearch/repositories/s3/async/AsyncTransferManager.java +++ b/plugins/repository-s3/src/main/java/org/opensearch/repositories/s3/async/AsyncTransferManager.java @@ -275,6 +275,14 @@ private BiFunction handleExcep return (response, throwable) -> { if (throwable != null) { + log.warn( + () -> new ParameterizedMessage( + "Multipart upload failed for file [{}] (upload id: {}), aborting. Cause: {}", + uploadRequest.getKey(), + uploadId, + throwable.getClass().getSimpleName() + ": " + throwable.getMessage() + ) + ); AsyncPartsHandler.cleanUpParts(s3AsyncClient, uploadRequest, uploadId); handleException(returnFuture, () -> "Failed to send multipart upload requests.", throwable); } else { diff --git a/plugins/repository-s3/src/test/java/org/opensearch/repositories/s3/async/AsyncTransferManagerTests.java b/plugins/repository-s3/src/test/java/org/opensearch/repositories/s3/async/AsyncTransferManagerTests.java index 8b5ab0333997a..2e61dd2451b38 100644 --- a/plugins/repository-s3/src/test/java/org/opensearch/repositories/s3/async/AsyncTransferManagerTests.java +++ b/plugins/repository-s3/src/test/java/org/opensearch/repositories/s3/async/AsyncTransferManagerTests.java @@ -292,4 +292,162 @@ public void testMultipartUploadCorruption() { verify(s3AsyncClient, times(0)).completeMultipartUpload(any(CompleteMultipartUploadRequest.class)); verify(s3AsyncClient, times(1)).abortMultipartUpload(any(AbortMultipartUploadRequest.class)); } + + /** + * Regression: when provideStream() throws for one part (e.g., AlreadyClosedException from a + * concurrently-merged Lucene segment), the upload must fail cleanly rather than silently + * leaving a null slot in inputStreamContainers and later NPE-ing in mergeAndVerifyChecksum. + * + * Root cause: the catch block in AsyncPartsHandler.uploadParts() swallowed the exception and + * added no future, so allOfExceptionForwarded() completed successfully on the shorter futures + * list, then mergeAndVerifyChecksum() dereferenced the null slot — producing a confusing + * "Failed to send multipart upload requests" SdkClientException wrapping an NPE instead of + * the original IOException. + * + * Fix: add a pre-failed CompletableFuture for any part whose provideStream() throws, so + * allOfExceptionForwarded() sees the failure, the upload is aborted, and the original + * exception propagates to the caller. + */ + public void testMultipartUploadProvideStreamExceptionOnMiddlePart() throws Exception { + setUpMultipartMocks(); + + // Part index 2 (middle) throws — simulates AlreadyClosedException from mmap'd segment + CompletableFuture result = asyncTransferManager.uploadObject( + s3AsyncClient, + buildUploadRequest(true, 3376132981L), + new StreamContext((partIdx, partSize, position) -> { + if (partIdx == 2) { + throw new IOException("Already closed: MemorySegmentIndexInput(path=\"_merged_430d.parquet\")"); + } + return new InputStreamContainer(new ZeroInputStream(partSize), partSize, position); + }, ByteSizeUnit.MB.toBytes(1), ByteSizeUnit.MB.toBytes(1), 5), + new StatsMetricPublisher() + ); + + ExecutionException ex = expectThrows(ExecutionException.class, () -> result.get(5, TimeUnit.SECONDS)); + + // Must NOT be NPE (was the pre-fix symptom) + assertFalse( + "Must not surface as NullPointerException", + ExceptionsHelper.unwrapCausesAndSuppressed(ex, t -> t instanceof NullPointerException).isPresent() + ); + // Original IOException must appear in the cause chain + assertTrue( + "Original IOException from provideStream must be in the cause chain", + ExceptionsHelper.unwrapCausesAndSuppressed( + ex, + t -> t instanceof IOException && t.getMessage().contains("Already closed") + ).isPresent() + ); + + verify(s3AsyncClient, times(1)).createMultipartUpload(any(CreateMultipartUploadRequest.class)); + verify(s3AsyncClient, times(0)).completeMultipartUpload(any(CompleteMultipartUploadRequest.class)); + verify(s3AsyncClient, times(1)).abortMultipartUpload(any(AbortMultipartUploadRequest.class)); + } + + /** + * Regression: same fix, first part fails. Ensures the mismatch between futures list size (0) + * and numberOfParts (5) is handled — previously allOfExceptionForwarded on an empty list + * completed immediately, then mergeAndVerifyChecksum NPE'd on slot 0. + */ + public void testMultipartUploadProvideStreamExceptionOnFirstPart() throws Exception { + setUpMultipartMocks(); + + CompletableFuture result = asyncTransferManager.uploadObject( + s3AsyncClient, + buildUploadRequest(true, 3376132981L), + new StreamContext((partIdx, partSize, position) -> { + if (partIdx == 0) { + throw new IOException("Already closed: MemorySegmentIndexInput(path=\"_merged_430d.parquet\")"); + } + return new InputStreamContainer(new ZeroInputStream(partSize), partSize, position); + }, ByteSizeUnit.MB.toBytes(1), ByteSizeUnit.MB.toBytes(1), 5), + new StatsMetricPublisher() + ); + + ExecutionException ex = expectThrows(ExecutionException.class, () -> result.get(5, TimeUnit.SECONDS)); + assertFalse( + "Must not surface as NullPointerException", + ExceptionsHelper.unwrapCausesAndSuppressed(ex, t -> t instanceof NullPointerException).isPresent() + ); + assertTrue( + "Original IOException must be in the cause chain", + ExceptionsHelper.unwrapCausesAndSuppressed( + ex, + t -> t instanceof IOException && t.getMessage().contains("Already closed") + ).isPresent() + ); + verify(s3AsyncClient, times(1)).createMultipartUpload(any(CreateMultipartUploadRequest.class)); + verify(s3AsyncClient, times(0)).completeMultipartUpload(any(CompleteMultipartUploadRequest.class)); + verify(s3AsyncClient, times(1)).abortMultipartUpload(any(AbortMultipartUploadRequest.class)); + } + + /** + * Regression: all parts fail — futures list is empty. allOfExceptionForwarded on [] used to + * complete immediately with success, then NPE on every slot. With the fix every part adds a + * failed future so the first failure propagates correctly. + */ + public void testMultipartUploadProvideStreamExceptionOnAllParts() throws Exception { + setUpMultipartMocks(); + + CompletableFuture result = asyncTransferManager.uploadObject( + s3AsyncClient, + buildUploadRequest(true, 3376132981L), + new StreamContext((partIdx, partSize, position) -> { + throw new IOException("Already closed: MemorySegmentIndexInput(path=\"_merged_430d.parquet\")"); + }, ByteSizeUnit.MB.toBytes(1), ByteSizeUnit.MB.toBytes(1), 5), + new StatsMetricPublisher() + ); + + ExecutionException ex = expectThrows(ExecutionException.class, () -> result.get(5, TimeUnit.SECONDS)); + assertFalse( + "Must not surface as NullPointerException", + ExceptionsHelper.unwrapCausesAndSuppressed(ex, t -> t instanceof NullPointerException).isPresent() + ); + assertTrue( + "Original IOException must be in the cause chain", + ExceptionsHelper.unwrapCausesAndSuppressed( + ex, + t -> t instanceof IOException && t.getMessage().contains("Already closed") + ).isPresent() + ); + verify(s3AsyncClient, times(1)).createMultipartUpload(any(CreateMultipartUploadRequest.class)); + verify(s3AsyncClient, times(0)).completeMultipartUpload(any(CompleteMultipartUploadRequest.class)); + verify(s3AsyncClient, times(1)).abortMultipartUpload(any(AbortMultipartUploadRequest.class)); + } + + // ── Helpers ──────────────────────────────────────────────────────────────── + + private void setUpMultipartMocks() { + CompletableFuture createFuture = new CompletableFuture<>(); + createFuture.complete(CreateMultipartUploadResponse.builder().uploadId("uploadId").build()); + when(s3AsyncClient.createMultipartUpload(any(CreateMultipartUploadRequest.class))).thenReturn(createFuture); + + CompletableFuture partFuture = new CompletableFuture<>(); + partFuture.complete(UploadPartResponse.builder().checksumCRC32("pzjqHA==").build()); + when(s3AsyncClient.uploadPart(any(UploadPartRequest.class), any(AsyncRequestBody.class))).thenReturn(partFuture); + + CompletableFuture abortFuture = new CompletableFuture<>(); + abortFuture.complete(AbortMultipartUploadResponse.builder().build()); + when(s3AsyncClient.abortMultipartUpload(any(AbortMultipartUploadRequest.class))).thenReturn(abortFuture); + } + + private UploadRequest buildUploadRequest(boolean integrityCheck, long checksum) { + return new UploadRequest( + "bucket", + "key", + ByteSizeUnit.MB.toBytes(5), + WritePriority.HIGH, + uploadSuccess -> {}, + integrityCheck, + checksum, + true, + new HashMap<>(), + ServerSideEncryption.AWS_KMS.toString(), + randomAlphaOfLength(10), + true, + null, + null + ); + } } diff --git a/plugins/transport-reactor-netty4/src/main/java/org/opensearch/http/reactor/netty4/ReactorNetty4HttpServerTransport.java b/plugins/transport-reactor-netty4/src/main/java/org/opensearch/http/reactor/netty4/ReactorNetty4HttpServerTransport.java index 9e6705c4d9899..29dcfdf1a3e81 100644 --- a/plugins/transport-reactor-netty4/src/main/java/org/opensearch/http/reactor/netty4/ReactorNetty4HttpServerTransport.java +++ b/plugins/transport-reactor-netty4/src/main/java/org/opensearch/http/reactor/netty4/ReactorNetty4HttpServerTransport.java @@ -59,11 +59,13 @@ import io.netty.buffer.ByteBuf; import io.netty.channel.Channel; +import io.netty.channel.ChannelInboundHandlerAdapter; import io.netty.channel.ChannelOption; import io.netty.channel.socket.nio.NioChannelOption; import io.netty.handler.codec.http.DefaultLastHttpContent; import io.netty.handler.codec.http.FullHttpResponse; import io.netty.handler.codec.http.HttpContent; +import io.netty.handler.codec.http.HttpContentDecompressor; import io.netty.handler.codec.http.HttpResponseStatus; import io.netty.handler.codec.quic.QuicSslContextBuilder; import io.netty.handler.ssl.ApplicationProtocolConfig; @@ -81,6 +83,7 @@ import reactor.netty.Connection; import reactor.netty.DisposableChannel; import reactor.netty.DisposableServer; +import reactor.netty.NettyPipeline; import reactor.netty.http.HttpProtocol; import reactor.netty.http.server.HttpServer; import reactor.netty.http.server.HttpServerRequest; @@ -111,6 +114,11 @@ public class ReactorNetty4HttpServerTransport extends AbstractHttpServerTranspor private static final String SETTING_KEY_HTTP_NETTY_MAX_COMPOSITE_BUFFER_COMPONENTS = "http.netty.max_composite_buffer_components"; private static final ByteSizeValue MTU = new ByteSizeValue(Long.parseLong(System.getProperty("opensearch.net.mtu", "1500"))); + /** + * The size of the http content decompressor buffer that is going to be used for request body decompression. + */ + private static final int UNLIMITED_DECOMPRESSOR_BUFFER = 0; + /** * Configure the maximum length of the content of the HTTP/2.0 clear-text upgrade request. * By default the server will reject an upgrade request with non-empty content, @@ -307,6 +315,7 @@ protected HttpServerChannel bind(InetSocketAddress socketAddress) throws Excepti .runOn(sharedGroup.getLowLevelGroup()) .bindAddress(() -> socketAddress) .compress(true) + .doOnConnection(conn -> conn.addHandlerFirst(NettyPipeline.HttpDecompressor, createDecompressor())) .http2Settings(spec -> spec.maxHeaderListSize(maxHeaderSize.bytesAsInt()).maxConcurrentStreams(h2MaxConcurrentStreams)) .httpRequestDecoder( spec -> spec.maxChunkSize(maxChunkSize.bytesAsInt()) @@ -372,6 +381,7 @@ private Optional configureHttp3(InetSocketAddress socketAddress) thr .runOn(sharedGroup.getLowLevelGroup()) .bindAddress(() -> socketAddress) .compress(true) + .doOnConnection(conn -> conn.addHandlerFirst(NettyPipeline.HttpDecompressor, createDecompressor())) .httpRequestDecoder( spec -> spec.maxChunkSize(maxChunkSize.bytesAsInt()) .h2cMaxContentLength(h2cMaxContentLength.bytesAsInt()) @@ -495,6 +505,16 @@ public void serverAcceptedChannel(HttpChannel httpChannel) { super.serverAcceptedChannel(httpChannel); } + /** + * Extension point that allows a NetworkPlugin to override the default netty HttpContentDecompressor + * and supply a custom decompressor. + * + * Used in instances to conditionally decompress depending on the outcome from header verification. + */ + protected ChannelInboundHandlerAdapter createDecompressor() { + return new HttpContentDecompressor(UNLIMITED_DECOMPRESSOR_BUFFER); + } + /** * Handles incoming Reactor Netty request * diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/AnalyticsSearchBackendPlugin.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/AnalyticsSearchBackendPlugin.java index adaf425215e3d..ae426598b1cea 100644 --- a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/AnalyticsSearchBackendPlugin.java +++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/AnalyticsSearchBackendPlugin.java @@ -167,6 +167,15 @@ default EngineResultStream fetchByRowIds( throw new UnsupportedOperationException("fetchByRowIds not implemented for [" + name() + "]"); } + /** + * Cooperatively cancels in-flight backend work for {@code contextId} (e.g. fire the per-context + * cancellation token). Called from a task cancellation listener for the fetch path, which — + * unlike the query path's {@code SearchExecEngine} — returns an opaque {@link EngineResultStream}. + * Implementations must signal the native execution to unwind, not close the stream cross-thread + * (that races the in-flight pull). No-op for an unknown {@code contextId}; default no-op. + */ + default void cancelByContext(long contextId) {} + /** * Converts a backend-specific exception into an appropriate OpenSearch exception type. * diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/DelegatedPredicateSerializer.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/DelegatedPredicateSerializer.java index 7935b3702dd44..5eefef5f28596 100644 --- a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/DelegatedPredicateSerializer.java +++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/DelegatedPredicateSerializer.java @@ -21,6 +21,12 @@ * {@link RexCall} and serialize them into backend-specific bytes that can be deserialized * at the data node to create the appropriate query. * + *

A serializer is the single place that understands a predicate's shape, so it can also + * report the fields the predicate references at planning time via {@link #referencedFields}. + * This is used for full-text predicates whose fields may live inside an opaque query string + * (e.g. {@code query_string}) rather than only in a {@code fields} operand. Serializers that do + * not own such field-resolution semantics inherit the default (no field references reported). + * *

TODO(same-backend-combining): When tree normalization combines adjacent same-backend * predicates under AND/OR into a single BooleanQuery, serializers will need to handle * composite predicate shapes — not just single-function leaves. @@ -39,4 +45,25 @@ public interface DelegatedPredicateSerializer { * @return backend-specific serialized bytes */ byte[] serialize(RexCall call, List fieldStorage); + + /** + * Returns the fields this predicate references, for plan-time validation, or {@code null} if + * this serializer does not expose field references (the default). Runs at planning time and + * must not require a {@code QueryShardContext} — field identity is analyzer-independent, so only + * the predicate's {@link RexCall} and per-column {@link FieldStorageInfo} are needed. + * + *

Overridden by full-text serializers whose fields may be written inside the query string + * (e.g. {@code query_string}); the implementation is expected to derive fields from the + * same operand path it would use for {@link #serialize}, so extraction can never drift + * from execution. + * + * @param call the relevance {@link RexCall} (e.g. {@code QUERY_STRING(MAP('fields', ...), MAP('query', ...))}) + * @param fieldStorage per-column storage metadata; {@link org.apache.calcite.rex.RexInputRef} + * indices in {@code call} index into this list + * @return the referenced fields split into validated literals vs. passed-through patterns, plus + * lenient metadata, or {@code null} if field references are not exposed + */ + default FieldReferences referencedFields(RexCall call, List fieldStorage) { + return null; + } } diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/DocumentLookupService.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/DocumentLookupService.java new file mode 100644 index 0000000000000..4cf62ccaf84eb --- /dev/null +++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/DocumentLookupService.java @@ -0,0 +1,169 @@ +/* + * 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.analytics.spi; + +import org.opensearch.common.annotation.ExperimentalApi; +import org.opensearch.common.lucene.uid.Versions; +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.core.common.bytes.BytesReference; +import org.opensearch.core.index.Index; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.index.engine.dataformat.DocumentInput; +import org.opensearch.index.engine.exec.DocumentMetadataResolver; +import org.opensearch.index.engine.exec.IndexReaderProvider; +import org.opensearch.index.engine.exec.Segment; +import org.opensearch.index.engine.exec.WriterFileSet; +import org.opensearch.index.get.DocumentLookupResult; +import org.opensearch.index.mapper.SeqNoFieldMapper; +import org.opensearch.index.seqno.SequenceNumbers; +import org.opensearch.indices.IndicesModule; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Core orchestrator for document lookup. Coordinates row-location resolution, + * backend-specific execution, and result assembly. + * + * @opensearch.experimental + */ +@ExperimentalApi +public class DocumentLookupService { + + /** + * Engine/storage metadata fields excluded from reconstructed {@code _source}, sourced from the mapper + * registry's built-in metadata mappers rather than a hand-maintained list. {@code _primary_term} (a + * sub-column emitted by the {@code _seq_no} mapper) and {@code __row_id__} (an engine-internal column) + * are not registered mappers, so they are excluded separately by constant in {@link #buildResultFromRow}. + */ + private static final Set METADATA_FIELDS = IndicesModule.getBuiltInMetadataFields(); + + private final DocumentMetadataResolver documentResolver; + private final DocumentRowReader executor; + + public DocumentLookupService(DocumentMetadataResolver documentResolver, DocumentRowReader executor) { + this.documentResolver = documentResolver; + this.executor = executor; + } + + public DocumentLookupResult getById(String id, IndexReaderProvider.Reader reader, Index index) throws IOException { + Map row = fetchRow(id, reader); + return row == null ? DocumentLookupResult.notFound(id) : buildResultFromRow(id, row); + } + + /** + * Resolves only the version metadata ({@code _version}/{@code _seq_no}/{@code _primary_term}) for an id, + * skipping {@code _source} reconstruction. + */ + public DocumentLookupResult getVersionMetadata(String id, IndexReaderProvider.Reader reader, Index index) throws IOException { + Map row = fetchRow(id, reader); + if (row == null) { + return DocumentLookupResult.notFound(id); + } + long seqNo = extractLong(row, "_seq_no", SequenceNumbers.UNASSIGNED_SEQ_NO); + long primaryTerm = extractLong(row, "_primary_term", SequenceNumbers.UNASSIGNED_PRIMARY_TERM); + long version = extractLong(row, "_version", Versions.NOT_FOUND); + return new DocumentLookupResult(id, version, true, null, seqNo, primaryTerm, Map.of(), Map.of()); + } + + /** Locates an id via the resolver and fetches its raw row. Returns null only when the id is not found. */ + private Map fetchRow(String id, IndexReaderProvider.Reader reader) throws IOException { + DocumentMetadataResolver.DocumentMetadata metadata = documentResolver.resolveMetadata(reader, id); + if (metadata == null) { + return null; + } + + WriterFileSet fileSet = reader.catalogSnapshot().findFileSet(executor.formatName(), metadata.writerGeneration()); + if (fileSet == null) { + throw new IllegalStateException( + "Resolver located id [" + + id + + "] at writer generation [" + + metadata.writerGeneration() + + "] but no matching file set was found" + ); + } + + Map row = executor.executeSingleRow(metadata.rowId(), fileSet); + if (row == null) { + throw new IllegalStateException( + "Resolver located id [" + + id + + "] at writer generation [" + + metadata.writerGeneration() + + "] rowId [" + + metadata.rowId() + + "] but backend returned no row" + ); + } + return row; + } + + public List getDocsAboveSeqNo(long fromSeqNoExclusive, IndexReaderProvider.Reader reader, Index index) + throws IOException { + List fileSets = new ArrayList<>(); + for (Segment segment : reader.catalogSnapshot().getSegments()) { + WriterFileSet fileSet = segment.dfGroupedSearchableFiles().get(executor.formatName()); + if (fileSet != null && !fileSet.files().isEmpty()) { + fileSets.add(fileSet); + } + } + List results = new ArrayList<>(); + for (Map row : executor.executeRowsAboveSeqNo(fileSets, fromSeqNoExclusive)) { + Object idVal = row.get("_id"); + if (idVal != null) { + results.add(buildResultFromRow(idVal.toString(), row)); + } + } + return results; + } + + /** Builds a DocumentLookupResult from a raw row, filtering metadata/internal fields out of {@code _source}. */ + private static DocumentLookupResult buildResultFromRow(String id, Map row) throws IOException { + long seqNo = extractLong(row, "_seq_no", SequenceNumbers.UNASSIGNED_SEQ_NO); + long primaryTerm = extractLong(row, "_primary_term", SequenceNumbers.UNASSIGNED_PRIMARY_TERM); + long version = extractLong(row, "_version", Versions.NOT_FOUND); + + Map filtered = new LinkedHashMap<>(); + for (Map.Entry e : row.entrySet()) { + // Exclude registered metadata fields, plus the two columns that are not registered mappers: + // _primary_term (a sub-column emitted by the _seq_no mapper) and __row_id__ (engine-internal). + String name = e.getKey(); + if (METADATA_FIELDS.contains(name) + || SeqNoFieldMapper.PRIMARY_TERM_NAME.equals(name) + || DocumentInput.ROW_ID_FIELD.equals(name)) { + continue; + } + filtered.put(name, e.getValue()); + } + + BytesReference source; + try (XContentBuilder xcb = XContentFactory.jsonBuilder()) { + xcb.map(filtered); + source = BytesReference.bytes(xcb); + } + + return new DocumentLookupResult(id, version, true, source, seqNo, primaryTerm, Map.of(), Map.of()); + } + + public static long extractLong(Map row, String key, long fallback) { + Object v = row.get(key); + if (v == null) return fallback; + if (v instanceof Number) return ((Number) v).longValue(); + try { + return Long.parseLong(v.toString()); + } catch (NumberFormatException e) { + return fallback; + } + } +} diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/DocumentRowReader.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/DocumentRowReader.java new file mode 100644 index 0000000000000..7297dc9439bb1 --- /dev/null +++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/DocumentRowReader.java @@ -0,0 +1,51 @@ +/* + * 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.analytics.spi; + +import org.opensearch.common.annotation.ExperimentalApi; +import org.opensearch.index.engine.exec.WriterFileSet; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** + * Backend-specific execution contract for reading document rows. Backends implement this + * to perform the actual storage read (e.g., DataFusion native parquet scan) from a file set + * that the Core layer has already resolved from the catalog snapshot. + * + * @opensearch.experimental + */ +@ExperimentalApi +public interface DocumentRowReader { + + /** + * The storage format this backend reads (e.g. {@code "parquet"}). Lets the Core layer + * resolve the backend's candidate {@link WriterFileSet} from the catalog snapshot. + */ + String formatName(); + + /** + * Fetch a single row at the given offset from the pre-resolved file set. + * + * @param rowId the row offset to fetch + * @param fileSet the file set to read from + * @return the row as a field-name → value map, or null if not found + */ + Map executeSingleRow(long rowId, WriterFileSet fileSet) throws IOException; + + /** + * Fetch all rows with {@code _seq_no > fromSeqNoExclusive} from the Core-resolved file sets + * (one per segment for this backend's format). + * + * @param fileSets the file sets to scan + * @param fromSeqNoExclusive the exclusive lower bound on {@code _seq_no} + */ + List> executeRowsAboveSeqNo(List fileSets, long fromSeqNoExclusive) throws IOException; +} diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/FieldReferences.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/FieldReferences.java new file mode 100644 index 0000000000000..f476bcc465bee --- /dev/null +++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/FieldReferences.java @@ -0,0 +1,50 @@ +/* + * 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.analytics.spi; + +import java.util.List; + +/** + * The fields a relevance predicate (e.g. {@code query_string}, {@code simple_query_string}, + * {@code multi_match}) explicitly references, plus the metadata the planner needs to decide whether + * to eagerly validate them. + * + *

Produced by {@link DelegatedPredicateSerializer#referencedFields} at planning time from the + * predicate's {@link org.apache.calcite.rex.RexCall} and consumed by the planner's text-relevance + * validation. Field-name extraction is analyzer-independent, so this can be computed without a + * {@code QueryShardContext}. + * + *

The planner validates only {@link #literalFields()}. {@link #patternTokens()} are + * informational and pass through unvalidated — wildcard/regex field expansion (and default-field + * fan-out for unqualified terms) is resolved at execution by the data node, which matches + * OpenSearch's best-effort, never-erroring treatment of those forms. + * + * @param literalFields explicitly-named concrete field names (from the {@code fields}/{@code field} + * operand and, for {@code query_string}, literal {@code field:} tokens in the + * query string). Validated by the planner. First-appearance ordered. + * @param patternTokens explicitly-named wildcard/regex tokens (e.g. {@code cat*}, {@code *}), + * classified via {@code Regex.isSimpleMatchPattern}. Passed through — not + * expanded, not rejected. + * @param lenient effective lenient flag: the explicit {@code lenient} param when set, + * otherwise {@code true} (assume tolerant when unset). When {@code true} the + * planner suppresses eager type rejection of {@link #literalFields()}. + * + * @opensearch.internal + */ +public record FieldReferences(List literalFields, List patternTokens, boolean lenient) { + + /** + * Normalizes null token lists to empty and wraps both in unmodifiable lists so the result is a + * safe, immutable value object. + */ + public FieldReferences { + literalFields = literalFields == null ? List.of() : List.copyOf(literalFields); + patternTokens = patternTokens == null ? List.of() : List.copyOf(patternTokens); + } +} diff --git a/sandbox/libs/analytics-framework/src/test/java/org/opensearch/analytics/spi/DocumentLookupServiceTests.java b/sandbox/libs/analytics-framework/src/test/java/org/opensearch/analytics/spi/DocumentLookupServiceTests.java new file mode 100644 index 0000000000000..3a1e259760393 --- /dev/null +++ b/sandbox/libs/analytics-framework/src/test/java/org/opensearch/analytics/spi/DocumentLookupServiceTests.java @@ -0,0 +1,287 @@ +/* + * 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.analytics.spi; + +import org.opensearch.common.lucene.uid.Versions; +import org.opensearch.core.index.Index; +import org.opensearch.index.engine.exec.DocumentMetadataResolver; +import org.opensearch.index.engine.exec.IndexReaderProvider; +import org.opensearch.index.engine.exec.Segment; +import org.opensearch.index.engine.exec.WriterFileSet; +import org.opensearch.index.engine.exec.coord.CatalogSnapshot; +import org.opensearch.index.get.DocumentLookupResult; +import org.opensearch.index.seqno.SequenceNumbers; +import org.opensearch.test.OpenSearchTestCase; + +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.mockito.ArgumentCaptor; + +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link DocumentLookupService}, the Core orchestrator for document lookup. + * + *

All collaborators are interfaces ({@link DocumentMetadataResolver}, {@link DocumentRowReader}, + * {@link IndexReaderProvider.Reader}) or an abstract class ({@link CatalogSnapshot}) and are mocked. + * {@link Segment}/{@link WriterFileSet} are real objects constructed via their builders since they + * are final records / sealed classes. + * + *

Coverage: + *

    + *
  • {@code getById} — not-found, success (reserved/underscore field filtering), and the two + * Lucene/Parquet inconsistency ISE paths (missing file set, missing row).
  • + *
  • {@code getVersionMetadata} — not-found, version-field extraction with null source, and + * fallback defaults when version fields are absent.
  • + *
  • {@code getDocsAboveSeqNo} — rows without {@code _id} skipped, empty/wrong-format file sets + * excluded from the backend scan, and empty result handling.
  • + *
  • {@code extractLong} — Number, missing key, parsable String, and unparsable String.
  • + *
+ */ +public class DocumentLookupServiceTests extends OpenSearchTestCase { + + private static final String FORMAT = "parquet"; + + private static final Index INDEX = new Index("idx", "uuid"); + + private DocumentMetadataResolver resolver; + private DocumentRowReader executor; + private IndexReaderProvider.Reader reader; + private CatalogSnapshot snapshot; + private DocumentLookupService service; + + @Override + public void setUp() throws Exception { + super.setUp(); + resolver = mock(DocumentMetadataResolver.class); + executor = mock(DocumentRowReader.class); + reader = mock(IndexReaderProvider.Reader.class); + snapshot = mock(CatalogSnapshot.class); + when(reader.catalogSnapshot()).thenReturn(snapshot); + when(executor.formatName()).thenReturn(FORMAT); + service = new DocumentLookupService(resolver, executor); + } + + // ---- helpers ------------------------------------------------------------ + + private static WriterFileSet fileSet(long generation, String... files) { + WriterFileSet.Builder b = WriterFileSet.builder() + .directory(Path.of("/tmp/idx")) + .writerGeneration(generation) + .addNumRows(files.length); + for (String f : files) { + b.addFile(f); + } + return b.build(); + } + + private static DocumentMetadataResolver.DocumentMetadata metadata(String id, long rowId, long generation) { + return new DocumentMetadataResolver.DocumentMetadata(id, rowId, generation); + } + + private static Map row(Object... kv) { + Map row = new LinkedHashMap<>(); + for (int i = 0; i < kv.length; i += 2) { + row.put((String) kv[i], kv[i + 1]); + } + return row; + } + + // ---- getById ------------------------------------------------------------ + + public void testGetById_notFoundWhenResolverReturnsNull() throws Exception { + when(resolver.resolveMetadata(reader, "missing")).thenReturn(null); + + DocumentLookupResult result = service.getById("missing", reader, INDEX); + + assertFalse(result.exists()); + assertEquals("missing", result.id()); + assertEquals(Versions.NOT_FOUND, result.version()); + assertNull(result.source()); + } + + public void testGetById_successFiltersMetadataAndInternalColumns() throws Exception { + when(resolver.resolveMetadata(reader, "doc1")).thenReturn(metadata("doc1", 0L, 7L)); + WriterFileSet fs = fileSet(7L, "0.parquet"); + when(snapshot.findFileSet(FORMAT, 7L)).thenReturn(fs); + when(executor.executeSingleRow(0L, fs)).thenReturn( + row("_id", "doc1", "_seq_no", 42L, "_primary_term", 2L, "_version", 5L, "__row_id__", 99L, "name", "alice", "age", 30) + ); + + DocumentLookupResult result = service.getById("doc1", reader, INDEX); + + assertTrue(result.exists()); + assertEquals("doc1", result.id()); + assertEquals(5L, result.version()); + assertEquals(42L, result.seqNo()); + assertEquals(2L, result.primaryTerm()); + + String source = result.source().utf8ToString(); + // user fields retained + assertTrue("user fields present: " + source, source.contains("\"name\":\"alice\"")); + assertTrue("user fields present: " + source, source.contains("\"age\":30")); + // metadata-mapper fields excluded via the predicate + assertFalse("metadata field excluded: " + source, source.contains("_seq_no")); + assertFalse("metadata field excluded: " + source, source.contains("_version")); + assertFalse("metadata field excluded: " + source, source.contains("\"_id\"")); + // non-mapper columns excluded via the constant guards (the regression this protects against) + assertFalse("_primary_term excluded via constant: " + source, source.contains("_primary_term")); + assertFalse("__row_id__ excluded via constant: " + source, source.contains("__row_id__")); + } + + public void testGetById_nonMetadataUnderscoreFieldRetained() throws Exception { + when(resolver.resolveMetadata(reader, "doc1")).thenReturn(metadata("doc1", 0L, 7L)); + WriterFileSet fs = fileSet(7L, "0.parquet"); + when(snapshot.findFileSet(FORMAT, 7L)).thenReturn(fs); + // "_custom" is not a registered metadata field, not _primary_term, not __row_id__ -> kept in _source. + when(executor.executeSingleRow(0L, fs)).thenReturn(row("_id", "doc1", "_seq_no", 1L, "_custom", "keepme", "name", "bob")); + + DocumentLookupResult result = service.getById("doc1", reader, INDEX); + + String source = result.source().utf8ToString(); + assertTrue("non-metadata underscore field retained: " + source, source.contains("\"_custom\":\"keepme\"")); + assertTrue("user field retained: " + source, source.contains("\"name\":\"bob\"")); + assertFalse("metadata field excluded: " + source, source.contains("_seq_no")); + } + + public void testGetById_throwsISEWhenFileSetNull() throws Exception { + when(resolver.resolveMetadata(reader, "doc1")).thenReturn(metadata("doc1", 0L, 7L)); + when(snapshot.findFileSet(FORMAT, 7L)).thenReturn(null); + + IllegalStateException e = expectThrows(IllegalStateException.class, () -> service.getById("doc1", reader, INDEX)); + assertTrue(e.getMessage(), e.getMessage().contains("no matching file set")); + assertTrue(e.getMessage(), e.getMessage().contains("doc1")); + } + + public void testGetById_throwsISEWhenRowNull() throws Exception { + when(resolver.resolveMetadata(reader, "doc1")).thenReturn(metadata("doc1", 0L, 7L)); + WriterFileSet fs = fileSet(7L, "0.parquet"); + when(snapshot.findFileSet(FORMAT, 7L)).thenReturn(fs); + when(executor.executeSingleRow(0L, fs)).thenReturn(null); + + IllegalStateException e = expectThrows(IllegalStateException.class, () -> service.getById("doc1", reader, INDEX)); + assertTrue(e.getMessage(), e.getMessage().contains("backend returned no row")); + assertTrue(e.getMessage(), e.getMessage().contains("doc1")); + } + + // ---- getVersionMetadata ------------------------------------------------- + + public void testGetVersionMetadata_notFoundWhenResolverReturnsNull() throws Exception { + when(resolver.resolveMetadata(reader, "missing")).thenReturn(null); + + DocumentLookupResult result = service.getVersionMetadata("missing", reader, INDEX); + + assertFalse(result.exists()); + assertEquals(Versions.NOT_FOUND, result.version()); + assertNull(result.source()); + } + + public void testGetVersionMetadata_extractsVersionFieldsWithNullSource() throws Exception { + when(resolver.resolveMetadata(reader, "doc1")).thenReturn(metadata("doc1", 0L, 7L)); + WriterFileSet fs = fileSet(7L, "0.parquet"); + when(snapshot.findFileSet(FORMAT, 7L)).thenReturn(fs); + when(executor.executeSingleRow(0L, fs)).thenReturn(row("_seq_no", 42L, "_primary_term", 2L, "_version", 5L, "name", "alice")); + + DocumentLookupResult result = service.getVersionMetadata("doc1", reader, INDEX); + + assertTrue(result.exists()); + assertEquals(5L, result.version()); + assertEquals(42L, result.seqNo()); + assertEquals(2L, result.primaryTerm()); + // version-only hot path: source is never reconstructed + assertNull(result.source()); + assertTrue(result.documentFields().isEmpty()); + } + + public void testGetVersionMetadata_defaultsWhenFieldsMissing() throws Exception { + when(resolver.resolveMetadata(reader, "doc1")).thenReturn(metadata("doc1", 0L, 7L)); + WriterFileSet fs = fileSet(7L, "0.parquet"); + when(snapshot.findFileSet(FORMAT, 7L)).thenReturn(fs); + // row exists (found) but carries no version fields + when(executor.executeSingleRow(0L, fs)).thenReturn(row("name", "alice")); + + DocumentLookupResult result = service.getVersionMetadata("doc1", reader, INDEX); + + assertTrue(result.exists()); + assertEquals(Versions.NOT_FOUND, result.version()); + assertEquals(SequenceNumbers.UNASSIGNED_SEQ_NO, result.seqNo()); + assertEquals(SequenceNumbers.UNASSIGNED_PRIMARY_TERM, result.primaryTerm()); + } + + // ---- getDocsAboveSeqNo -------------------------------------------------- + + public void testGetDocsAboveSeqNo_buildsResultsOnlyForRowsWithId() throws Exception { + Segment segment = Segment.builder(1L).addSearchableFiles(FORMAT, fileSet(1L, "0.parquet")).build(); + when(snapshot.getSegments()).thenReturn(List.of(segment)); + + Map withId = row("_id", "d1", "_seq_no", 10L, "name", "alice"); + Map withoutId = row("_seq_no", 11L, "name", "bob"); + when(executor.executeRowsAboveSeqNo(anyList(), eq(5L))).thenReturn(List.of(withId, withoutId)); + + List results = service.getDocsAboveSeqNo(5L, reader, INDEX); + + assertEquals(1, results.size()); + assertEquals("d1", results.get(0).id()); + assertEquals(10L, results.get(0).seqNo()); + assertTrue(results.get(0).source().utf8ToString().contains("\"name\":\"alice\"")); + } + + public void testGetDocsAboveSeqNo_skipsEmptyAndWrongFormatFileSets() throws Exception { + WriterFileSet good = fileSet(1L, "0.parquet"); + WriterFileSet empty = fileSet(2L); // no files -> excluded + WriterFileSet lucene = fileSet(3L, "seg.cfs"); // different format -> excluded + Segment s1 = Segment.builder(1L).addSearchableFiles(FORMAT, good).build(); + Segment s2 = Segment.builder(2L).addSearchableFiles(FORMAT, empty).build(); + Segment s3 = Segment.builder(3L).addSearchableFiles("lucene", lucene).build(); + when(snapshot.getSegments()).thenReturn(List.of(s1, s2, s3)); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + when(executor.executeRowsAboveSeqNo(captor.capture(), eq(0L))).thenReturn(List.of()); + + service.getDocsAboveSeqNo(0L, reader, INDEX); + + List passed = captor.getValue(); + assertEquals(1, passed.size()); + assertEquals(good, passed.get(0)); + } + + public void testGetDocsAboveSeqNo_emptyWhenNoRows() throws Exception { + when(snapshot.getSegments()).thenReturn(List.of()); + when(executor.executeRowsAboveSeqNo(anyList(), anyLong())).thenReturn(List.of()); + + assertTrue(service.getDocsAboveSeqNo(5L, reader, INDEX).isEmpty()); + } + + // ---- extractLong -------------------------------------------------------- + + public void testExtractLong_number() { + assertEquals(5L, DocumentLookupService.extractLong(Map.of("k", 5L), "k", 99L)); + assertEquals(7L, DocumentLookupService.extractLong(Map.of("k", 7), "k", 99L)); + } + + public void testExtractLong_nullReturnsFallback() { + assertEquals(99L, DocumentLookupService.extractLong(Map.of(), "k", 99L)); + } + + public void testExtractLong_parsesStringNumber() { + assertEquals(123L, DocumentLookupService.extractLong(Map.of("k", "123"), "k", 99L)); + } + + public void testExtractLong_unparseableStringReturnsFallback() { + assertEquals(99L, DocumentLookupService.extractLong(Map.of("k", "abc"), "k", 99L)); + } +} diff --git a/sandbox/libs/dataformat-native/rust/Cargo.lock b/sandbox/libs/dataformat-native/rust/Cargo.lock index 78827944527d7..3ae84106485da 100644 --- a/sandbox/libs/dataformat-native/rust/Cargo.lock +++ b/sandbox/libs/dataformat-native/rust/Cargo.lock @@ -684,6 +684,15 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" +[[package]] +name = "congee" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72819224a9c43e0a5d64bdad35a71fde68bf479992fb8989635c72bc547909be" +dependencies = [ + "crossbeam-epoch", +] + [[package]] name = "const-oid" version = "0.10.2" @@ -710,6 +719,12 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "const_for" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "988d3bd6bf67b6d7ae2b519c55296fa57411c27c312575e47b2975f8d1d8590b" + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -743,6 +758,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1666,6 +1687,19 @@ dependencies = [ "web-time", ] +[[package]] +name = "fastlanes" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9272a674b446f53253f66582596614e41552d14968a3d1aaa590576a91c1188" +dependencies = [ + "const_for", + "core_detect", + "num-traits", + "paste", + "seq-macro", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -2689,10 +2723,12 @@ checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" name = "native-bridge-common" version = "0.1.0" dependencies = [ + "log", "native-bridge-macros", "tikv-jemalloc-ctl", "tikv-jemalloc-sys", "tikv-jemallocator", + "tokio", ] [[package]] @@ -2838,6 +2874,7 @@ dependencies = [ "arrow-schema", "async-trait", "base64", + "bytes", "chrono", "chrono-tz", "crc32fast", @@ -2858,6 +2895,10 @@ dependencies = [ "num_cpus", "object_store", "once_cell", + "opensearch-block-cache", + "opensearch-liquid-cache-core", + "opensearch-liquid-cache-datafusion", + "opensearch-tiered-storage", "parking_lot", "parquet", "proptest", @@ -2877,6 +2918,41 @@ dependencies = [ "url", ] +[[package]] +name = "opensearch-liquid-cache-core" +version = "0.1.0" +dependencies = [ + "ahash", + "arrow", + "arrow-schema", + "congee", + "datafusion-common", + "datafusion-expr-common", + "datafusion-physical-expr", + "fastlanes", + "log", + "num-traits", + "serde", +] + +[[package]] +name = "opensearch-liquid-cache-datafusion" +version = "0.1.0" +dependencies = [ + "ahash", + "arrow", + "arrow-schema", + "bytes", + "datafusion", + "futures", + "log", + "object_store", + "opensearch-liquid-cache-core", + "parquet", + "tempfile", + "tokio", +] + [[package]] name = "opensearch-native-lib" version = "0.1.0" @@ -2952,6 +3028,7 @@ dependencies = [ "object_store", "serde", "serde_json", + "tokio", ] [[package]] @@ -2966,7 +3043,6 @@ dependencies = [ "native-bridge-common", "object_store", "opensearch-block-cache", - "tempfile", "thiserror 1.0.69", "tokio", ] diff --git a/sandbox/libs/dataformat-native/rust/Cargo.toml b/sandbox/libs/dataformat-native/rust/Cargo.toml index d632a5c550f4d..0af25e842e7b9 100644 --- a/sandbox/libs/dataformat-native/rust/Cargo.toml +++ b/sandbox/libs/dataformat-native/rust/Cargo.toml @@ -4,6 +4,8 @@ members = [ "common", "macros", "lib", + "liquid-cache/core", + "liquid-cache/datafusion", "../../../plugins/analytics-backend-datafusion/rust", "../../../plugins/parquet-data-format/src/main/rust", "../../../plugins/native-repository-s3/src/main/rust", @@ -30,6 +32,8 @@ datafusion-datasource = "=54.0.0" datafusion-common = "=54.0.0" datafusion-execution = "=54.0.0" datafusion-physical-expr = "=54.0.0" +datafusion-physical-expr-common = "=54.0.0" +datafusion-expr-common = "=54.0.0" datafusion-substrait = "=54.0.0" # Async @@ -56,6 +60,12 @@ tikv-jemallocator = { version = "=0.6.1", features = ["disable_initial_exec_tls" tikv-jemalloc-ctl = { version = "=0.6.1", features = ["stats"] } tikv-jemalloc-sys = { version = "=0.6.1", features = ["stats"] } +# Liquid cache (vendored subset — see liquid-cache/README.md) +ahash = "=0.8.12" +congee = "=0.4.1" +fastlanes = "=0.5.2" +num-traits = "=0.2.19" + # Misc dashmap = { version = "=5.5.3", features = ["raw-api", "serde"] } num_cpus = "=1.17.0" @@ -77,6 +87,9 @@ proptest = "=1.4.0" # Internal native-bridge-common = { path = "common" } +opensearch-liquid-cache-core = { path = "liquid-cache/core" } +opensearch-liquid-cache-datafusion = { path = "liquid-cache/datafusion" } +opensearch-tiered-storage = { path = "../../tiered-storage/src/main/rust" } opensearch-repository-s3 = { path = "../../../plugins/native-repository-s3/src/main/rust" } opensearch-repository-gcs = { path = "../../../plugins/native-repository-gcs/src/main/rust" } opensearch-repository-azure = { path = "../../../plugins/native-repository-azure/src/main/rust" } diff --git a/sandbox/libs/dataformat-native/rust/common/Cargo.toml b/sandbox/libs/dataformat-native/rust/common/Cargo.toml index 65f31790dee1d..93c42c77e3e75 100644 --- a/sandbox/libs/dataformat-native/rust/common/Cargo.toml +++ b/sandbox/libs/dataformat-native/rust/common/Cargo.toml @@ -12,6 +12,8 @@ crate-type = ["rlib"] native-bridge-macros = { path = "../macros" } tikv-jemalloc-ctl = { workspace = true } tikv-jemalloc-sys = { workspace = true } +tokio = { workspace = true } +log = { workspace = true } [dev-dependencies] tikv-jemallocator = { workspace = true } diff --git a/sandbox/libs/dataformat-native/rust/common/src/allocator.rs b/sandbox/libs/dataformat-native/rust/common/src/allocator.rs index 1b1d091699183..79af1910843c0 100644 --- a/sandbox/libs/dataformat-native/rust/common/src/allocator.rs +++ b/sandbox/libs/dataformat-native/rust/common/src/allocator.rs @@ -36,9 +36,17 @@ fn mib() -> &'static StatsMib { /// Advances the jemalloc epoch and reads both stats atomically. fn refresh_stats() -> Result<(i64, i64), String> { let m = mib(); - m.epoch.advance().map_err(|e| format!("jemalloc epoch advance failed: {}", e))?; - let alloc = m.allocated.read().map_err(|e| format!("jemalloc allocated read failed: {}", e))? as i64; - let res = m.resident.read().map_err(|e| format!("jemalloc resident read failed: {}", e))? as i64; + m.epoch + .advance() + .map_err(|e| format!("jemalloc epoch advance failed: {}", e))?; + let alloc = m + .allocated + .read() + .map_err(|e| format!("jemalloc allocated read failed: {}", e))? as i64; + let res = m + .resident + .read() + .map_err(|e| format!("jemalloc resident read failed: {}", e))? as i64; Ok((alloc, res)) } @@ -69,27 +77,35 @@ pub fn resident_bytes() -> i64 { /// FFI: Returns current jemalloc allocated bytes, or negative error pointer. #[no_mangle] pub extern "C" fn native_jemalloc_allocated_bytes() -> i64 { - ffm_wrap("native_jemalloc_allocated_bytes", || refresh_stats().map(|(alloc, _)| alloc)) + ffm_wrap("native_jemalloc_allocated_bytes", || { + refresh_stats().map(|(alloc, _)| alloc) + }) } /// FFI: Returns current jemalloc resident bytes, or negative error pointer. #[no_mangle] pub extern "C" fn native_jemalloc_resident_bytes() -> i64 { - ffm_wrap("native_jemalloc_resident_bytes", || refresh_stats().map(|(_, res)| res)) + ffm_wrap("native_jemalloc_resident_bytes", || { + refresh_stats().map(|(_, res)| res) + }) } /// FFI: Sets dirty_decay_ms for all arenas at runtime. Returns 0 on success, negative error pointer on failure. /// Called from Java when the cluster setting `native.jemalloc.dirty_decay_ms` changes. #[no_mangle] pub extern "C" fn native_jemalloc_set_dirty_decay_ms(ms: i64) -> i64 { - ffm_wrap("native_jemalloc_set_dirty_decay_ms", || set_all_arenas(b"dirty_decay_ms\0", ms)) + ffm_wrap("native_jemalloc_set_dirty_decay_ms", || { + set_all_arenas(b"dirty_decay_ms\0", ms) + }) } /// FFI: Sets muzzy_decay_ms for all arenas at runtime. Returns 0 on success, negative error pointer on failure. /// Called from Java when the cluster setting `native.jemalloc.muzzy_decay_ms` changes. #[no_mangle] pub extern "C" fn native_jemalloc_set_muzzy_decay_ms(ms: i64) -> i64 { - ffm_wrap("native_jemalloc_set_muzzy_decay_ms", || set_all_arenas(b"muzzy_decay_ms\0", ms)) + ffm_wrap("native_jemalloc_set_muzzy_decay_ms", || { + set_all_arenas(b"muzzy_decay_ms\0", ms) + }) } /// Applies a setting to all existing jemalloc arenas. @@ -196,7 +212,10 @@ fn purge_thread_loop() { /// Called once from Java at node startup (NativeBridgeModule.createComponents). /// Safe to call multiple times — only the first call spawns the thread. #[no_mangle] -pub extern "C" fn native_jemalloc_start_purge_thread(threshold_bytes: i64, interval_ms: i64) -> i64 { +pub extern "C" fn native_jemalloc_start_purge_thread( + threshold_bytes: i64, + interval_ms: i64, +) -> i64 { PURGE_THRESHOLD_BYTES.store(threshold_bytes, Ordering::Relaxed); PURGE_INTERVAL_MS.store(interval_ms as u64, Ordering::Relaxed); start_purge_thread().unpark(); @@ -238,7 +257,6 @@ pub extern "C" fn native_jemalloc_get_purge_count() -> i64 { PURGE_COUNT.load(Ordering::Relaxed) as i64 } - // ── Heap profiling ────────────────────────────────────────────────────────── // // Requires the process to be started with `_RJEM_MALLOC_CONF=prof:true,...` @@ -280,9 +298,12 @@ pub unsafe extern "C" fn native_jemalloc_heap_prof_dump(path: *const std::ffi::c let c_str = std::ffi::CStr::from_ptr(path); let path_bytes = c_str.to_bytes_with_nul(); // prof.dump expects a *const c_char pointing to the file path - tikv_jemalloc_ctl::raw::write(b"prof.dump\0", path_bytes.as_ptr() as *const std::ffi::c_char) - .map(|_| 0i64) - .map_err(|e| format!("failed to dump heap profile: {}", e)) + tikv_jemalloc_ctl::raw::write( + b"prof.dump\0", + path_bytes.as_ptr() as *const std::ffi::c_char, + ) + .map(|_| 0i64) + .map_err(|e| format!("failed to dump heap profile: {}", e)) }) } @@ -296,7 +317,12 @@ pub extern "C" fn native_jemalloc_heap_prof_reset(lg_sample: usize) -> i64 { ffm_wrap("native_jemalloc_heap_prof_reset", || { unsafe { tikv_jemalloc_ctl::raw::write(b"prof.reset\0", lg_sample) } .map(|_| 0i64) - .map_err(|e| format!("failed to reset profiling with lg_sample={}: {}", lg_sample, e)) + .map_err(|e| { + format!( + "failed to reset profiling with lg_sample={}: {}", + lg_sample, e + ) + }) }) } @@ -370,7 +396,11 @@ mod tests { #[test] fn heap_prof_dump_null_path_returns_error() { let rc = unsafe { native_jemalloc_heap_prof_dump(std::ptr::null()) }; - assert!(rc < 0, "expected negative error pointer for null path, got {}", rc); + assert!( + rc < 0, + "expected negative error pointer for null path, got {}", + rc + ); } #[test] @@ -379,7 +409,10 @@ mod tests { native_jemalloc_start_purge_thread(0, 50); let before = native_jemalloc_get_purge_count(); std::thread::sleep(Duration::from_millis(200)); - assert!(native_jemalloc_get_purge_count() > before, "purge thread should have fired"); + assert!( + native_jemalloc_get_purge_count() > before, + "purge thread should have fired" + ); } #[test] @@ -391,7 +424,11 @@ mod tests { std::thread::sleep(Duration::from_millis(100)); let before = native_jemalloc_get_purge_count(); std::thread::sleep(Duration::from_millis(200)); - assert_eq!(native_jemalloc_get_purge_count(), before, "no purges when paused"); + assert_eq!( + native_jemalloc_get_purge_count(), + before, + "no purges when paused" + ); // Resume native_jemalloc_set_purge_interval(50); } @@ -402,7 +439,11 @@ mod tests { native_jemalloc_start_purge_thread(i64::MAX, 50); let before = native_jemalloc_get_purge_count(); std::thread::sleep(Duration::from_millis(200)); - assert_eq!(native_jemalloc_get_purge_count(), before, "no purge when below threshold"); + assert_eq!( + native_jemalloc_get_purge_count(), + before, + "no purge when below threshold" + ); // Restore for other tests native_jemalloc_set_purge_threshold(0); } diff --git a/sandbox/libs/dataformat-native/rust/common/src/error.rs b/sandbox/libs/dataformat-native/rust/common/src/error.rs index ec30f053654a0..8e1375cf85446 100644 --- a/sandbox/libs/dataformat-native/rust/common/src/error.rs +++ b/sandbox/libs/dataformat-native/rust/common/src/error.rs @@ -18,7 +18,8 @@ use std::os::raw::c_char; /// Heap-allocate the error message and return its pointer as a negative i64. pub fn into_error_ptr(msg: String) -> i64 { - let c = CString::new(msg).unwrap_or_else(|_| CString::new("error contained null byte").unwrap()); + let c = + CString::new(msg).unwrap_or_else(|_| CString::new("error contained null byte").unwrap()); let ptr = c.into_raw(); -(ptr as i64) } @@ -64,7 +65,8 @@ pub unsafe extern "C" fn native_error_free(ptr: i64) { #[no_mangle] pub unsafe extern "C" fn native_test_panic(msg_ptr: *const u8, msg_len: i64) -> i64 { match ::std::panic::catch_unwind(::std::panic::AssertUnwindSafe(|| -> Result { - let msg = std::str::from_utf8_unchecked(std::slice::from_raw_parts(msg_ptr, msg_len as usize)); + let msg = + std::str::from_utf8_unchecked(std::slice::from_raw_parts(msg_ptr, msg_len as usize)); panic!("{}", msg); })) { Ok(Ok(v)) => v, diff --git a/sandbox/libs/dataformat-native/rust/common/src/io_runtime.rs b/sandbox/libs/dataformat-native/rust/common/src/io_runtime.rs new file mode 100644 index 0000000000000..43951a46d4453 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/common/src/io_runtime.rs @@ -0,0 +1,104 @@ +/* + * 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. + */ + +//! Process-global IO runtime [`Handle`], shared across all native crates. +//! +//! # Why this lives here +//! +//! The dedicated IO runtime is owned by the analytics engine's `RuntimeManager` +//! (in the `opensearch-datafusion` crate). But the object stores that should +//! dispatch their network IO onto it — `AmazonS3`, `GoogleCloudStorage`, +//! `MicrosoftAzure` — are built in the separate `native-repository-*` crates, +//! which do NOT depend on `opensearch-datafusion`. The one crate they all share +//! is `native-bridge-common`, so the handle slot lives here as the single source +//! of truth every crate can reach. +//! +//! The analytics `RuntimeManager` calls [`set_io_handle`] when it builds the IO +//! runtime (and [`clear_io_handle`] on shutdown). Each remote object-store +//! builder calls [`io_handle`] and, if present, installs a +//! `SpawnedReqwestConnector` so HTTP requests + response-body streaming run on +//! the IO runtime instead of the CPU runtime — DataFusion's `thread_pools` +//! example mechanism. If no handle is installed (e.g. a unit test, or a process +//! with no IO runtime), the builders leave their default connector untouched. +//! +//! A swappable [`RwLock`] slot (not a first-wins `OnceLock`) is used on purpose: +//! `DataFusionService.doStop()` tears the IO runtime down and `doStart()` builds +//! a fresh one (node restarts in tests, service recycling). A stale handle would +//! point at a dead runtime; last-writer-wins keeps it live across restarts. + +use std::sync::RwLock; +use tokio::runtime::Handle; + +static GLOBAL_IO_HANDLE: RwLock> = RwLock::new(None); + +/// Install (or replace) the process-global IO runtime handle. Most recent writer +/// wins, so the handle always points at the live IO runtime. +pub fn set_io_handle(handle: Handle) { + *GLOBAL_IO_HANDLE.write().unwrap() = Some(handle); +} + +/// Clear the process-global IO runtime handle (called when the owning runtime is +/// shut down) so a stale, dead-runtime handle is never handed out. +pub fn clear_io_handle() { + *GLOBAL_IO_HANDLE.write().unwrap() = None; +} + +/// Returns the process-global IO runtime handle, if one is currently installed. +pub fn io_handle() -> Option { + GLOBAL_IO_HANDLE.read().unwrap().clone() +} + +#[cfg(test)] +mod tests { + use super::*; + + // One test on purpose: the slot is process-global, so parallel tests would + // race on it. Covers the full lifecycle the remote object-store builders and + // RuntimeManager rely on: install → read → last-writer-wins → clear. + #[test] + fn set_get_replace_clear_lifecycle() { + // Clean slate (a prior test in this binary may have left a handle). + clear_io_handle(); + assert!(io_handle().is_none(), "handle must start cleared"); + + let rt_a = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + .unwrap(); + let rt_b = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + .unwrap(); + + // Install A → io_handle() hands out A. + set_io_handle(rt_a.handle().clone()); + assert_eq!( + io_handle().as_ref().map(|h| h.id()), + Some(rt_a.handle().id()), + "io_handle() must return the installed handle (A)" + ); + + // Install B → last writer wins (this is why it's an RwLock slot, not a + // first-wins OnceLock: doStop/doStart rebuilds the runtime). + set_io_handle(rt_b.handle().clone()); + assert_eq!( + io_handle().as_ref().map(|h| h.id()), + Some(rt_b.handle().id()), + "the most recent set_io_handle must win (B replaces A)" + ); + + // Clear → builders fall back to the default connector. + clear_io_handle(); + assert!( + io_handle().is_none(), + "clear_io_handle must empty the slot so no stale handle is handed out" + ); + } +} diff --git a/sandbox/libs/dataformat-native/rust/common/src/lib.rs b/sandbox/libs/dataformat-native/rust/common/src/lib.rs index c44fa871c4fb3..c26409d0d8f97 100644 --- a/sandbox/libs/dataformat-native/rust/common/src/lib.rs +++ b/sandbox/libs/dataformat-native/rust/common/src/lib.rs @@ -8,9 +8,10 @@ //! Shared Rust utilities for OpenSearch sandbox native plugins. +pub mod allocator; pub mod error; +pub mod io_runtime; pub mod logger; -pub mod allocator; pub mod memory_pool; // Re-export the proc macro so plugins use `#[native_bridge_common::ffm_safe]` diff --git a/sandbox/libs/dataformat-native/rust/common/src/logger.rs b/sandbox/libs/dataformat-native/rust/common/src/logger.rs index f48ba3259a53a..3e47d3e35bbd6 100644 --- a/sandbox/libs/dataformat-native/rust/common/src/logger.rs +++ b/sandbox/libs/dataformat-native/rust/common/src/logger.rs @@ -49,10 +49,42 @@ pub extern "C" fn native_logger_set_level(level: i32) { MAX_LEVEL.store(clamped, Ordering::Relaxed); } +/// A `log` crate backend that forwards to our native bridge callback. +/// This makes `log::info!()` from any crate (including liquid-cache) +/// appear in OpenSearch's log file via the Java RustLoggerBridge. +struct NativeBridgeLogger; + +impl ::log::Log for NativeBridgeLogger { + fn enabled(&self, metadata: &::log::Metadata) -> bool { + metadata.level() <= ::log::Level::Info + } + + fn log(&self, record: &::log::Record) { + if !self.enabled(record.metadata()) { + return; + } + let level = match record.level() { + ::log::Level::Error => LogLevel::Error, + ::log::Level::Warn | ::log::Level::Info => LogLevel::Info, + _ => LogLevel::Debug, + }; + let msg = format!("{}", record.args()); + log(level, &msg); + } + + fn flush(&self) {} +} + +static NATIVE_BRIDGE_LOGGER: NativeBridgeLogger = NativeBridgeLogger; + /// Called by Java at startup to register the log callback. #[no_mangle] pub unsafe extern "C" fn native_logger_init(callback: LogCallback) { LOG_CALLBACK.store(callback as *mut (), Ordering::Release); + // Initialize the `log` crate facade so that log::info!() from any + // dependency (e.g. liquid-cache) routes through our native bridge. + let _ = ::log::set_logger(&NATIVE_BRIDGE_LOGGER); + ::log::set_max_level(::log::LevelFilter::Info); log(LogLevel::Info, "Native logger initialized successfully"); } diff --git a/sandbox/libs/dataformat-native/rust/common/src/memory_pool.rs b/sandbox/libs/dataformat-native/rust/common/src/memory_pool.rs index 7fc5c7c83a20c..6fcbdf2ae94ab 100644 --- a/sandbox/libs/dataformat-native/rust/common/src/memory_pool.rs +++ b/sandbox/libs/dataformat-native/rust/common/src/memory_pool.rs @@ -15,10 +15,10 @@ //! `MemoryReservation` is an RAII handle that automatically returns memory to the //! pool on drop, preventing leaks even on error paths. +use std::fmt; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, Condvar, Mutex}; +use std::sync::{Arc, Condvar, Mutex, OnceLock}; use std::time::Duration; -use std::fmt; /// Default timeout for blocking wait (300 seconds). pub const DEFAULT_WAIT_TIMEOUT: Duration = Duration::from_secs(300); @@ -26,13 +26,61 @@ pub const DEFAULT_WAIT_TIMEOUT: Duration = Duration::from_secs(300); /// Merge operations can wait longer (600 seconds). pub const MERGE_WAIT_TIMEOUT: Duration = Duration::from_secs(600); -/// Controls whether an allocation blocks or rejects immediately. +/// Controls how a reservation reacts when the pool is full. #[derive(Debug, Clone)] pub enum PoolBehavior { /// Block until memory is available, up to the given timeout. Wait(Duration), - /// Fail immediately if pool is full. + /// Fail immediately if the pool is full, unless the registered over-commit decider (the Java-side + /// allocator) grants an over-commit based on node-level native memory pressure. When granted, the + /// reservation over-commits via infallible `grow` and the decider is told to release when the + /// reservation is freed/dropped. Falls back to a plain reject when no decider is registered or the + /// decider declines. Reject, + /// Never block and never fail: account via infallible `grow`, allowing the pool to over-commit. + IgnoreLimit, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Over-commit decider hook. +// +// The decision "may this reservation over-commit its pool right now?" is owned by the Java-side +// `ArrowNativeAllocator` (it knows node-level native memory pressure, permits, and the feature +// flag). Java registers two C-ABI callbacks via FFM upcall stubs. Because all native modules are +// linked into a single cdylib, these statics are a single shared instance and one registration +// covers every pool that uses `Reject`. +// ───────────────────────────────────────────────────────────────────────────── + +/// Decider: returns a nonzero token id to grant an over-commit, or 0 to reject. The token is an +/// opaque handle minted by the Java allocator; Rust stores it and echoes it back on release. +pub type OverCommitDecider = extern "C" fn() -> i64; +/// Releaser: called with the token of a reservation that held an over-commit permit when it is +/// freed/dropped, so the Java allocator can release exactly that grant. +pub type OverCommitReleaser = extern "C" fn(i64); + +static OVERCOMMIT_DECIDER: OnceLock = OnceLock::new(); +static OVERCOMMIT_RELEASER: OnceLock = OnceLock::new(); + +/// Registers the over-commit decision callbacks. Idempotent — first registration wins. +pub fn set_overcommit_callbacks(decider: OverCommitDecider, releaser: OverCommitReleaser) { + let _ = OVERCOMMIT_DECIDER.set(decider); + let _ = OVERCOMMIT_RELEASER.set(releaser); +} + +/// Asks the registered decider whether an over-commit may proceed. Returns the granted token id +/// (nonzero), or 0 when no decider is registered (feature effectively off) or the decider declines. +fn overcommit_grant() -> i64 { + match OVERCOMMIT_DECIDER.get() { + Some(decide) => decide(), + None => 0, + } +} + +/// Returns the over-commit permit identified by `token` back to the decider (Java allocator). +fn overcommit_release(token: i64) { + if let Some(release) = OVERCOMMIT_RELEASER.get() { + release(token); + } } /// Error returned when a pool cannot satisfy an allocation request. @@ -118,14 +166,16 @@ impl MemoryPool { return Ok(()); } let limit = self.limit.load(Ordering::Relaxed); - let result = self.used.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |used| { - let new_used = used.checked_add(bytes)?; - if limit > 0 && new_used > limit { - None - } else { - Some(new_used) - } - }); + let result = self + .used + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |used| { + let new_used = used.checked_add(bytes)?; + if limit > 0 && new_used > limit { + None + } else { + Some(new_used) + } + }); match result { Ok(old) => { @@ -167,7 +217,10 @@ impl MemoryPool { let remaining = timeout - elapsed; let guard = self.notify_lock.lock().unwrap(); - let _ = self.notify.wait_timeout(guard, remaining.min(Duration::from_secs(1))).unwrap(); + let _ = self + .notify + .wait_timeout(guard, remaining.min(Duration::from_secs(1))) + .unwrap(); if self.try_grow(bytes).is_ok() { return Ok(()); @@ -228,6 +281,10 @@ pub struct MemoryReservation { consumer: &'static str, size: usize, behavior: PoolBehavior, + /// Nonzero token of the over-commit permit held by this reservation (0 = none). The permit is + /// returned to the decider (with this token) when the reservation is freed/dropped (one grant ↔ + /// one release). + overcommit_token: i64, } impl MemoryReservation { @@ -237,6 +294,7 @@ impl MemoryReservation { consumer, size: 0, behavior, + overcommit_token: 0, } } @@ -244,15 +302,57 @@ impl MemoryReservation { pub fn request(&mut self, bytes: usize) -> Result<(), Box> { match &self.behavior { PoolBehavior::Reject => { - self.pool.try_grow(bytes)?; - self.size += bytes; - Ok(()) + match self.pool.try_grow(bytes) { + Ok(()) => { + self.size += bytes; + Ok(()) + } + Err(exhausted) => { + if self.overcommit_token != 0 { + // Already holding an over-commit permit for this reservation — continue + // over-committing without re-consulting the decider. + self.pool.grow(bytes); + self.size += bytes; + Ok(()) + } else { + let token = overcommit_grant(); + if token != 0 { + // Decider (Java allocator) granted based on node-level native pressure. + self.pool.grow(bytes); // infallible over-commit + self.size += bytes; + self.overcommit_token = token; + crate::log_debug!( + "[RUST] over-commit granted: pool '{}' consumer '{}' token {} committing {} bytes beyond limit (reservation size now {})", + self.pool.name(), + self.consumer, + token, + bytes, + self.size + ); + Ok(()) + } else { + crate::log_debug!( + "[RUST] over-commit refused by decider: pool '{}' consumer '{}' needed {} bytes beyond limit", + self.pool.name(), + self.consumer, + bytes + ); + Err(Box::new(exhausted)) + } + } + } + } } PoolBehavior::Wait(timeout) => { self.pool.wait_and_grow(bytes, *timeout)?; self.size += bytes; Ok(()) } + PoolBehavior::IgnoreLimit => { + self.pool.grow(bytes); + self.size += bytes; + Ok(()) + } } } @@ -269,6 +369,31 @@ impl MemoryReservation { self.size -= actual; } + /// Reserve an estimated amount. Returns the estimated amount for later use with `reconcile()`. + pub fn reserve_estimated( + &mut self, + estimated: usize, + ) -> Result> { + self.request(estimated)?; + Ok(estimated) + } + + /// Reconcile a previous estimate with the actual measured size. + /// If actual > estimated: infallible grow for the delta. + /// If actual < estimated: shrink the excess. + pub fn reconcile(&mut self, estimated: usize, actual: usize) { + if actual > estimated { + self.grow(actual - estimated); + } else if actual < estimated { + self.shrink(estimated - actual); + } + } + + /// Returns a reference to the underlying pool. + pub fn pool(&self) -> &Arc { + &self.pool + } + /// Release all memory back to the pool. pub fn free(&mut self) -> usize { let s = self.size; @@ -276,6 +401,16 @@ impl MemoryReservation { self.pool.shrink(s); self.size = 0; } + if self.overcommit_token != 0 { + overcommit_release(self.overcommit_token); + crate::log_debug!( + "[RUST] over-commit permit released: pool '{}' consumer '{}' token {}", + self.pool.name(), + self.consumer, + self.overcommit_token + ); + self.overcommit_token = 0; + } s } @@ -294,6 +429,7 @@ impl MemoryReservation { consumer, size: 0, behavior: self.behavior.clone(), + overcommit_token: 0, } } } @@ -303,6 +439,15 @@ impl Drop for MemoryReservation { if self.size > 0 { self.pool.shrink(self.size); } + if self.overcommit_token != 0 { + overcommit_release(self.overcommit_token); + crate::log_debug!( + "[RUST] over-commit permit released on drop: pool '{}' consumer '{}' token {}", + self.pool.name(), + self.consumer, + self.overcommit_token + ); + } } } @@ -367,4 +512,36 @@ mod tests { assert_eq!(child.consumer(), "child"); assert_eq!(pool.used(), 100); } + + #[test] + fn test_ignore_limit_exceeds_limit() { + let pool = Arc::new(MemoryPool::new("test", 100)); + let mut res = MemoryReservation::new(&pool, "writer", PoolBehavior::IgnoreLimit); + // Request far beyond the limit — must succeed and over-commit. + assert!(res.request(500).is_ok()); + assert_eq!(res.size(), 500); + assert_eq!(pool.used(), 500); + assert!(pool.used() > pool.limit()); + } + + #[test] + fn test_ignore_limit_never_fails_when_already_over() { + let pool = Arc::new(MemoryPool::new("test", 100)); + let mut res = MemoryReservation::new(&pool, "writer", PoolBehavior::IgnoreLimit); + res.request(1000).unwrap(); + // Already over the limit; further requests still succeed immediately. + assert!(res.request(1000).is_ok()); + assert_eq!(pool.used(), 2000); + } + + #[test] + fn test_ignore_limit_drop_releases() { + let pool = Arc::new(MemoryPool::new("test", 100)); + { + let mut res = MemoryReservation::new(&pool, "writer", PoolBehavior::IgnoreLimit); + res.request(500).unwrap(); + assert_eq!(pool.used(), 500); + } + assert_eq!(pool.used(), 0); + } } diff --git a/sandbox/libs/dataformat-native/rust/common/tests/pool_backpressure_tests.rs b/sandbox/libs/dataformat-native/rust/common/tests/pool_backpressure_tests.rs new file mode 100644 index 0000000000000..4a9c269e1b8b8 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/common/tests/pool_backpressure_tests.rs @@ -0,0 +1,196 @@ +/* + * 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. + */ + +//! Tests for memory pool backpressure, timeout, and deadlock prevention. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use native_bridge_common::memory_pool::{MemoryPool, MemoryReservation, PoolBehavior}; + +/// Request times out cleanly when pool is full. +#[test] +fn test_request_rejected_when_pool_full() { + let pool = Arc::new(MemoryPool::new("test", 1_000)); + let mut reservation = MemoryReservation::new( + &pool, + "requester", + PoolBehavior::Wait(Duration::from_secs(1)), + ); + + // Pre-fill pool to near capacity + reservation.grow(999); + assert_eq!(pool.used(), 999); + + // Request more than available — should timeout + let start = Instant::now(); + let result = reservation.request(500); + let elapsed = start.elapsed(); + + assert!(result.is_err(), "Expected timeout error"); + assert!( + elapsed >= Duration::from_millis(900), + "Should have waited ~1s, got {:?}", + elapsed + ); + assert!( + elapsed < Duration::from_millis(2_000), + "Should not hang, got {:?}", + elapsed + ); + // Pool unchanged — failed request didn't partially allocate + assert_eq!(pool.used(), 999); +} + +/// Request blocks and succeeds when another thread frees memory. +#[test] +fn test_request_waits_and_succeeds() { + let pool = Arc::new(MemoryPool::new("test", 10_000)); + + // Blocker fills pool + let mut blocker = + MemoryReservation::new(&pool, "blocker", PoolBehavior::Wait(Duration::from_secs(1))); + blocker.grow(9_500); + assert_eq!(pool.used(), 9_500); + + // Thread frees after 500ms + let pool_clone = Arc::clone(&pool); + let handle = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(500)); + pool_clone.shrink(9_500); + }); + + // Writer requests — should block then succeed after space is freed + let mut writer = + MemoryReservation::new(&pool, "writer", PoolBehavior::Wait(Duration::from_secs(5))); + let start = Instant::now(); + let result = writer.request(5_000); + let elapsed = start.elapsed(); + + handle.join().unwrap(); + + assert!(result.is_ok(), "Expected success after blocker freed"); + assert!( + elapsed >= Duration::from_millis(400), + "Should have waited ~500ms, got {:?}", + elapsed + ); + assert!( + elapsed < Duration::from_millis(2_000), + "Should not wait too long, got {:?}", + elapsed + ); + // Writer holds its allocation + assert_eq!(writer.size(), 5_000); +} + +/// Request blocks for the full timeout duration then fails. +#[test] +fn test_request_waits_and_times_out() { + let pool = Arc::new(MemoryPool::new("test", 1_000)); + + let mut blocker = + MemoryReservation::new(&pool, "blocker", PoolBehavior::Wait(Duration::from_secs(1))); + blocker.grow(999); + + let mut writer = + MemoryReservation::new(&pool, "writer", PoolBehavior::Wait(Duration::from_secs(2))); + + let start = Instant::now(); + let result = writer.request(500); + let elapsed = start.elapsed(); + + assert!(result.is_err(), "Expected timeout"); + assert!( + elapsed >= Duration::from_secs(2), + "Should wait full timeout, got {:?}", + elapsed + ); + assert!( + elapsed < Duration::from_secs(3), + "Should not hang forever, got {:?}", + elapsed + ); + assert_eq!(pool.used(), 999); // blocker unchanged +} + +/// Two concurrent requesters on the same pool — one waits for the other, no deadlock. +#[test] +fn test_no_deadlock_two_requesters_same_pool() { + let pool = Arc::new(MemoryPool::new("test", 5_000)); + + let start = Instant::now(); + + std::thread::scope(|s| { + let p1 = Arc::clone(&pool); + let p2 = Arc::clone(&pool); + + // Thread 1: grabs 3000, holds for 500ms, releases + s.spawn(move || { + let mut r = + MemoryReservation::new(&p1, "t1", PoolBehavior::Wait(Duration::from_secs(10))); + r.request(3_000).expect("t1 request should succeed"); + std::thread::sleep(Duration::from_millis(500)); + r.shrink(3_000); + }); + + // Thread 2: waits 100ms (ensure t1 gets in first), then requests 3000 + s.spawn(move || { + std::thread::sleep(Duration::from_millis(100)); + let mut r = + MemoryReservation::new(&p2, "t2", PoolBehavior::Wait(Duration::from_secs(10))); + // This blocks until t1 releases (pool: 3000/5000, needs 3000 more = 6000 > 5000) + r.request(3_000) + .expect("t2 request should succeed after t1 releases"); + r.shrink(3_000); + }); + }); + + let elapsed = start.elapsed(); + + assert_eq!(pool.used(), 0, "Pool should be empty after both complete"); + assert!( + elapsed < Duration::from_secs(5), + "Should complete within 5s (no deadlock), got {:?}", + elapsed + ); +} + +/// Reservation that partially succeeded then failed on a subsequent request +/// frees all its memory when dropped. +#[test] +fn test_reservation_drop_frees_all_on_partial_failure() { + let pool = Arc::new(MemoryPool::new("test", 10_000)); + + { + let mut reservation = + MemoryReservation::new(&pool, "res", PoolBehavior::Wait(Duration::from_secs(1))); + + // First allocation succeeds + let r1 = reservation.request(3_000); + assert!(r1.is_ok()); + assert_eq!(pool.used(), 3_000); + assert_eq!(reservation.size(), 3_000); + + // Infallible grow also succeeds + reservation.grow(2_000); + assert_eq!(pool.used(), 5_000); + assert_eq!(reservation.size(), 5_000); + + // Second request fails — needs 8000 more, total would be 13000 > 10000 + let r2 = reservation.request(8_000); + assert!(r2.is_err()); + assert_eq!(pool.used(), 5_000); // unchanged by failed request + assert_eq!(reservation.size(), 5_000); // unchanged + + // reservation drops here + } + + // After drop — all previously allocated memory is freed + assert_eq!(pool.used(), 0, "Drop should free all tracked memory"); +} diff --git a/sandbox/libs/dataformat-native/rust/lib/src/lib.rs b/sandbox/libs/dataformat-native/rust/lib/src/lib.rs index 29e59e9e025bc..03bfa9b990714 100644 --- a/sandbox/libs/dataformat-native/rust/lib/src/lib.rs +++ b/sandbox/libs/dataformat-native/rust/lib/src/lib.rs @@ -45,8 +45,8 @@ static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; extern crate native_bridge_common; extern crate opensearch_datafusion; extern crate opensearch_parquet_format; -extern crate opensearch_repository_s3; -extern crate opensearch_repository_gcs; extern crate opensearch_repository_azure; extern crate opensearch_repository_fs; +extern crate opensearch_repository_gcs; +extern crate opensearch_repository_s3; extern crate opensearch_tiered_storage; diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/README.md b/sandbox/libs/dataformat-native/rust/liquid-cache/README.md new file mode 100644 index 0000000000000..d5c43b9ae656a --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/README.md @@ -0,0 +1,43 @@ +# opensearch-liquid-cache (vendored subset) + +In-memory-only subset of [liquid-cache](https://github.com/XiangpengHao/liquid-cache), +vendored so the OpenSearch sandbox takes **no Cargo dependency** on the upstream +package. + +## Provenance + +- Vendored from: https://github.com/cocosz/liquid-cache +- Branch: `lc-opensearch-df54-v2` +- Commit: `8311bccc258756127adbebee3e54140b60fc09ba` +- License: Apache-2.0 (same as upstream) + +## What was removed relative to upstream + +Everything needed only for the disk tier, client/server mode, or data types the +OpenSearch integration gate never caches: + +- **Disk tier**: `t4` store (io-uring), `DiskLiquid`/`DiskArrow` cache entries, + `SqueezeIoHandler`/`DefaultSqueezeIo`, squeeze-to-disk policies, `ipc.rs` + serialization and per-array `to_bytes`/`from_bytes`. The cache is memory-only; + under pressure entries are transcoded (Arrow → Liquid) and then evicted. +- **Client/server**: the whole `liquid-cache-common` crate (arrow-flight/tonic/axum), + `datafusion-client`, `datafusion-server`. +- **String/binary caching**: `byte_view_array` + FSST (`fsst-rs`). The integration + gate only engages LC for numeric/date/timestamp/boolean projections. +- **Variant/JSON shredding**: `variant_array`, `parquet-variant-*` deps, variant UDFs. +- **Date32 squeeze**: `squeezed_date32_array` (a squeeze-to-disk optimization). +- **LineageOptimizer**: excluded by the POC already (planning overhead). +- **Tracing**: `fastrace`, `sysinfo`. + +## Crates + +- `core/` — package `opensearch-liquid-cache-core`, **lib name `liquid_cache`**: + cache storage (index, budget, eviction policies, transcode) + numeric + LiquidArray encodings. +- `datafusion/` — package `opensearch-liquid-cache-datafusion`, **lib name + `liquid_cache_datafusion`**: `LiquidParquetSource` (the ParquetSource + replacement), reader/stream machinery, `LocalModeOptimizer`. + +The lib names intentionally match upstream so vendored code and the +`analytics-backend-datafusion` integration compile with unchanged `use` paths, +which also keeps future re-syncs against upstream reviewable. diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/core/Cargo.toml b/sandbox/libs/dataformat-native/rust/liquid-cache/core/Cargo.toml new file mode 100644 index 0000000000000..dbcefdcdf512e --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/core/Cargo.toml @@ -0,0 +1,31 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# Vendored in-memory subset of liquid-cache core. +# Provenance: https://github.com/cocosz/liquid-cache branch lc-opensearch-df54-v2 +# commit 8311bccc258756127adbebee3e54140b60fc09ba. See ../README.md. + +[package] +name = "opensearch-liquid-cache-core" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +publish = false + +[lib] +# Keep the upstream crate name so vendored code and the integration keep +# their `use liquid_cache::...` paths, making future upstream re-syncs diffable. +name = "liquid_cache" +path = "src/lib.rs" + +[dependencies] +ahash = { workspace = true } +arrow = { workspace = true } +arrow-schema = { workspace = true } +congee = { workspace = true } +datafusion-common = { workspace = true } +datafusion-expr-common = { workspace = true } +datafusion-physical-expr = { workspace = true } +fastlanes = { workspace = true } +log = { workspace = true } +num-traits = { workspace = true } +serde = { workspace = true } diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/budget.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/budget.rs new file mode 100644 index 0000000000000..4069f23a28417 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/budget.rs @@ -0,0 +1,164 @@ +use crate::sync::atomic::{AtomicUsize, Ordering}; + +#[derive(Debug)] +pub struct BudgetAccounting { + max_memory_bytes: AtomicUsize, + used_memory_bytes: AtomicUsize, +} + +impl BudgetAccounting { + pub(super) fn new(max_memory_bytes: usize) -> Self { + Self { + max_memory_bytes: AtomicUsize::new(max_memory_bytes), + used_memory_bytes: AtomicUsize::new(0), + } + } + + pub(super) fn reset_usage(&self) { + self.used_memory_bytes.store(0, Ordering::Relaxed); + } + + /// Dynamically update the max memory limit. Takes effect for new reservations. + pub fn set_max_memory_bytes(&self, new_limit: usize) { + self.max_memory_bytes.store(new_limit, Ordering::Relaxed); + } + + pub fn max_memory_bytes(&self) -> usize { + self.max_memory_bytes.load(Ordering::Relaxed) + } + + /// Try to reserve memory in the cache. + /// Returns ok if the memory was reserved, err if the memory budget is full. + pub(super) fn try_reserve_memory(&self, request_bytes: usize) -> Result<(), ()> { + let used = self.used_memory_bytes.load(Ordering::Relaxed); + if used + request_bytes > self.max_memory_bytes.load(Ordering::Relaxed) { + return Err(()); + } + + match self.used_memory_bytes.compare_exchange( + used, + used + request_bytes, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => Ok(()), + Err(_) => self.try_reserve_memory(request_bytes), + } + } + + /// Adjust memory usage after transcoding. + /// Returns ok if the usage was adjusted, err if the memory budget is full when new_size is larger than old_size. + pub(super) fn try_update_memory_usage( + &self, + old_size: usize, + new_size: usize, + ) -> Result<(), ()> { + if old_size < new_size { + let diff = new_size - old_size; + self.try_reserve_memory(diff)?; + Ok(()) + } else { + self.used_memory_bytes + .fetch_sub(old_size - new_size, Ordering::Relaxed); + Ok(()) + } + } + + pub fn memory_usage_bytes(&self) -> usize { + self.used_memory_bytes.load(Ordering::Relaxed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sync::{Arc, Barrier, thread}; + + fn test_budget(max_memory_bytes: usize) -> BudgetAccounting { + BudgetAccounting::new(max_memory_bytes) + } + + #[test] + fn test_memory_reservation_and_accounting() { + let config = test_budget(1000); + + assert_eq!(config.memory_usage_bytes(), 0); + + assert!(config.try_reserve_memory(500).is_ok()); + assert_eq!(config.memory_usage_bytes(), 500); + + assert!(config.try_reserve_memory(300).is_ok()); + assert_eq!(config.memory_usage_bytes(), 800); + + assert!(config.try_reserve_memory(300).is_err()); + assert_eq!(config.memory_usage_bytes(), 800); + + config.reset_usage(); + assert_eq!(config.memory_usage_bytes(), 0); + } + + #[test] + fn test_concurrent_memory_operations() { + test_concurrent_memory_budget(); + } + + fn test_concurrent_memory_budget() { + let num_threads = 3; + let max_memory = 10000; + let operations_per_thread = 100; + + let budget = Arc::new(test_budget(max_memory)); + let barrier = Arc::new(Barrier::new(num_threads)); + + let mut thread_handles = vec![]; + + for _ in 0..num_threads { + let budget_clone = budget.clone(); + let barrier_clone = barrier.clone(); + + let handle = thread::spawn(move || { + let mut successful_reservations = Vec::new(); + + barrier_clone.wait(); + + for i in 0..operations_per_thread { + let reserve_size = 10 + (i % 20) * 5; // 10 to 105 bytes + if budget_clone.try_reserve_memory(reserve_size).is_ok() { + successful_reservations.push(reserve_size); + } + + if i.is_multiple_of(5) && !successful_reservations.is_empty() { + let idx = i % successful_reservations.len(); + let old_size = successful_reservations[idx]; + let new_size = if i.is_multiple_of(2) { + old_size + 5 // Grow + } else { + old_size.saturating_sub(5) // Shrink + }; + + if budget_clone + .try_update_memory_usage(old_size, new_size) + .is_ok() + { + successful_reservations[idx] = new_size; + } + } + } + successful_reservations + }); + + thread_handles.push(handle); + } + + let mut expected_memory_usage = 0; + for handle in thread_handles { + let reservations = handle.join().unwrap(); + for size in reservations { + expected_memory_usage += size; + } + } + + assert_eq!(budget.memory_usage_bytes(), expected_memory_usage); + assert!(budget.memory_usage_bytes() <= max_memory); + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/builders.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/builders.rs new file mode 100644 index 0000000000000..4d0570b879003 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/builders.rs @@ -0,0 +1,391 @@ +use std::future::{IntoFuture, Ready}; + +use arrow::array::{ + Array, ArrayData, ArrayRef, BinaryViewArray, BooleanArray, StringViewArray, make_array, +}; +use arrow::buffer::BooleanBuffer; + +use super::cached_batch::CacheEntry; +use super::core::{CacheFull, LiquidCache}; +use super::policies::{CachePolicy, SqueezePolicy, TranscodeEvict}; +use super::{EntryID, LiquidExpr, LiquidPolicy}; +use crate::sync::Arc; + +/// Builder for [LiquidCache]. +/// +/// Example: +/// ```rust +/// use liquid_cache::cache::LiquidCacheBuilder; +/// use liquid_cache::cache_policies::LiquidPolicy; +/// +/// let _storage = LiquidCacheBuilder::new() +/// .with_batch_size(8192) +/// .with_max_memory_bytes(1024 * 1024 * 1024) +/// .with_cache_policy(Box::new(LiquidPolicy::new())) +/// .build(); +/// ``` +pub struct LiquidCacheBuilder { + batch_size: usize, + max_memory_bytes: usize, + cache_policy: Box, + squeeze_policy: Box, +} + +impl Default for LiquidCacheBuilder { + fn default() -> Self { + Self::new() + } +} + +/// Default max memory when none is provided: 1 GiB. +const DEFAULT_MAX_MEMORY_BYTES: usize = 1 << 30; + +impl LiquidCacheBuilder { + /// Create a new instance of [LiquidCacheBuilder]. + pub fn new() -> Self { + Self { + batch_size: 8192, + max_memory_bytes: DEFAULT_MAX_MEMORY_BYTES, + cache_policy: Box::new(LiquidPolicy::new()), + squeeze_policy: Box::new(TranscodeEvict), + } + } + + /// Set the batch size for the cache. + /// Default is 8192. + pub fn with_batch_size(mut self, batch_size: usize) -> Self { + self.batch_size = batch_size; + self + } + + /// Set the max memory bytes for the cache. + /// Default is 1 GiB. + pub fn with_max_memory_bytes(mut self, max_memory_bytes: usize) -> Self { + self.max_memory_bytes = max_memory_bytes; + self + } + + /// Set the cache policy for the cache. + /// Default is [LiquidPolicy]. + pub fn with_cache_policy(mut self, policy: Box) -> Self { + self.cache_policy = policy; + self + } + + /// Set the squeeze policy for the cache. + /// Default is [TranscodeEvict]. + pub fn with_squeeze_policy(mut self, policy: Box) -> Self { + self.squeeze_policy = policy; + self + } + + /// Build the cache storage. + /// + /// The cache storage is wrapped in an [Arc] to allow for concurrent access. + pub fn build(self) -> Arc { + Arc::new(LiquidCache::new( + self.batch_size, + self.max_memory_bytes, + self.squeeze_policy, + self.cache_policy, + )) + } +} + +/// Builder returned by [`LiquidCache::insert`] for configuring cache writes. +#[derive(Debug)] +pub struct Insert<'a> { + pub(super) storage: &'a LiquidCache, + pub(super) entry_id: EntryID, + pub(super) batch: ArrayRef, + pub(super) skip_gc: bool, +} + +impl<'a> Insert<'a> { + pub(super) fn new(storage: &'a LiquidCache, entry_id: EntryID, batch: ArrayRef) -> Self { + Self { + storage, + entry_id, + batch, + skip_gc: false, + } + } + + /// Skip garbage collection of view arrays. + pub fn with_skip_gc(mut self) -> Self { + self.skip_gc = true; + self + } + + /// Insert the batch into the cache. + pub fn execute(self) -> Result<(), CacheFull> { + let batch = if self.skip_gc { + self.batch.clone() + } else { + maybe_gc_view_arrays(&self.batch).unwrap_or_else(|| self.batch.clone()) + }; + let batch = CacheEntry::memory_arrow(batch); + self.storage.insert_inner(self.entry_id, batch) + } +} + +impl<'a> IntoFuture for Insert<'a> { + type Output = Result<(), CacheFull>; + type IntoFuture = Ready; + + fn into_future(self) -> Self::IntoFuture { + std::future::ready(self.execute()) + } +} + +/// Builder returned by [`LiquidCache::get`] for configuring cache reads. +#[derive(Debug)] +pub struct Get<'a> { + pub(super) storage: &'a LiquidCache, + pub(super) entry_id: &'a EntryID, + pub(super) selection: Option<&'a BooleanBuffer>, +} + +impl<'a> Get<'a> { + pub(super) fn new(storage: &'a LiquidCache, entry_id: &'a EntryID) -> Self { + Self { + storage, + entry_id, + selection: None, + } + } + + /// Attach a selection bitmap used to filter rows prior to materialization. + pub fn with_selection(mut self, selection: &'a BooleanBuffer) -> Self { + self.selection = Some(selection); + self + } + + /// Materialize the cached array as [`ArrayRef`]. + pub fn read(self) -> Option { + self.storage.observer().on_get(self.selection.is_some()); + self.storage.read_arrow_array(self.entry_id, self.selection) + } +} + +impl<'a> IntoFuture for Get<'a> { + type Output = Option; + type IntoFuture = Ready; + + fn into_future(self) -> Self::IntoFuture { + std::future::ready(self.read()) + } +} + +/// Recursively garbage collects view arrays (BinaryView/StringView) within an array tree. +fn maybe_gc_view_arrays(array: &ArrayRef) -> Option { + if let Some(binary_view) = array.as_any().downcast_ref::() { + return Some(Arc::new(binary_view.gc())); + } + if let Some(utf8_view) = array.as_any().downcast_ref::() { + return Some(Arc::new(utf8_view.gc())); + } + + let data = array.to_data(); + if data.child_data().is_empty() { + return None; + } + + let mut changed = false; + let mut children: Vec = Vec::with_capacity(data.child_data().len()); + for child in data.child_data() { + let child_array = make_array(child.clone()); + if let Some(gc_child) = maybe_gc_view_arrays(&child_array) { + changed = true; + children.push(gc_child.to_data()); + } else { + children.push(child.clone()); + } + } + + if !changed { + return None; + } + + let new_data = data.into_builder().child_data(children).build().ok()?; + Some(make_array(new_data)) +} + +/// Builder for predicate evaluation on cached data. +#[derive(Debug)] +pub struct EvaluatePredicate<'a> { + pub(super) storage: &'a LiquidCache, + pub(super) entry_id: &'a EntryID, + pub(super) predicate: &'a LiquidExpr, + pub(super) selection: Option<&'a BooleanBuffer>, +} + +impl<'a> EvaluatePredicate<'a> { + pub(super) fn new( + storage: &'a LiquidCache, + entry_id: &'a EntryID, + predicate: &'a LiquidExpr, + ) -> Self { + Self { + storage, + entry_id, + predicate, + selection: None, + } + } + + /// Attach a selection bitmap used to pre-filter rows before predicate evaluation. + pub fn with_selection(mut self, selection: &'a BooleanBuffer) -> Self { + self.selection = Some(selection); + self + } + + /// Evaluate the predicate against the cached data. + pub fn read(self) -> Option { + self.storage + .eval_predicate_internal(self.entry_id, self.selection, self.predicate) + } +} + +impl<'a> IntoFuture for EvaluatePredicate<'a> { + type Output = Option; + type IntoFuture = Ready; + + fn into_future(self) -> Self::IntoFuture { + std::future::ready(self.read()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use arrow::array::{AsArray, StructArray}; + use arrow_schema::{DataType, Field, Fields}; + + #[test] + fn insert_gcs_view_arrays_recursively() { + // Build view arrays then slice to create non-zero offsets (and larger backing buffers). + let bin = Arc::new(BinaryViewArray::from(vec![ + Some(b"long_prefix_m0" as &[u8]), + Some(b"m1"), + ])) as ArrayRef; + let str_view = Arc::new(StringViewArray::from(vec![ + Some("long_prefix_s0"), + Some("s1"), + ])) as ArrayRef; + let nested_metadata = Arc::new(BinaryViewArray::from(vec![ + Some(b"meta0" as &[u8]), + Some(b"meta1"), + ])) as ArrayRef; + let nested_value = Arc::new(BinaryViewArray::from(vec![ + Some(b"value0" as &[u8]), + Some(b"value1"), + ])) as ArrayRef; + + // Slice to keep only the second element so buffers still reference unused bytes. + let bin_slice = bin.slice(1, 1); + let str_slice = str_view.slice(1, 1); + let nested_metadata_slice = nested_metadata.slice(1, 1); + let nested_value_slice = nested_value.slice(1, 1); + + // Nested struct: metadata (BinaryView), value (BinaryView), and a typed string view. + let nested_typed_fields = Fields::from(vec![Arc::new(Field::new( + "typed_str", + DataType::Utf8View, + true, + ))]); + let nested_struct_fields = Fields::from(vec![ + Arc::new(Field::new("metadata", DataType::BinaryView, true)), + Arc::new(Field::new("value", DataType::BinaryView, true)), + Arc::new(Field::new( + "typed_value", + DataType::Struct(nested_typed_fields.clone()), + true, + )), + ]); + let nested_struct = Arc::new(StructArray::new( + nested_struct_fields.clone(), + vec![ + nested_metadata_slice.clone(), + nested_value_slice.clone(), + Arc::new(StructArray::new( + nested_typed_fields.clone(), + vec![str_slice.clone()], + None, + )) as ArrayRef, + ], + None, + )); + + let root_fields = Fields::from(vec![ + Arc::new(Field::new("bin_view", DataType::BinaryView, true)), + Arc::new(Field::new("str_view", DataType::Utf8View, true)), + Arc::new(Field::new( + "nested", + DataType::Struct(nested_struct_fields.clone()), + true, + )), + ]); + let root = Arc::new(StructArray::new( + root_fields, + vec![ + bin_slice.clone(), + str_slice.clone(), + nested_struct.clone() as ArrayRef, + ], + None, + )) as ArrayRef; + + let pre_size = root.get_array_memory_size(); + + let cache = LiquidCacheBuilder::new().build(); + let entry_id = EntryID::from(123usize); + cache.insert(entry_id, root.clone()).execute().unwrap(); + + let stored = cache.get(&entry_id).read().expect("array present"); + let post_size = stored.get_array_memory_size(); + + // GC should have compacted the view arrays, reducing memory footprint. + assert!(post_size < pre_size, "expected gc to reduce memory usage"); + + // Validate values are preserved. + let struct_out = stored + .as_any() + .downcast_ref::() + .expect("struct array"); + + assert_eq!(struct_out.len(), 1); + + let bin_out = struct_out + .column_by_name("bin_view") + .unwrap() + .as_binary_view(); + assert_eq!(bin_out.value(0), b"m1"); + + let str_out = struct_out + .column_by_name("str_view") + .unwrap() + .as_string_view(); + assert_eq!(str_out.value(0), "s1"); + + let nested_out = struct_out.column_by_name("nested").unwrap().as_struct(); + let meta_out = nested_out + .column_by_name("metadata") + .unwrap() + .as_binary_view(); + assert_eq!(meta_out.value(0), b"meta1"); + + let val_out = nested_out.column_by_name("value").unwrap().as_binary_view(); + assert_eq!(val_out.value(0), b"value1"); + + let typed_out = nested_out + .column_by_name("typed_value") + .unwrap() + .as_struct(); + let typed_str_out = typed_out + .column_by_name("typed_str") + .unwrap() + .as_string_view(); + assert_eq!(typed_str_out.value(0), "s1"); + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/cached_batch.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/cached_batch.rs new file mode 100644 index 0000000000000..053edf0eb7206 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/cached_batch.rs @@ -0,0 +1,71 @@ +//! Cached batch types. + +use std::{fmt::Display, sync::Arc}; + +use arrow::array::ArrayRef; + +use crate::liquid_array::LiquidArrayRef; + +/// A cached entry storing data in various formats. +#[derive(Debug, Clone)] +pub enum CacheEntry { + /// Cached batch in memory as Arrow array. + MemoryArrow(ArrayRef), + /// Cached batch in memory as liquid array. + MemoryLiquid(LiquidArrayRef), +} + +impl CacheEntry { + /// Construct a cached batch stored as an in-memory Arrow array. + pub fn memory_arrow(array: ArrayRef) -> Self { + Self::MemoryArrow(array) + } + + /// Construct a cached batch stored as an in-memory Liquid array. + pub fn memory_liquid(array: LiquidArrayRef) -> Self { + Self::MemoryLiquid(array) + } + + /// Memory usage reported by the underlying representation. + pub fn memory_usage_bytes(&self) -> usize { + match self { + Self::MemoryArrow(array) => array.get_array_memory_size(), + Self::MemoryLiquid(array) => array.get_array_memory_size(), + } + } + + /// Reference count (if any) of the backing storage. + pub fn reference_count(&self) -> usize { + match self { + Self::MemoryArrow(array) => Arc::strong_count(array), + Self::MemoryLiquid(array) => Arc::strong_count(array), + } + } +} + +impl Display for CacheEntry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MemoryArrow(_) => write!(f, "MemoryArrow"), + Self::MemoryLiquid(_) => write!(f, "MemoryLiquid"), + } + } +} + +/// The type of the cached batch. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +pub enum CachedBatchType { + /// Cached batch in memory as Arrow array. + MemoryArrow, + /// Cached batch in memory as liquid array. + MemoryLiquid, +} + +impl From<&CacheEntry> for CachedBatchType { + fn from(batch: &CacheEntry) -> Self { + match batch { + CacheEntry::MemoryArrow(_) => Self::MemoryArrow, + CacheEntry::MemoryLiquid(_) => Self::MemoryLiquid, + } + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/core.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/core.rs new file mode 100644 index 0000000000000..9099ea0dd9394 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/core.rs @@ -0,0 +1,681 @@ +use arrow::array::cast::AsArray; +use arrow::array::{ArrayRef, BooleanArray}; +use arrow::buffer::BooleanBuffer; +use arrow::record_batch::RecordBatch; +use arrow_schema::{Field, Schema}; + +use super::{ + budget::BudgetAccounting, + builders::{EvaluatePredicate, Get, Insert}, + cached_batch::{CacheEntry, CachedBatchType}, + observer::Observer, + policies::CachePolicy, + utils::CacheConfig, +}; +use crate::cache::CacheStats; +use crate::cache::policies::{SqueezeOutcome, SqueezePolicy}; +use crate::cache::{LiquidExpr, index::ArtIndex, utils::EntryID}; +use crate::sync::Arc; + +/// The cache could not free enough memory to admit an entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CacheFull; + +/// Cache storage for liquid cache. +/// +/// Example: +/// ```rust +/// use liquid_cache::cache::{LiquidCacheBuilder, EntryID}; +/// use arrow::array::UInt64Array; +/// use std::sync::Arc; +/// +/// let storage = LiquidCacheBuilder::new() +/// .with_max_memory_bytes(1024 * 1024) +/// .build(); +/// +/// let entry_id = EntryID::from(0); +/// let arrow_array = Arc::new(UInt64Array::from_iter_values(0..32)); +/// storage.insert(entry_id, arrow_array.clone()).execute().unwrap(); +/// +/// // Get the arrow array back +/// let retrieved = storage.get(&entry_id).read().unwrap(); +/// assert_eq!(retrieved.as_ref(), arrow_array.as_ref()); +/// ``` +#[derive(Debug)] +pub struct LiquidCache { + index: ArtIndex, + config: CacheConfig, + budget: BudgetAccounting, + cache_policy: Box, + squeeze_policy: Box, + observer: Arc, +} + +impl LiquidCache { + /// Return current cache statistics: counts and resource usage. + pub fn stats(&self) -> CacheStats { + // Count entries by format + let total_entries = self.index.entry_count(); + + let mut memory_arrow_entries = 0usize; + let mut memory_liquid_entries = 0usize; + + let mut memory_arrow_bytes = 0usize; + let mut memory_liquid_bytes = 0usize; + + self.index.for_each(|_, batch| match batch { + CacheEntry::MemoryArrow(array) => { + memory_arrow_entries += 1; + memory_arrow_bytes += array.get_array_memory_size(); + } + CacheEntry::MemoryLiquid(array) => { + memory_liquid_entries += 1; + memory_liquid_bytes += array.get_array_memory_size(); + } + }); + + let memory_usage_bytes = self.budget.memory_usage_bytes(); + let runtime = self.observer.runtime_snapshot(); + + CacheStats { + total_entries, + memory_arrow_entries, + memory_liquid_entries, + memory_arrow_bytes, + memory_liquid_bytes, + memory_usage_bytes, + max_memory_bytes: self.budget.max_memory_bytes(), + runtime, + } + } + + /// Insert a batch into the cache. + pub fn insert<'a>(&'a self, entry_id: EntryID, batch_to_cache: ArrayRef) -> Insert<'a> { + Insert::new(self, entry_id, batch_to_cache) + } + + /// Create a [`Get`] builder for the provided entry. + pub fn get<'a>(&'a self, entry_id: &'a EntryID) -> Get<'a> { + Get::new(self, entry_id) + } + + /// Create an [`EvaluatePredicate`] builder for evaluating predicates on cached data. + pub fn eval_predicate<'a>( + &'a self, + entry_id: &'a EntryID, + predicate: &'a LiquidExpr, + ) -> EvaluatePredicate<'a> { + EvaluatePredicate::new(self, entry_id, predicate) + } + + /// Try to read a liquid array from the cache. + /// Returns None if the cached data is not in liquid format. + pub fn try_read_liquid( + &self, + entry_id: &EntryID, + ) -> Option { + self.observer.on_try_read_liquid(); + let batch = self.index.get(entry_id)?; + self.cache_policy + .notify_access(entry_id, CachedBatchType::from(batch.as_ref())); + + match batch.as_ref() { + CacheEntry::MemoryLiquid(array) => Some(array.clone()), + CacheEntry::MemoryArrow(_) => None, + } + } + + /// Iterate over all entries in the cache. + /// No guarantees are made about the order of the entries. + /// Isolation level: read-committed + pub fn for_each_entry(&self, mut f: impl FnMut(&EntryID, &CacheEntry)) { + self.index.for_each(&mut f); + } + + /// Reset the cache. + pub fn reset(&self) { + self.index.reset(); + self.budget.reset_usage(); + } + + /// Check if a batch is cached. + pub fn is_cached(&self, entry_id: &EntryID) -> bool { + self.index.is_cached(entry_id) + } + + /// Get the config of the cache. + pub fn config(&self) -> &CacheConfig { + &self.config + } + + /// Get the budget of the cache. + pub fn budget(&self) -> &BudgetAccounting { + &self.budget + } + + /// Access the cache observer (runtime stats). + pub fn observer(&self) -> &Observer { + &self.observer + } +} + +impl LiquidCache { + /// Insert a batch into the cache, it will run cache replacement policy until the batch is inserted. + pub(crate) fn insert_inner( + &self, + entry_id: EntryID, + mut batch_to_cache: CacheEntry, + ) -> Result<(), CacheFull> { + loop { + let Err(not_inserted) = self.try_insert(entry_id, batch_to_cache) else { + return Ok(()); + }; + + let victims = self.cache_policy.find_memory_victim(8); + if victims.is_empty() { + // No advice, because the cache is already empty: + // the entry to be inserted does not fit in memory at all. + return Err(CacheFull); + } + self.squeeze_victims(victims); + + batch_to_cache = not_inserted; + } + } + + /// Create a new instance of CacheStorage. + pub(crate) fn new( + batch_size: usize, + max_memory_bytes: usize, + squeeze_policy: Box, + cache_policy: Box, + ) -> Self { + let config = CacheConfig::new(batch_size, max_memory_bytes); + let observer = Arc::new(Observer::new()); + Self { + index: ArtIndex::new(), + budget: BudgetAccounting::new(config.max_memory_bytes()), + config, + cache_policy, + squeeze_policy, + observer, + } + } + + fn try_insert(&self, entry_id: EntryID, to_insert: CacheEntry) -> Result<(), CacheEntry> { + let new_memory_size = to_insert.memory_usage_bytes(); + let cached_batch_type = if let Some(entry) = self.index.get(&entry_id) { + let old_memory_size = entry.memory_usage_bytes(); + if self + .budget + .try_update_memory_usage(old_memory_size, new_memory_size) + .is_err() + { + return Err(to_insert); + } + let batch_type = CachedBatchType::from(&to_insert); + self.index.insert(&entry_id, to_insert); + batch_type + } else { + if self.budget.try_reserve_memory(new_memory_size).is_err() { + return Err(to_insert); + } + let batch_type = CachedBatchType::from(&to_insert); + self.index.insert(&entry_id, to_insert); + batch_type + }; + + self.cache_policy + .notify_insert(&entry_id, cached_batch_type); + + Ok(()) + } + + fn remove_memory_entry(&self, entry_id: EntryID) { + let Some(removed) = self.index.remove(&entry_id) else { + return; + }; + self.budget + .try_update_memory_usage(removed.memory_usage_bytes(), 0) + .expect("memory release cannot fail"); + self.cache_policy.notify_remove(&entry_id); + self.observer.on_memory_eviction(); + } + + /// Get the index of the cache. + #[cfg(test)] + pub(crate) fn index(&self) -> &ArtIndex { + &self.index + } + + fn squeeze_victims(&self, victims: Vec) { + for victim in victims { + self.squeeze_victim_inner(victim); + } + } + + fn squeeze_victim_inner(&self, to_squeeze: EntryID) { + let Some(mut to_squeeze_batch) = self.index.get(&to_squeeze) else { + return; + }; + + loop { + let outcome = self.squeeze_policy.squeeze(to_squeeze_batch.as_ref()); + + match outcome { + SqueezeOutcome::Replace(new_batch) => { + match self.try_insert(to_squeeze, new_batch) { + Ok(()) => { + self.observer.on_transcode(); + break; + } + Err(batch) => { + // The replacement did not fit either; squeeze it further. + // The squeeze policies guarantee this converges to `Remove`. + to_squeeze_batch = Arc::new(batch); + } + } + } + SqueezeOutcome::Remove => { + self.remove_memory_entry(to_squeeze); + break; + } + } + } + } + + pub(crate) fn read_arrow_array( + &self, + entry_id: &EntryID, + selection: Option<&BooleanBuffer>, + ) -> Option { + use arrow::array::BooleanArray; + + let batch = self.index.get(entry_id)?; + self.cache_policy + .notify_access(entry_id, CachedBatchType::from(batch.as_ref())); + + match batch.as_ref() { + CacheEntry::MemoryArrow(array) => match selection { + Some(selection) => { + let selection_array = BooleanArray::new(selection.clone(), None); + arrow::compute::filter(array, &selection_array).ok() + } + None => Some(array.clone()), + }, + CacheEntry::MemoryLiquid(array) => match selection { + Some(selection) => Some(array.filter(selection)), + None => Some(array.to_arrow_array()), + }, + } + } + + pub(crate) fn eval_predicate_internal( + &self, + entry_id: &EntryID, + selection_opt: Option<&BooleanBuffer>, + predicate: &LiquidExpr, + ) -> Option { + use arrow::array::BooleanArray; + + self.observer.on_eval_predicate(); + let batch = self.index.get(entry_id)?; + self.cache_policy + .notify_access(entry_id, CachedBatchType::from(batch.as_ref())); + + match batch.as_ref() { + CacheEntry::MemoryArrow(array) => { + let mut owned = None; + let selection = selection_opt.unwrap_or_else(|| { + owned = Some(BooleanBuffer::new_set(array.len())); + owned.as_ref().unwrap() + }); + let selection_array = BooleanArray::new(selection.clone(), None); + let filtered = arrow::compute::filter(array, &selection_array) + .expect("selection must match array length"); + Some(self.eval_predicate_on_array(filtered, predicate)) + } + CacheEntry::MemoryLiquid(array) => { + let mut owned = None; + let selection = selection_opt.unwrap_or_else(|| { + owned = Some(BooleanBuffer::new_set(array.len())); + owned.as_ref().unwrap() + }); + Some(array.try_eval_predicate(predicate, selection)) + } + } + } + + fn eval_predicate_on_array(&self, array: ArrayRef, predicate: &LiquidExpr) -> BooleanArray { + let schema = Arc::new(Schema::new(vec![Field::new( + "liquid_predicate_col", + array.data_type().clone(), + true, + )])); + let record_batch = + RecordBatch::try_new(schema, vec![array]).expect("single-column predicate batch"); + let result = predicate + .physical_expr() + .evaluate(&record_batch) + .expect("validated LiquidExpr must evaluate"); + let boolean_array = result + .into_array(record_batch.num_rows()) + .expect("predicate output must be an array"); + boolean_array.as_boolean().clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cache::{ + CacheEntry, CachePolicy, LiquidCacheBuilder, LiquidPolicy, transcode_liquid_inner, + utils::{create_cache_store, create_test_array, create_test_arrow_array}, + }; + use crate::sync::thread; + use arrow::array::{Array, ArrayRef, BooleanArray, Int32Array}; + use datafusion_common::ScalarValue; + use datafusion_expr_common::operator::Operator as DFOperator; + use datafusion_physical_expr::PhysicalExpr; + use datafusion_physical_expr::expressions::{BinaryExpr, Column, Literal}; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + // Unified advice type for more concise testing + #[derive(Debug)] + struct TestPolicy { + target_id: Option, + advice_count: AtomicUsize, + } + + impl TestPolicy { + fn new(target_id: Option) -> Self { + Self { + target_id, + advice_count: AtomicUsize::new(0), + } + } + } + + impl CachePolicy for TestPolicy { + fn find_memory_victim(&self, _cnt: usize) -> Vec { + self.advice_count.fetch_add(1, Ordering::SeqCst); + let id_to_use = self.target_id.unwrap(); + vec![id_to_use] + } + } + + #[test] + fn test_basic_cache_operations() { + // Test basic insert, get, and size tracking in one test + let budget_size = 10 * 1024; + let store = create_cache_store(budget_size, Box::new(LiquidPolicy::new())); + + // 1. Initial budget should be empty + assert_eq!(store.budget.memory_usage_bytes(), 0); + + // 2. Insert and verify first entry + let entry_id1: EntryID = EntryID::from(1); + let array1 = create_test_array(100); + let size1 = array1.memory_usage_bytes(); + store.insert_inner(entry_id1, array1).unwrap(); + + // Verify budget usage and data correctness + assert_eq!(store.budget.memory_usage_bytes(), size1); + let retrieved1 = store.index().get(&entry_id1).unwrap(); + match retrieved1.as_ref() { + CacheEntry::MemoryArrow(arr) => assert_eq!(arr.len(), 100), + _ => panic!("Expected ArrowMemory"), + } + + let entry_id2: EntryID = EntryID::from(2); + let array2 = create_test_array(200); + let size2 = array2.memory_usage_bytes(); + store.insert_inner(entry_id2, array2).unwrap(); + + assert_eq!(store.budget.memory_usage_bytes(), size1 + size2); + + let array3 = create_test_array(150); + let size3 = array3.memory_usage_bytes(); + store.insert_inner(entry_id1, array3).unwrap(); + + assert_eq!(store.budget.memory_usage_bytes(), size3 + size2); + assert!(store.index().get(&EntryID::from(999)).is_none()); + } + + #[test] + fn test_cache_advice_strategies() { + // Under memory pressure, the advised victim is transcoded to liquid. + let entry_id1 = EntryID::from(1); + let entry_id2 = EntryID::from(2); + + let advisor = TestPolicy::new(Some(entry_id1)); + let store = create_cache_store(8000, Box::new(advisor)); // Small budget to force advice + + store + .insert_inner(entry_id1, create_test_array(800)) + .unwrap(); + match store.index().get(&entry_id1).unwrap().as_ref() { + CacheEntry::MemoryArrow(_) => {} + other => panic!("Expected ArrowMemory, got {other:?}"), + } + + store + .insert_inner(entry_id2, create_test_array(800)) + .unwrap(); + match store.index().get(&entry_id1).unwrap().as_ref() { + CacheEntry::MemoryLiquid(_) => {} + other => panic!("Expected LiquidMemory after eviction, got {other:?}"), + } + } + + #[test] + fn test_concurrent_cache_operations() { + concurrent_cache_operations(); + } + + fn concurrent_cache_operations() { + let num_threads = 3; + let ops_per_thread = 50; + + // Large enough that no entry is evicted: with no disk tier, evicted + // entries are gone, so the retrievability invariant below requires + // every entry to fit in memory. + let budget_size = + num_threads * ops_per_thread * create_test_arrow_array(100).get_array_memory_size() * 2; + let store = create_cache_store(budget_size, Box::new(LiquidPolicy::new())); + + let mut handles = vec![]; + for thread_id in 0..num_threads { + let store = store.clone(); + handles.push(thread::spawn(move || { + for i in 0..ops_per_thread { + let unique_id = thread_id * ops_per_thread + i; + let entry_id: EntryID = EntryID::from(unique_id); + let array = create_test_arrow_array(100); + store.insert(entry_id, array).execute().unwrap(); + } + })); + } + for handle in handles { + handle.join().unwrap(); + } + + // Invariant 1: Every previously inserted entry can be retrieved + for thread_id in 0..num_threads { + for i in 0..ops_per_thread { + let unique_id = thread_id * ops_per_thread + i; + let entry_id: EntryID = EntryID::from(unique_id); + assert!(store.index().get(&entry_id).is_some()); + } + } + + // Invariant 2: Number of entries matches number of insertions + assert_eq!(store.index().keys().len(), num_threads * ops_per_thread); + } + + #[test] + fn test_cache_stats_memory_usage() { + let storage = LiquidCacheBuilder::new() + .with_max_memory_bytes(10 * 1024 * 1024) + .build(); + + // Insert two small batches + let arr1: ArrayRef = Arc::new(Int32Array::from_iter_values(0..64)); + let arr2: ArrayRef = Arc::new(Int32Array::from_iter_values(0..128)); + storage + .insert(EntryID::from(1usize), arr1) + .execute() + .unwrap(); + storage + .insert(EntryID::from(2usize), arr2) + .execute() + .unwrap(); + + // Stats after insert: 2 entries, memory usage > 0 + let s = storage.stats(); + assert_eq!(s.total_entries, 2); + assert_eq!(s.memory_arrow_entries, 2); + assert_eq!(s.memory_liquid_entries, 0); + assert!(s.memory_usage_bytes > 0); + assert_eq!(s.max_memory_bytes, 10 * 1024 * 1024); + } + + #[test] + fn insert_returns_cache_full_when_memory_is_saturated() { + let cache = LiquidCacheBuilder::new().with_max_memory_bytes(0).build(); + let array: ArrayRef = Arc::new(Int32Array::from_iter_values(0..16)); + + let err = cache.insert(EntryID::from(900usize), array).execute(); + + assert_eq!(err, Err(CacheFull)); + assert!(!cache.is_cached(&EntryID::from(900usize))); + } + + #[test] + fn eviction_under_memory_pressure_keeps_newest_entries() { + // Budget fits roughly one arrow entry; older entries must be transcoded or evicted. + let first_array: ArrayRef = Arc::new(Int32Array::from_iter_values(0..1024)); + let entry_size = first_array.get_array_memory_size(); + let cache = LiquidCacheBuilder::new() + .with_max_memory_bytes(entry_size + entry_size / 2) + .with_cache_policy(Box::new(LiquidPolicy::new())) + .build(); + + let first = EntryID::from(910usize); + let second = EntryID::from(911usize); + cache.insert(first, first_array).execute().unwrap(); + assert!(cache.is_cached(&first)); + + let second_array: ArrayRef = Arc::new(Int32Array::from_iter_values(1024..2048)); + cache.insert(second, second_array).execute().unwrap(); + assert!(cache.is_cached(&second)); + assert!(cache.budget().memory_usage_bytes() <= cache.budget().max_memory_bytes()); + } + + #[test] + fn eviction_releases_budget() { + let cache = LiquidCacheBuilder::new() + .with_max_memory_bytes(1 << 20) + .build(); + let entry = EntryID::from(914usize); + let array: ArrayRef = Arc::new(Int32Array::from_iter_values(0..16)); + cache.insert(entry, array).execute().unwrap(); + let before = cache.stats().memory_usage_bytes; + assert!(before > 0); + + cache.remove_memory_entry(entry); + + assert_eq!(cache.stats().memory_usage_bytes, 0); + assert!(!cache.is_cached(&entry)); + } + + #[test] + fn get_with_selection_filters_rows() { + let cache = LiquidCacheBuilder::new() + .with_max_memory_bytes(1 << 20) + .build(); + let entry = EntryID::from(915usize); + let array: ArrayRef = Arc::new(Int32Array::from(vec![1, 2, 3, 4])); + cache.insert(entry, array).execute().unwrap(); + + let selection = arrow::buffer::BooleanBuffer::from(vec![true, false, true, false]); + let result = cache + .get(&entry) + .with_selection(&selection) + .read() + .expect("present"); + let expected = Int32Array::from(vec![1, 3]); + assert_eq!(result.as_ref(), &expected as &dyn Array); + } + + #[test] + fn eval_predicate_on_arrow_and_liquid_entries() { + let cache = LiquidCacheBuilder::new() + .with_max_memory_bytes(1 << 20) + .build(); + let arrow_entry = EntryID::from(916usize); + let liquid_entry = EntryID::from(917usize); + + let array: ArrayRef = Arc::new(Int32Array::from(vec![1, 2, 3, 4])); + cache.insert(arrow_entry, array.clone()).execute().unwrap(); + + let liquid = transcode_liquid_inner(&array).unwrap(); + cache + .insert_inner(liquid_entry, CacheEntry::memory_liquid(liquid)) + .unwrap(); + + let expr: Arc = Arc::new(BinaryExpr::new( + Arc::new(Column::new("liquid_predicate_col", 0)), + DFOperator::Gt, + Arc::new(Literal::new(ScalarValue::Int32(Some(2)))), + )); + let predicate = LiquidExpr::new_unchecked(expr); + let expected = BooleanArray::from(vec![false, false, true, true]); + + for entry in [arrow_entry, liquid_entry] { + let got = cache + .eval_predicate(&entry, &predicate) + .read() + .expect("entry present"); + assert_eq!(got, expected); + } + } + + #[test] + fn try_read_liquid_returns_only_liquid_entries() { + let cache = LiquidCacheBuilder::new() + .with_max_memory_bytes(1 << 20) + .build(); + let arrow_entry = EntryID::from(918usize); + let liquid_entry = EntryID::from(919usize); + + let array: ArrayRef = Arc::new(Int32Array::from(vec![1, 2, 3])); + cache.insert(arrow_entry, array.clone()).execute().unwrap(); + let liquid = transcode_liquid_inner(&array).unwrap(); + cache + .insert_inner(liquid_entry, CacheEntry::memory_liquid(liquid)) + .unwrap(); + + assert!(cache.try_read_liquid(&arrow_entry).is_none()); + let read = cache.try_read_liquid(&liquid_entry).expect("liquid entry"); + assert_eq!(read.to_arrow_array().as_ref(), array.as_ref()); + } + + #[test] + fn reset_clears_entries_and_budget() { + let cache = LiquidCacheBuilder::new() + .with_max_memory_bytes(1 << 20) + .build(); + let entry = EntryID::from(920usize); + let array: ArrayRef = Arc::new(Int32Array::from(vec![1, 2, 3])); + cache.insert(entry, array).execute().unwrap(); + assert!(cache.is_cached(&entry)); + + cache.reset(); + + assert!(!cache.is_cached(&entry)); + assert_eq!(cache.budget().memory_usage_bytes(), 0); + assert_eq!(cache.stats().total_entries, 0); + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/index.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/index.rs new file mode 100644 index 0000000000000..a25fec751ac6c --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/index.rs @@ -0,0 +1,146 @@ +use congee::CongeeArc; +use std::{ + fmt::{Debug, Formatter}, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, +}; + +use crate::cache::{cached_batch::CacheEntry, utils::EntryID}; + +pub(crate) struct ArtIndex { + art: CongeeArc, + entry_count: AtomicUsize, +} + +impl Debug for ArtIndex { + fn fmt(&self, _f: &mut Formatter<'_>) -> std::fmt::Result { + Ok(()) + } +} + +impl ArtIndex { + pub(crate) fn new() -> Self { + let art: CongeeArc = CongeeArc::new(); + Self { + art, + entry_count: AtomicUsize::new(0), + } + } + + pub(crate) fn get(&self, entry_id: &EntryID) -> Option> { + let guard = self.art.pin(); + let batch = self.art.get(*entry_id, &guard)?; + Some(batch) + } + + pub(crate) fn is_cached(&self, entry_id: &EntryID) -> bool { + let guard = self.art.pin(); + self.art.get(*entry_id, &guard).is_some() + } + + pub(crate) fn insert(&self, entry_id: &EntryID, batch: CacheEntry) { + let guard = self.art.pin(); + let existing = self + .art + .insert(*entry_id, Arc::new(batch), &guard) + .expect("Insertion failed"); + if existing.is_none() { + self.entry_count.fetch_add(1, Ordering::Relaxed); + } + } + + pub(crate) fn remove(&self, entry_id: &EntryID) -> Option> { + let guard = self.art.pin(); + let removed = self.art.remove(*entry_id, &guard); + if removed.is_some() { + self.entry_count.fetch_sub(1, Ordering::Relaxed); + } + removed + } + + pub(crate) fn reset(&self) { + let guard = self.art.pin(); + self.art.keys().into_iter().for_each(|k| { + _ = self.art.remove(k, &guard).unwrap(); + }); + self.entry_count.store(0, Ordering::Relaxed); + } + + pub(crate) fn for_each(&self, mut f: impl FnMut(&EntryID, &CacheEntry)) { + let guard = self.art.pin(); + for id in self.art.keys().into_iter() { + f( + &id, + &self + .art + .get(id, &guard) + .expect("Failed to get value from ART"), + ); + } + } + + #[cfg(test)] + pub(crate) fn keys(&self) -> Vec { + self.art.keys() + } + + pub(crate) fn entry_count(&self) -> usize { + self.entry_count.load(Ordering::Relaxed) + } +} + +#[cfg(test)] +mod tests { + use crate::cache::cached_batch::CacheEntry; + use crate::cache::utils::create_test_array; + + use super::*; + + #[test] + fn test_get_and_is_cached() { + let store = ArtIndex::new(); + let entry_id1: EntryID = EntryID::from(1); + let entry_id2: EntryID = EntryID::from(2); + let array1 = create_test_array(100); + + // Initially, entries should not be cached + assert!(!store.is_cached(&entry_id1)); + assert!(!store.is_cached(&entry_id2)); + assert!(store.get(&entry_id1).is_none()); + + // Insert an entry and verify it's cached + { + store.insert(&entry_id1, array1.clone()); + } + + assert!(store.is_cached(&entry_id1)); + assert!(!store.is_cached(&entry_id2)); + + // Get should return the cached value + match store.get(&entry_id1) { + Some(batch) => match batch.as_ref() { + CacheEntry::MemoryArrow(arr) => assert_eq!(arr.len(), 100), + _ => panic!("Expected ArrowMemory batch"), + }, + None => panic!("Expected ArrowMemory batch"), + } + } + + #[test] + fn test_reset() { + let store = ArtIndex::new(); + let entry_id: EntryID = EntryID::from(1); + let array = create_test_array(100); + + store.insert(&entry_id, array.clone()); + + let entry_id: EntryID = EntryID::from(1); + assert!(store.is_cached(&entry_id)); + + store.reset(); + let entry_id: EntryID = EntryID::from(1); + assert!(!store.is_cached(&entry_id)); + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/liquid_expr.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/liquid_expr.rs new file mode 100644 index 0000000000000..0c808c198a33a --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/liquid_expr.rs @@ -0,0 +1,196 @@ +use arrow_schema::DataType; +use datafusion_common::ScalarValue; +use datafusion_expr_common::operator::Operator; +use datafusion_physical_expr::expressions::{ + BinaryExpr, CastExpr, Column, DynamicFilterPhysicalExpr, Literal, TryCastExpr, +}; +use datafusion_physical_expr::{PhysicalExpr, ScalarFunctionExpr}; + +use crate::sync::Arc; +use crate::utils::get_bytes_needle; + +/// A predicate expression validated for LiquidCache predicate evaluation. +#[derive(Clone)] +pub struct LiquidExpr { + expr: Arc, +} + +impl std::fmt::Debug for LiquidExpr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LiquidExpr") + .field("expr", &self.expr.to_string()) + .finish() + } +} + +impl LiquidExpr { + /// Validate and wrap a physical expression for LiquidCache predicate evaluation. + /// + /// Returns `None` when the expression shape or operator is unsupported for the + /// provided column type. + pub fn try_new(expr: Arc, data_type: &DataType) -> Option { + let normalized = unwrap_dynamic_filter(&expr)?; + if supports_expr(&normalized, data_type) { + Some(Self { expr: normalized }) + } else { + None + } + } + + /// Get the underlying validated physical expression. + pub fn physical_expr(&self) -> &Arc { + &self.expr + } + + #[cfg(test)] + pub(crate) fn new_unchecked(expr: Arc) -> Self { + Self { expr } + } +} + +fn unwrap_dynamic_filter(expr: &Arc) -> Option> { + if let Some(dynamic_filter) = expr.downcast_ref::() { + dynamic_filter.current().ok() + } else { + Some(expr.clone()) + } +} + +fn supports_expr(expr: &Arc, data_type: &DataType) -> bool { + if let Some(binary) = expr.downcast_ref::() { + return supports_binary_expr(binary, data_type); + } + + if let Some(literal) = expr.downcast_ref::() { + return matches!(literal.value(), ScalarValue::Boolean(Some(_))) && is_byte_like(data_type); + } + + false +} + +fn supports_binary_expr(binary: &BinaryExpr, data_type: &DataType) -> bool { + let Some(literal) = binary.right().downcast_ref::() else { + return false; + }; + let op = binary.op(); + if is_byte_like(data_type) { + if !is_column_like(binary.left()) { + return false; + } + + match op { + Operator::Eq + | Operator::NotEq + | Operator::Lt + | Operator::LtEq + | Operator::Gt + | Operator::GtEq => get_bytes_needle(literal.value()).is_some(), + _ => false, + } + } else if is_numeric_like(data_type) { + matches!( + op, + Operator::Eq + | Operator::NotEq + | Operator::Lt + | Operator::LtEq + | Operator::Gt + | Operator::GtEq + ) && (is_column_like(binary.left()) || is_to_timestamp_seconds_column(binary.left())) + } else { + false + } +} + +fn is_column_like(expr: &Arc) -> bool { + if expr.downcast_ref::().is_some() { + return true; + } + if let Some(cast_expr) = expr.downcast_ref::() { + return is_column_like(cast_expr.expr()); + } + if let Some(try_cast_expr) = expr.downcast_ref::() { + return is_column_like(try_cast_expr.expr()); + } + false +} + +fn is_to_timestamp_seconds_column(expr: &Arc) -> bool { + if let Some(func) = expr.downcast_ref::() + && func.name() == "to_timestamp_seconds" + && let [arg] = func.args() + { + return is_column_like(arg); + } + false +} + +fn is_byte_like(data_type: &DataType) -> bool { + match data_type { + DataType::Utf8 | DataType::Utf8View | DataType::Binary | DataType::BinaryView => true, + DataType::Dictionary(_, value_type) => is_byte_like(value_type.as_ref()), + _ => false, + } +} + +fn is_numeric_like(data_type: &DataType) -> bool { + matches!( + data_type, + DataType::Int8 + | DataType::Int16 + | DataType::Int32 + | DataType::Int64 + | DataType::UInt8 + | DataType::UInt16 + | DataType::UInt32 + | DataType::UInt64 + | DataType::Float32 + | DataType::Float64 + | DataType::Date32 + | DataType::Date64 + | DataType::Decimal128(_, _) + | DataType::Decimal256(_, _) + ) || matches!(data_type, DataType::Timestamp(_, None)) +} + +#[cfg(test)] +mod tests { + use super::*; + use datafusion_common::ScalarValue; + use datafusion_physical_expr::expressions::{BinaryExpr, Column}; + + #[test] + fn validates_byte_comparison_with_literal() { + let expr: Arc = Arc::new(BinaryExpr::new( + Arc::new(Column::new("c", 0)), + Operator::Eq, + Arc::new(Literal::new(ScalarValue::Utf8(Some("x".to_string())))), + )); + let liquid_expr = LiquidExpr::try_new(expr, &DataType::Utf8); + assert!(liquid_expr.is_some()); + } + + #[test] + fn validates_numeric_comparison() { + let expr: Arc = Arc::new(BinaryExpr::new( + Arc::new(Column::new("c", 0)), + Operator::Gt, + Arc::new(Literal::new(ScalarValue::Int32(Some(42)))), + )); + let liquid_expr = LiquidExpr::try_new(expr, &DataType::Int32); + assert!(liquid_expr.is_some()); + } + + #[test] + fn rejects_unsupported_like_expression() { + use datafusion_physical_expr::expressions::LikeExpr; + let expr: Arc = Arc::new(LikeExpr::new( + false, + false, + Arc::new(Column::new("c", 0)), + Arc::new(Literal::new(ScalarValue::Utf8(Some("%abc%".to_string())))), + )); + let liquid_expr = LiquidExpr::try_new(expr, &DataType::Utf8); + assert!(liquid_expr.is_none()); + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/mod.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/mod.rs new file mode 100644 index 0000000000000..4ae208e5bae1e --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/mod.rs @@ -0,0 +1,35 @@ +//! Cache layer for liquid cache. + +mod budget; +mod builders; +mod cached_batch; +mod core; +mod index; +mod liquid_expr; +mod observer; +pub mod policies; +mod transcode; +mod utils; + +pub use builders::{EvaluatePredicate, Get, Insert, LiquidCacheBuilder}; +pub use cached_batch::{CacheEntry, CachedBatchType}; +pub use core::{CacheFull, LiquidCache}; +pub use liquid_expr::LiquidExpr; +pub use observer::Observer; +pub use observer::{CacheStats, RuntimeStats, RuntimeStatsSnapshot}; +pub use policies::{ + CachePolicy, Evict, LiquidPolicy, LruPolicy, SqueezeOutcome, SqueezePolicy, TranscodeEvict, +}; +pub use transcode::transcode_liquid_inner; +pub use utils::EntryID; + +// Backwards-compatible module paths for existing imports. +/// Legacy path: re-export cache policy types under `cache::cache_policies`. +pub mod cache_policies { + pub use super::policies::cache::*; +} + +/// Legacy path: re-export squeeze policy types under `cache::squeeze_policies`. +pub mod squeeze_policies { + pub use super::policies::squeeze::*; +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/observer/mod.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/observer/mod.rs new file mode 100644 index 0000000000000..07ab4e1c208bd --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/observer/mod.rs @@ -0,0 +1,64 @@ +mod stats; + +pub use stats::{CacheStats, RuntimeStats, RuntimeStatsSnapshot}; + +use stats::{RuntimeStats as RuntimeStatsInner, RuntimeStatsSnapshot as RuntimeStatsSnapshotInner}; + +#[derive(Debug)] +/// Cache-side observer for runtime stats. +pub struct Observer { + runtime: RuntimeStatsInner, +} + +impl Default for Observer { + fn default() -> Self { + Self::new() + } +} + +impl Observer { + /// Create a new observer with all counters reset. + pub fn new() -> Self { + Self { + runtime: RuntimeStatsInner::default(), + } + } + + /// Snapshot runtime counters and reset them to zero. + pub fn runtime_snapshot(&self) -> RuntimeStatsSnapshotInner { + self.runtime.consume_snapshot() + } + + #[inline] + pub(crate) fn on_get(&self, selection: bool) { + self.runtime.incr_get(); + if selection { + self.runtime.incr_get_with_selection(); + } + } + + #[inline] + pub(crate) fn on_try_read_liquid(&self) { + self.runtime.incr_try_read_liquid(); + } + + #[inline] + pub(crate) fn on_eval_predicate(&self) { + self.runtime.incr_eval_predicate(); + } + + #[inline] + pub(crate) fn on_memory_eviction(&self) { + self.runtime.incr_memory_evictions(); + } + + #[inline] + pub(crate) fn on_transcode(&self) { + self.runtime.incr_transcodes(); + } + + /// Access the underlying runtime statistics counters. + pub fn runtime_stats(&self) -> &RuntimeStats { + &self.runtime + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/observer/stats.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/observer/stats.rs new file mode 100644 index 0000000000000..d4b39dfbcbfcf --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/observer/stats.rs @@ -0,0 +1,127 @@ +use std::fmt; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// Macro to define runtime statistics metrics. +/// +/// Usage: +/// ```ignore +/// define_runtime_stats! { +/// (field_name, "doc comment", method_name), +/// ... +/// } +/// ``` +/// +/// This generates: +/// - Fields in `RuntimeStats` struct +/// - Fields in `RuntimeStatsSnapshot` struct +/// - Increment methods (`incr_*`) +/// - `consume_snapshot` implementation +/// - `reset` implementation +macro_rules! define_runtime_stats { + ( + $( + ($field:ident, $doc:literal, $method:ident) + ),* $(,)? + ) => { + /// Atomic runtime counters for cache API calls. + #[derive(Debug, Default)] + pub struct RuntimeStats { + $( + #[doc = $doc] + pub(crate) $field: AtomicU64, + )* + } + + /// Immutable snapshot of [`RuntimeStats`]. + #[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)] + pub struct RuntimeStatsSnapshot { + $( + #[doc = concat!("Total ", stringify!($field), ".")] + pub $field: u64, + )* + } + + impl RuntimeStats { + /// Return an immutable snapshot of the current runtime counters and reset the stats to 0. + pub fn consume_snapshot(&self) -> RuntimeStatsSnapshot { + let v = RuntimeStatsSnapshot { + $( + $field: self.$field.load(Ordering::Relaxed), + )* + }; + self.reset(); + v + } + + $( + /// Increment counter. + #[inline] + pub fn $method(&self) { + self.$field.fetch_add(1, Ordering::Relaxed); + } + )* + + /// Reset the runtime stats to 0. + pub fn reset(&self) { + $( + self.$field.store(0, Ordering::Relaxed); + )* + } + } + + impl fmt::Display for RuntimeStats { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "RuntimeStats:")?; + $( + writeln!(f, " {}: {}", stringify!($field), self.$field.load(Ordering::Relaxed))?; + )* + Ok(()) + } + } + + impl fmt::Display for RuntimeStatsSnapshot { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "RuntimeStatsSnapshot:")?; + $( + writeln!(f, " {}: {}", stringify!($field), self.$field)?; + )* + Ok(()) + } + } + }; +} + +// Define all runtime statistics metrics here. +// To add a new metric, add a line: (field_name, "doc comment", method_name) +define_runtime_stats! { + (get, "Number of `get` calls issued via `CachedData`.", incr_get), + (get_with_selection, "Number of `get_with_selection` calls issued via `CachedData`.", incr_get_with_selection), + (eval_predicate, "Number of `eval_predicate` calls issued via `CachedData`.", incr_eval_predicate), + (cache_hit, "Number of cache hits (data found in cache).", incr_cache_hit), + (cache_miss, "Number of cache misses (data not in cache, fell back to Parquet).", incr_cache_miss), + (try_read_liquid_calls, "Number of `try_read_liquid` calls issued via `CachedData`.", incr_try_read_liquid), + (eval_predicate_on_liquid_failed, "Number of `eval_predicate` calls that failed on Liquid array.", incr_eval_predicate_on_liquid_failed), + (memory_evictions, "Number of cache entries evicted from memory.", incr_memory_evictions), + (transcodes, "Number of Arrow entries transcoded to Liquid under memory pressure.", incr_transcodes), +} + +/// Snapshot of cache statistics. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct CacheStats { + /// Total number of entries in the cache. + pub total_entries: usize, + /// Number of in-memory Arrow entries. + pub memory_arrow_entries: usize, + /// Number of in-memory Liquid entries. + pub memory_liquid_entries: usize, + /// Total size of in-memory Arrow entries in bytes. + pub memory_arrow_bytes: usize, + /// Total size of in-memory Liquid entries in bytes. + pub memory_liquid_bytes: usize, + /// Total memory usage of the cache. + pub memory_usage_bytes: usize, + /// Maximum memory size. + pub max_memory_bytes: usize, + /// Runtime counters snapshot. + pub runtime: RuntimeStatsSnapshot, +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/policies/cache/doubly_linked_list.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/policies/cache/doubly_linked_list.rs new file mode 100644 index 0000000000000..1a48caafdb26b --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/policies/cache/doubly_linked_list.rs @@ -0,0 +1,125 @@ +use std::ptr::NonNull; + +/// Intrusive doubly linked list node used by cache policies. +#[derive(Debug)] +pub(crate) struct DoublyLinkedNode { + pub(crate) data: T, + pub(crate) prev: Option>, + pub(crate) next: Option>, +} + +impl DoublyLinkedNode { + pub(crate) fn new(data: T) -> Box { + Box::new(Self { + data, + prev: None, + next: None, + }) + } +} + +/// Intrusive doubly linked list utility shared across cache policies. +#[derive(Debug)] +pub(crate) struct DoublyLinkedList { + head: Option>>, + tail: Option>>, +} + +impl Default for DoublyLinkedList { + fn default() -> Self { + Self::new() + } +} + +impl DoublyLinkedList { + pub(crate) fn new() -> Self { + Self { + head: None, + tail: None, + } + } + + pub(crate) fn head(&self) -> Option>> { + self.head + } + + #[allow(dead_code)] + pub(crate) fn tail(&self) -> Option>> { + self.tail + } + + /// Inserts the node at the front (head) of the list. + pub(crate) unsafe fn push_front(&mut self, mut node_ptr: NonNull>) { + let node = unsafe { node_ptr.as_mut() }; + node.prev = None; + node.next = self.head; + + if let Some(mut head) = self.head { + unsafe { head.as_mut().prev = Some(node_ptr) }; + } else { + self.tail = Some(node_ptr); + } + + self.head = Some(node_ptr); + } + + /// Inserts the node at the back (tail) of the list. + pub(crate) unsafe fn push_back(&mut self, mut node_ptr: NonNull>) { + let node = unsafe { node_ptr.as_mut() }; + node.next = None; + node.prev = self.tail; + + if let Some(mut tail) = self.tail { + unsafe { tail.as_mut().next = Some(node_ptr) }; + } else { + self.head = Some(node_ptr); + } + + self.tail = Some(node_ptr); + } + + /// Moves an existing node to the front of the list. + #[allow(dead_code)] + pub(crate) unsafe fn move_to_front(&mut self, node_ptr: NonNull>) { + unsafe { + self.unlink(node_ptr); + self.push_front(node_ptr); + } + } + + /// Unlinks `node_ptr` from the list without deallocating it. + pub(crate) unsafe fn unlink(&mut self, mut node_ptr: NonNull>) { + let node = unsafe { node_ptr.as_mut() }; + + match node.prev { + Some(mut prev) => unsafe { prev.as_mut().next = node.next }, + None => self.head = node.next, + } + + match node.next { + Some(mut next) => unsafe { next.as_mut().prev = node.prev }, + None => self.tail = node.prev, + } + + node.prev = None; + node.next = None; + } + + /// Drops all nodes currently owned by the list. + pub(crate) unsafe fn drop_all(&mut self) { + let mut current = self.head; + while let Some(node_ptr) = current { + current = unsafe { node_ptr.as_ref().next }; + unsafe { + drop(Box::from_raw(node_ptr.as_ptr())); + } + } + self.head = None; + self.tail = None; + } +} + +/// Drops the boxed node referenced by `ptr`. +pub(crate) unsafe fn drop_boxed_node(ptr: NonNull>) { + unsafe { drop(Box::from_raw(ptr.as_ptr())) } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/policies/cache/lru.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/policies/cache/lru.rs new file mode 100644 index 0000000000000..0cd8df7d45bc0 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/policies/cache/lru.rs @@ -0,0 +1,246 @@ +//! LRU (Least Recently Used) cache eviction policy. +//! +//! Uses 2 queues by entry type (Arrow, Liquid), same as LiquidPolicy. +//! Within each queue, entries are ordered by recency (moved to back on access). +//! Eviction priority: Arrow (largest) first, then Liquid. +//! Within each queue, evicts the LRU entry (front of the queue). + +use crate::cache::cached_batch::CachedBatchType; +use crate::cache::utils::EntryID; +use crate::sync::Mutex; +use ahash::AHashMap; +use std::ptr::NonNull; + +use super::CachePolicy; +use super::doubly_linked_list::{DoublyLinkedList, DoublyLinkedNode, drop_boxed_node}; + +/// Which queue an entry belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LruQueueKind { + Arrow, + Liquid, +} + +/// LRU cache eviction policy with type-aware queues. +/// +/// On insert: entry goes to the back of its type queue (most recently used). +/// On access: entry moves to the back of its type queue. +/// On eviction: picks the LRU entry from Arrow queue first, then Liquid. +#[derive(Debug)] +pub struct LruPolicy { + inner: Mutex, +} + +#[derive(Debug)] +struct LruInner { + arrow: DoublyLinkedList, + liquid: DoublyLinkedList, + /// Maps entry_id → (node_ptr, which queue it's in) + map: AHashMap>, LruQueueKind)>, +} + +// Safety: We control access via Mutex and never expose raw pointers outside. +unsafe impl Send for LruInner {} +unsafe impl Sync for LruInner {} + +impl LruInner { + fn queue_mut(&mut self, kind: LruQueueKind) -> &mut DoublyLinkedList { + match kind { + LruQueueKind::Arrow => &mut self.arrow, + LruQueueKind::Liquid => &mut self.liquid, + } + } + + fn queue_kind_for(batch_type: CachedBatchType) -> LruQueueKind { + match batch_type { + CachedBatchType::MemoryArrow => LruQueueKind::Arrow, + CachedBatchType::MemoryLiquid => LruQueueKind::Liquid, + } + } + + /// Pop the LRU entry (front) from a specific queue. + fn pop_lru(&mut self, kind: LruQueueKind) -> Option { + let list = self.queue_mut(kind); + let node_ptr = list.head()?; + let entry_id = unsafe { node_ptr.as_ref().data }; + unsafe { list.unlink(node_ptr) }; + self.map.remove(&entry_id); + unsafe { drop_boxed_node(node_ptr) }; + Some(entry_id) + } +} + +impl LruPolicy { + /// Create a new LRU policy. + pub fn new() -> Self { + Self { + inner: Mutex::new(LruInner { + arrow: DoublyLinkedList::new(), + liquid: DoublyLinkedList::new(), + map: AHashMap::new(), + }), + } + } +} + +impl Default for LruPolicy { + fn default() -> Self { + Self::new() + } +} + +impl CachePolicy for LruPolicy { + fn find_memory_victim(&self, cnt: usize) -> Vec { + let mut inner = self.inner.lock().unwrap(); + let mut victims = Vec::with_capacity(cnt); + + while victims.len() < cnt { + // Priority: evict Arrow (largest) first, then Liquid + if let Some(entry) = inner.pop_lru(LruQueueKind::Arrow) { + victims.push(entry); + continue; + } + if let Some(entry) = inner.pop_lru(LruQueueKind::Liquid) { + victims.push(entry); + continue; + } + break; + } + + victims + } + + fn notify_insert(&self, entry_id: &EntryID, batch_type: CachedBatchType) { + let mut inner = self.inner.lock().unwrap(); + let target = LruInner::queue_kind_for(batch_type); + + // If already present, remove from old position/queue + if let Some((node_ptr, old_kind)) = inner.map.remove(entry_id) { + let old_list = inner.queue_mut(old_kind); + unsafe { old_list.unlink(node_ptr) }; + unsafe { drop_boxed_node(node_ptr) }; + } + + // Insert at the back of the target queue (most recently used) + let node = DoublyLinkedNode::new(*entry_id); + let node_ptr = NonNull::new(Box::into_raw(node)).unwrap(); + let list = inner.queue_mut(target); + unsafe { list.push_back(node_ptr) }; + inner.map.insert(*entry_id, (node_ptr, target)); + } + + fn notify_access(&self, entry_id: &EntryID, _batch_type: CachedBatchType) { + let mut inner = self.inner.lock().unwrap(); + + let Some(&(node_ptr, kind)) = inner.map.get(entry_id) else { + return; + }; + + // Move to back of its current queue (most recently used) + let list = inner.queue_mut(kind); + unsafe { + list.unlink(node_ptr); + list.push_back(node_ptr); + } + } + + fn notify_remove(&self, entry_id: &EntryID) { + let mut inner = self.inner.lock().unwrap(); + + if let Some((node_ptr, kind)) = inner.map.remove(entry_id) { + let list = inner.queue_mut(kind); + unsafe { list.unlink(node_ptr) }; + unsafe { drop_boxed_node(node_ptr) }; + } + } +} + +impl Drop for LruPolicy { + fn drop(&mut self) { + let mut inner = self.inner.lock().unwrap(); + unsafe { + inner.arrow.drop_all(); + inner.liquid.drop_all(); + } + inner.map.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(id: usize) -> EntryID { + EntryID::from(id) + } + + #[test] + fn test_lru_evicts_arrow_first() { + let policy = LruPolicy::new(); + + // Insert entries: some Arrow, some Liquid + policy.notify_insert(&entry(1), CachedBatchType::MemoryArrow); + policy.notify_insert(&entry(2), CachedBatchType::MemoryLiquid); + policy.notify_insert(&entry(3), CachedBatchType::MemoryArrow); + policy.notify_insert(&entry(4), CachedBatchType::MemoryLiquid); + + // Evict 1 — should pick from Arrow queue first (LRU = entry 1) + let victims = policy.find_memory_victim(1); + assert_eq!(victims, vec![entry(1)]); + + // Evict 1 more — next Arrow LRU = entry 3 + let victims = policy.find_memory_victim(1); + assert_eq!(victims, vec![entry(3)]); + + // Evict 1 more — Arrow empty, now Liquid LRU = entry 2 + let victims = policy.find_memory_victim(1); + assert_eq!(victims, vec![entry(2)]); + } + + #[test] + fn test_lru_access_moves_to_back() { + let policy = LruPolicy::new(); + + policy.notify_insert(&entry(1), CachedBatchType::MemoryArrow); + policy.notify_insert(&entry(2), CachedBatchType::MemoryArrow); + policy.notify_insert(&entry(3), CachedBatchType::MemoryArrow); + + // Access entry 1 — moves it to back + policy.notify_access(&entry(1), CachedBatchType::MemoryArrow); + + // Evict 1 — LRU is now entry 2 (entry 1 was moved to back) + let victims = policy.find_memory_victim(1); + assert_eq!(victims, vec![entry(2)]); + } + + #[test] + fn test_lru_insert_moves_between_queues() { + let policy = LruPolicy::new(); + + // Insert as Arrow + policy.notify_insert(&entry(1), CachedBatchType::MemoryArrow); + + // Re-insert same entry as Liquid (simulates transcode) + policy.notify_insert(&entry(1), CachedBatchType::MemoryLiquid); + + // Arrow queue should be empty now + let victims = policy.find_memory_victim(1); + // Should come from Liquid queue + assert_eq!(victims, vec![entry(1)]); + } + + #[test] + fn test_lru_remove() { + let policy = LruPolicy::new(); + + policy.notify_insert(&entry(1), CachedBatchType::MemoryArrow); + policy.notify_insert(&entry(2), CachedBatchType::MemoryArrow); + + // Remove entry 1 + policy.notify_remove(&entry(1)); + + // Evict — should get entry 2 (entry 1 was removed) + let victims = policy.find_memory_victim(1); + assert_eq!(victims, vec![entry(2)]); + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/policies/cache/mod.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/policies/cache/mod.rs new file mode 100644 index 0000000000000..5e2e6f483c653 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/policies/cache/mod.rs @@ -0,0 +1,188 @@ +//! Cache policies for liquid cache. + +use crate::cache::cached_batch::CachedBatchType; +use crate::cache::utils::EntryID; + +mod doubly_linked_list; +mod lru; +mod three_queue; + +pub use lru::LruPolicy; +pub use three_queue::LiquidPolicy; + +/// The cache policy that guides the replacement of LiquidCache +pub trait CachePolicy: std::fmt::Debug + Send + Sync { + /// Give cnt amount of entries to evict when cache is full. + fn find_memory_victim(&self, cnt: usize) -> Vec; + + /// Notify the cache policy that an entry was inserted. + fn notify_insert(&self, _entry_id: &EntryID, _batch_type: CachedBatchType) {} + + /// Notify the cache policy that an entry was accessed. + fn notify_access(&self, _entry_id: &EntryID, _batch_type: CachedBatchType) {} + + /// Notify the cache policy that an entry was removed. + fn notify_remove(&self, _entry_id: &EntryID) {} +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cache::utils::EntryID; + use crate::sync::{Arc, Mutex, thread}; + + fn entry(id: usize) -> EntryID { + id.into() + } + + fn concurrent_invariant_advice_once(policy: Arc) { + let num_threads = 4; + + for i in 0..100 { + policy.notify_insert(&entry(i), CachedBatchType::MemoryArrow); + } + + let advised_entries = Arc::new(Mutex::new(Vec::new())); + + let mut handles = Vec::new(); + for _ in 0..num_threads { + let policy_clone = policy.clone(); + let advised_entries_clone = advised_entries.clone(); + + let handle = thread::spawn(move || { + let advice = policy_clone.find_memory_victim(1); + if let Some(entry_id) = advice.first() { + let mut entries = advised_entries_clone.lock().unwrap(); + entries.push(*entry_id); + } + }); + + handles.push(handle); + } + + for handle in handles { + handle.join().unwrap(); + } + + let entries = advised_entries.lock().unwrap(); + let mut unique_entries = entries.clone(); + unique_entries.sort(); + unique_entries.dedup(); + + assert_eq!( + entries.len(), + unique_entries.len(), + "Some entries were advised for eviction multiple times: {entries:?}" + ); + } + + fn run_concurrent_invariant_tests() { + concurrent_invariant_advice_once(Arc::new(LiquidPolicy::new())); + concurrent_invariant_advice_once(Arc::new(super::LruPolicy::new())); + } + + #[test] + fn test_concurrent_invariant_advice_once() { + run_concurrent_invariant_tests(); + } + + #[test] + fn lru_evicts_least_recently_used() { + let policy = LruPolicy::new(); + + // Insert entries 0, 1, 2, 3 in order + for i in 0..4 { + policy.notify_insert(&entry(i), CachedBatchType::MemoryArrow); + } + + // Evict 1 → should be entry 0 (oldest) + let victims = policy.find_memory_victim(1); + assert_eq!(victims, vec![entry(0)]); + + // Evict 1 more → should be entry 1 + let victims = policy.find_memory_victim(1); + assert_eq!(victims, vec![entry(1)]); + } + + #[test] + fn lru_access_moves_to_back() { + let policy = LruPolicy::new(); + + // Insert 0, 1, 2 + for i in 0..3 { + policy.notify_insert(&entry(i), CachedBatchType::MemoryArrow); + } + + // Access entry 0 → moves it to back + policy.notify_access(&entry(0), CachedBatchType::MemoryArrow); + + // Evict → should be entry 1 now (0 was moved to back) + let victims = policy.find_memory_victim(1); + assert_eq!(victims, vec![entry(1)]); + + // Evict → entry 2 + let victims = policy.find_memory_victim(1); + assert_eq!(victims, vec![entry(2)]); + + // Evict → entry 0 (was moved to back by access) + let victims = policy.find_memory_victim(1); + assert_eq!(victims, vec![entry(0)]); + } + + #[test] + fn lru_remove_removes_entry() { + let policy = LruPolicy::new(); + + for i in 0..3 { + policy.notify_insert(&entry(i), CachedBatchType::MemoryArrow); + } + + // Remove entry 1 + policy.notify_remove(&entry(1)); + + // Evict → entry 0, then entry 2 (entry 1 is gone) + let victims = policy.find_memory_victim(2); + assert_eq!(victims, vec![entry(0), entry(2)]); + } + + #[test] + fn lru_evict_more_than_available() { + let policy = LruPolicy::new(); + + policy.notify_insert(&entry(0), CachedBatchType::MemoryArrow); + policy.notify_insert(&entry(1), CachedBatchType::MemoryArrow); + + // Ask for 5 but only 2 exist + let victims = policy.find_memory_victim(5); + assert_eq!(victims.len(), 2); + assert_eq!(victims, vec![entry(0), entry(1)]); + + // Empty now + let victims = policy.find_memory_victim(1); + assert!(victims.is_empty()); + } + + #[test] + fn lru_reinsert_updates_position() { + let policy = LruPolicy::new(); + + for i in 0..3 { + policy.notify_insert(&entry(i), CachedBatchType::MemoryArrow); + } + + // Re-insert entry 0 → should move to back + policy.notify_insert(&entry(0), CachedBatchType::MemoryLiquid); + + // Evict → entry 1 (oldest now) + let victims = policy.find_memory_victim(1); + assert_eq!(victims, vec![entry(1)]); + + // Evict → entry 2 + let victims = policy.find_memory_victim(1); + assert_eq!(victims, vec![entry(2)]); + + // Evict → entry 0 (re-inserted last) + let victims = policy.find_memory_victim(1); + assert_eq!(victims, vec![entry(0)]); + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/policies/cache/three_queue.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/policies/cache/three_queue.rs new file mode 100644 index 0000000000000..2be4e2559a04c --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/policies/cache/three_queue.rs @@ -0,0 +1,267 @@ +use std::{collections::HashMap, ptr::NonNull}; + +use crate::{ + cache::{CachePolicy, EntryID, cached_batch::CachedBatchType}, + sync::Mutex, +}; + +use super::doubly_linked_list::{DoublyLinkedList, DoublyLinkedNode, drop_boxed_node}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum QueueKind { + Arrow, + Liquid, +} + +#[derive(Debug)] +struct QueueNode { + entry_id: EntryID, + queue: QueueKind, +} + +type NodePtr = NonNull>; + +#[derive(Default, Debug)] +struct LiquidQueueInternalState { + map: HashMap, + arrow: DoublyLinkedList, + liquid: DoublyLinkedList, +} + +impl LiquidQueueInternalState { + unsafe fn list_mut(&mut self, queue: QueueKind) -> &mut DoublyLinkedList { + match queue { + QueueKind::Arrow => &mut self.arrow, + QueueKind::Liquid => &mut self.liquid, + } + } + + unsafe fn push_back(&mut self, queue: QueueKind, mut node_ptr: NodePtr) { + unsafe { + node_ptr.as_mut().data.queue = queue; + self.list_mut(queue).push_back(node_ptr); + } + } + + unsafe fn detach(&mut self, node_ptr: NodePtr) { + unsafe { + let queue = node_ptr.as_ref().data.queue; + self.list_mut(queue).unlink(node_ptr); + } + } + + fn upsert_into_queue(&mut self, entry_id: EntryID, target: QueueKind) { + if let Some(node_ptr) = self.map.get(&entry_id).copied() { + unsafe { + self.detach(node_ptr); + self.push_back(target, node_ptr); + } + return; + } + + let node = DoublyLinkedNode::new(QueueNode { + entry_id, + queue: target, + }); + let node_ptr = NonNull::from(Box::leak(node)); + + self.map.insert(entry_id, node_ptr); + unsafe { + self.push_back(target, node_ptr); + } + } + + fn pop_front(&mut self, queue: QueueKind) -> Option { + let list = match queue { + QueueKind::Arrow => &mut self.arrow, + QueueKind::Liquid => &mut self.liquid, + }; + + let head_ptr = list.head()?; + let entry_id = unsafe { head_ptr.as_ref().data.entry_id }; + let node_ptr = self + .map + .remove(&entry_id) + .expect("list head must exist in map"); + unsafe { + list.unlink(node_ptr); + drop_boxed_node(node_ptr); + } + Some(entry_id) + } + + fn remove(&mut self, entry_id: &EntryID) -> Option { + let node_ptr = self.map.remove(entry_id)?; + let removed = unsafe { node_ptr.as_ref().data.entry_id }; + unsafe { + self.detach(node_ptr); + drop_boxed_node(node_ptr); + } + Some(removed) + } +} + +impl Drop for LiquidQueueInternalState { + fn drop(&mut self) { + let nodes: Vec<_> = self.map.drain().map(|(_, ptr)| ptr).collect(); + for node_ptr in nodes { + unsafe { + match node_ptr.as_ref().data.queue { + QueueKind::Arrow => self.arrow.unlink(node_ptr), + QueueKind::Liquid => self.liquid.unlink(node_ptr), + } + drop_boxed_node(node_ptr); + } + } + + unsafe { + self.arrow.drop_all(); + self.liquid.drop_all(); + } + } +} + +/// Cache policy that keeps independent FIFO queues per batch type. +#[derive(Debug, Default)] +pub struct LiquidPolicy { + inner: Mutex, +} + +impl LiquidPolicy { + /// Create a new [`LiquidPolicy`]. + pub fn new() -> Self { + Self { + inner: Mutex::new(LiquidQueueInternalState::default()), + } + } +} + +// SAFETY: Access to raw pointers is protected by the internal `Mutex`. +unsafe impl Send for LiquidPolicy {} +unsafe impl Sync for LiquidPolicy {} + +impl CachePolicy for LiquidPolicy { + fn notify_insert(&self, entry_id: &EntryID, batch_type: CachedBatchType) { + let mut inner = self.inner.lock().unwrap(); + let target = match batch_type { + CachedBatchType::MemoryArrow => QueueKind::Arrow, + CachedBatchType::MemoryLiquid => QueueKind::Liquid, + }; + + inner.upsert_into_queue(*entry_id, target); + } + + fn find_memory_victim(&self, cnt: usize) -> Vec { + if cnt == 0 { + return vec![]; + } + + let mut inner = self.inner.lock().unwrap(); + let mut victims = Vec::with_capacity(cnt); + + while victims.len() < cnt { + if let Some(entry) = inner.pop_front(QueueKind::Arrow) { + victims.push(entry); + continue; + } + + if let Some(entry) = inner.pop_front(QueueKind::Liquid) { + victims.push(entry); + continue; + } + + break; + } + + victims + } + + fn notify_access(&self, _entry_id: &EntryID, _batch_type: CachedBatchType) {} + + fn notify_remove(&self, entry_id: &EntryID) { + let mut inner = self.inner.lock().unwrap(); + inner.remove(entry_id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cache::utils::EntryID; + + fn entry(id: usize) -> EntryID { + id.into() + } + + #[test] + fn test_fifo_within_each_queue() { + let policy = LiquidPolicy::new(); + + let arrow_a = entry(1); + let arrow_b = entry(2); + let liquid_a = entry(3); + let liquid_b = entry(4); + + policy.notify_insert(&arrow_a, CachedBatchType::MemoryArrow); + policy.notify_insert(&arrow_b, CachedBatchType::MemoryArrow); + policy.notify_insert(&liquid_a, CachedBatchType::MemoryLiquid); + policy.notify_insert(&liquid_b, CachedBatchType::MemoryLiquid); + + assert_eq!(policy.find_memory_victim(1), vec![arrow_a]); + assert_eq!(policy.find_memory_victim(2), vec![arrow_b, liquid_a]); + assert_eq!(policy.find_memory_victim(1), vec![liquid_b]); + } + + #[test] + fn test_queue_priority_order() { + let policy = LiquidPolicy::new(); + + let arrow_entry = entry(1); + let liquid_entry = entry(2); + + policy.notify_insert(&liquid_entry, CachedBatchType::MemoryLiquid); + policy.notify_insert(&arrow_entry, CachedBatchType::MemoryArrow); + + // Request more victims than available to ensure we only get what exists. + let victims = policy.find_memory_victim(5); + assert_eq!(victims, vec![arrow_entry, liquid_entry]); + } + + #[test] + fn test_zero_victim_request_returns_empty() { + let policy = LiquidPolicy::new(); + + policy.notify_insert(&entry(1), CachedBatchType::MemoryArrow); + assert!(policy.find_memory_victim(0).is_empty()); + } + + #[test] + fn test_reinsert_moves_entry_to_back_of_queue() { + let policy = LiquidPolicy::new(); + + let first = entry(1); + let second = entry(2); + + policy.notify_insert(&first, CachedBatchType::MemoryArrow); + policy.notify_insert(&second, CachedBatchType::MemoryArrow); + + // Reinserting should refresh the entry as the newest arrow batch. + policy.notify_insert(&first, CachedBatchType::MemoryArrow); + + assert_eq!(policy.find_memory_victim(1), vec![second]); + assert_eq!(policy.find_memory_victim(1), vec![first]); + } + + #[test] + fn test_reinsert_handles_cross_queue_move() { + let policy = LiquidPolicy::new(); + + let entry_id = entry(42); + + policy.notify_insert(&entry_id, CachedBatchType::MemoryArrow); + policy.notify_insert(&entry_id, CachedBatchType::MemoryLiquid); + + let victims = policy.find_memory_victim(2); + assert_eq!(victims, vec![entry_id]); + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/policies/mod.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/policies/mod.rs new file mode 100644 index 0000000000000..fd5fae6a955c0 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/policies/mod.rs @@ -0,0 +1,7 @@ +//! Policy modules for cache eviction and squeezing. + +pub mod cache; +pub mod squeeze; + +pub use cache::*; +pub use squeeze::*; diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/policies/squeeze.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/policies/squeeze.rs new file mode 100644 index 0000000000000..c5d43152c4e43 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/policies/squeeze.rs @@ -0,0 +1,106 @@ +//! Squeeze policies for liquid cache. + +use crate::cache::{cached_batch::CacheEntry, transcode_liquid_inner}; + +/// What to do when we need to squeeze an entry? +#[derive(Debug, Clone)] +pub enum SqueezeOutcome { + /// Replace the cache entry with a smaller in-memory representation. + Replace(CacheEntry), + /// Remove the entry entirely. + Remove, +} + +/// Policy that chooses the next representation for an entry under memory pressure. +pub trait SqueezePolicy: std::fmt::Debug + Send + Sync { + /// Squeeze the entry. + fn squeeze(&self, entry: &CacheEntry) -> SqueezeOutcome; +} + +/// Evict the entry from memory. +#[derive(Debug, Default, Clone)] +pub struct Evict; + +impl SqueezePolicy for Evict { + fn squeeze(&self, _entry: &CacheEntry) -> SqueezeOutcome { + SqueezeOutcome::Remove + } +} + +/// Transcode Arrow entries to liquid memory; evict liquid entries. +#[derive(Debug, Default, Clone)] +pub struct TranscodeEvict; + +impl SqueezePolicy for TranscodeEvict { + fn squeeze(&self, entry: &CacheEntry) -> SqueezeOutcome { + match entry { + CacheEntry::MemoryArrow(array) => match transcode_liquid_inner(array) { + Ok(liquid_array) => { + SqueezeOutcome::Replace(CacheEntry::memory_liquid(liquid_array)) + } + Err(_) => SqueezeOutcome::Remove, + }, + CacheEntry::MemoryLiquid(_) => SqueezeOutcome::Remove, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cache::cached_batch::CacheEntry; + use arrow::array::{ArrayRef, BooleanArray, Int32Array}; + use std::sync::Arc; + + fn int_array(n: i32) -> ArrayRef { + Arc::new(Int32Array::from_iter_values(0..n)) + } + + #[test] + fn test_evict_policy_always_removes() { + let policy = Evict; + + let arrow_entry = CacheEntry::memory_arrow(int_array(8)); + assert!(matches!( + policy.squeeze(&arrow_entry), + SqueezeOutcome::Remove + )); + + let arr = int_array(8); + let liquid = transcode_liquid_inner(&arr).unwrap(); + let liquid_entry = CacheEntry::memory_liquid(liquid); + assert!(matches!( + policy.squeeze(&liquid_entry), + SqueezeOutcome::Remove + )); + } + + #[test] + fn test_transcode_evict_policy() { + let policy = TranscodeEvict; + + // MemoryArrow -> MemoryLiquid + let arr = int_array(8); + let outcome = policy.squeeze(&CacheEntry::memory_arrow(arr.clone())); + match outcome { + SqueezeOutcome::Replace(CacheEntry::MemoryLiquid(liq)) => { + assert_eq!(liq.to_arrow_array().as_ref(), arr.as_ref()); + } + other => panic!("unexpected: {other:?}"), + } + + // MemoryLiquid -> Remove + let liquid = transcode_liquid_inner(&arr).unwrap(); + let outcome = policy.squeeze(&CacheEntry::memory_liquid(liquid)); + assert!(matches!(outcome, SqueezeOutcome::Remove)); + } + + #[test] + fn transcode_evict_untranscodable_removes() { + let policy = TranscodeEvict; + // Boolean arrays are not supported by the transcoder. + let arr: ArrayRef = Arc::new(BooleanArray::from(vec![true, false, true])); + let outcome = policy.squeeze(&CacheEntry::memory_arrow(arr)); + assert!(matches!(outcome, SqueezeOutcome::Remove)); + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/transcode.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/transcode.rs new file mode 100644 index 0000000000000..284cafa31036f --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/transcode.rs @@ -0,0 +1,201 @@ +use std::sync::Arc; + +use arrow::array::types::*; +use arrow::array::{ArrayRef, AsArray}; +use arrow_schema::{DataType, TimeUnit}; + +use crate::liquid_array::{ + LiquidArrayRef, LiquidDecimalArray, LiquidFloatArray, LiquidPrimitiveArray, +}; + +/// This method is used to transcode an arrow array into a liquid array. +/// +/// Returns the transcoded liquid array if successful, otherwise returns the original arrow array. +pub fn transcode_liquid_inner(array: &ArrayRef) -> Result { + let data_type = array.data_type(); + if data_type.is_primitive() { + // For primitive types, perform the transcoding. + let liquid_array: LiquidArrayRef = match data_type { + DataType::Int8 => Arc::new(LiquidPrimitiveArray::::from_arrow_array( + array.as_primitive::().clone(), + )), + DataType::Int16 => Arc::new(LiquidPrimitiveArray::::from_arrow_array( + array.as_primitive::().clone(), + )), + DataType::Int32 => Arc::new(LiquidPrimitiveArray::::from_arrow_array( + array.as_primitive::().clone(), + )), + DataType::Int64 => Arc::new(LiquidPrimitiveArray::::from_arrow_array( + array.as_primitive::().clone(), + )), + DataType::UInt8 => Arc::new(LiquidPrimitiveArray::::from_arrow_array( + array.as_primitive::().clone(), + )), + DataType::UInt16 => Arc::new(LiquidPrimitiveArray::::from_arrow_array( + array.as_primitive::().clone(), + )), + DataType::UInt32 => Arc::new(LiquidPrimitiveArray::::from_arrow_array( + array.as_primitive::().clone(), + )), + DataType::UInt64 => Arc::new(LiquidPrimitiveArray::::from_arrow_array( + array.as_primitive::().clone(), + )), + DataType::Date32 => Arc::new(LiquidPrimitiveArray::::from_arrow_array( + array.as_primitive::().clone(), + )), + DataType::Date64 => Arc::new(LiquidPrimitiveArray::::from_arrow_array( + array.as_primitive::().clone(), + )), + DataType::Timestamp(TimeUnit::Second, None) => Arc::new(LiquidPrimitiveArray::< + TimestampSecondType, + >::from_arrow_array( + array.as_primitive::().clone(), + )), + DataType::Timestamp(TimeUnit::Millisecond, None) => Arc::new(LiquidPrimitiveArray::< + TimestampMillisecondType, + >::from_arrow_array( + array.as_primitive::().clone(), + )), + DataType::Timestamp(TimeUnit::Microsecond, None) => Arc::new(LiquidPrimitiveArray::< + TimestampMicrosecondType, + >::from_arrow_array( + array.as_primitive::().clone(), + )), + DataType::Timestamp(TimeUnit::Nanosecond, None) => Arc::new(LiquidPrimitiveArray::< + TimestampNanosecondType, + >::from_arrow_array( + array.as_primitive::().clone(), + )), + DataType::Timestamp(_, Some(_)) => { + log::warn!("unsupported timestamp type with timezone {data_type:?}"); + return Err(array); + } + DataType::Float32 => Arc::new(LiquidFloatArray::::from_arrow_array( + array.as_primitive::().clone(), + )), + DataType::Float64 => Arc::new(LiquidFloatArray::::from_arrow_array( + array.as_primitive::().clone(), + )), + DataType::Decimal128(_, _) => { + let decimals = array.as_primitive::(); + if LiquidDecimalArray::fits_u64(decimals) { + return Ok(Arc::new(LiquidDecimalArray::from_decimal_array(decimals))); + } + log::debug!("decimal128 does not fit u64, not transcoding"); + return Err(array); + } + DataType::Decimal256(_, _) => { + let decimals = array.as_primitive::(); + if LiquidDecimalArray::fits_u64(decimals) { + return Ok(Arc::new(LiquidDecimalArray::from_decimal_array(decimals))); + } + log::debug!("decimal256 does not fit u64, not transcoding"); + return Err(array); + } + _ => { + // For unsupported primitive types, leave the value unchanged. + log::warn!("unsupported primitive type {data_type:?}"); + return Err(array); + } + }; + return Ok(liquid_array); + } + + log::debug!("unsupported data type {:?}", array.data_type()); + Err(array) +} + +#[cfg(test)] +mod tests { + use super::*; + use arrow::array::{ + ArrayRef, BooleanArray, Float32Array, Float64Array, Int32Array, Int64Array, + TimestampMicrosecondArray, + }; + + const TEST_ARRAY_SIZE: usize = 8192; + + fn assert_transcode(original: &ArrayRef, transcoded: &LiquidArrayRef) { + assert!( + transcoded.get_array_memory_size() < original.get_array_memory_size(), + "transcoded size: {}, original size: {}", + transcoded.get_array_memory_size(), + original.get_array_memory_size() + ); + let back_to_arrow = transcoded.to_arrow_array(); + assert_eq!(original, &back_to_arrow); + } + + #[test] + fn test_transcode_int32() { + let array: ArrayRef = Arc::new(Int32Array::from_iter_values(0..TEST_ARRAY_SIZE as i32)); + let transcoded = transcode_liquid_inner(&array).unwrap(); + assert_transcode(&array, &transcoded); + } + + #[test] + fn test_transcode_int64() { + let array: ArrayRef = Arc::new(Int64Array::from_iter_values(0..TEST_ARRAY_SIZE as i64)); + let transcoded = transcode_liquid_inner(&array).unwrap(); + assert_transcode(&array, &transcoded); + } + + #[test] + fn test_transcode_float32() { + let array: ArrayRef = Arc::new(Float32Array::from_iter_values( + (0..TEST_ARRAY_SIZE).map(|i| i as f32), + )); + let transcoded = transcode_liquid_inner(&array).unwrap(); + assert_transcode(&array, &transcoded); + } + + #[test] + fn test_transcode_float64() { + let array: ArrayRef = Arc::new(Float64Array::from_iter_values( + (0..TEST_ARRAY_SIZE).map(|i| i as f64), + )); + + let transcoded = transcode_liquid_inner(&array).unwrap(); + assert_transcode(&array, &transcoded); + } + + #[test] + fn test_transcode_timestamp_microsecond() { + let array: ArrayRef = Arc::new(TimestampMicrosecondArray::from_iter_values( + (0..TEST_ARRAY_SIZE).map(|i| (i as i64) * 1_000), + )); + + let transcoded = transcode_liquid_inner(&array).unwrap(); + assert_transcode(&array, &transcoded); + } + + #[test] + fn test_transcode_decimal128() { + use arrow::array::Decimal128Builder; + let mut builder = Decimal128Builder::new(); + for i in 0..TEST_ARRAY_SIZE { + builder.append_value(i as i128 * 100); + } + let array: ArrayRef = Arc::new(builder.finish().with_precision_and_scale(20, 2).unwrap()); + + let transcoded = transcode_liquid_inner(&array).unwrap(); + let back_to_arrow = transcoded.to_arrow_array(); + assert_eq!(&array, &back_to_arrow); + } + + #[test] + fn test_transcode_unsupported_type() { + // Create a boolean array which is not supported by the transcoder + let values: Vec = (0..TEST_ARRAY_SIZE).map(|i| i.is_multiple_of(2)).collect(); + let array: ArrayRef = Arc::new(BooleanArray::from(values)); + + // Try to transcode and expect an error + let result = transcode_liquid_inner(&array); + + // Verify it returns Err with the original array + assert!(result.is_err()); + if let Err(original) = result { + assert_eq!(&array, original); + } + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/utils.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/utils.rs new file mode 100644 index 0000000000000..215b16ecc93d5 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/cache/utils.rs @@ -0,0 +1,80 @@ +#[cfg(test)] +use crate::cache::cached_batch::CacheEntry; +#[cfg(test)] +use crate::sync::Arc; +#[cfg(test)] +use arrow::array::ArrayRef; + +#[derive(Debug)] +pub struct CacheConfig { + batch_size: usize, + max_memory_bytes: usize, +} + +impl CacheConfig { + pub(super) fn new(batch_size: usize, max_memory_bytes: usize) -> Self { + Self { + batch_size, + max_memory_bytes, + } + } + + pub fn batch_size(&self) -> usize { + self.batch_size + } + + pub fn max_memory_bytes(&self) -> usize { + self.max_memory_bytes + } +} + +// Helper methods +#[cfg(test)] +pub(crate) fn create_test_array(size: usize) -> CacheEntry { + use arrow::array::Int64Array; + use std::sync::Arc; + + CacheEntry::memory_arrow(Arc::new(Int64Array::from_iter_values(0..size as i64))) +} + +// Helper methods +#[cfg(test)] +pub(crate) fn create_test_arrow_array(size: usize) -> ArrayRef { + use arrow::array::Int64Array; + Arc::new(Int64Array::from_iter_values(0..size as i64)) +} + +#[cfg(test)] +pub(crate) fn create_cache_store( + max_memory_bytes: usize, + policy: Box, +) -> Arc { + use crate::cache::{LiquidCacheBuilder, TranscodeEvict}; + + let batch_size = 128; + + let builder = LiquidCacheBuilder::new() + .with_batch_size(batch_size) + .with_max_memory_bytes(max_memory_bytes) + .with_squeeze_policy(Box::new(TranscodeEvict)) + .with_cache_policy(policy); + builder.build() +} + +/// EntryID is a unique identifier for a batch of rows, i.e., the cache key. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, serde::Serialize)] +pub struct EntryID { + val: usize, +} + +impl From for EntryID { + fn from(val: usize) -> Self { + Self { val } + } +} + +impl From for usize { + fn from(val: EntryID) -> Self { + val.val + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/lib.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/lib.rs new file mode 100644 index 0000000000000..a84cbcdc78af6 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/lib.rs @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +// Vendored in-memory subset of liquid-cache core. See ../../README.md for provenance. + +//! In-memory liquid cache: cache storage (index, budget, eviction policies, +//! transcode) plus the numeric LiquidArray encodings. + +pub mod cache; +pub mod liquid_array; +mod sync; +pub mod utils; + +pub use cache::cache_policies; diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/liquid_array/decimal_array.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/liquid_array/decimal_array.rs new file mode 100644 index 0000000000000..10bb692c137cd --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/liquid_array/decimal_array.rs @@ -0,0 +1,207 @@ +use std::any::Any; +use std::mem::size_of; +use std::sync::Arc; + +use arrow::array::{Array, ArrayRef, PrimitiveArray}; +use arrow::buffer::ScalarBuffer; +use arrow::datatypes::{Decimal128Type, Decimal256Type, DecimalType, UInt64Type, i256}; +use arrow_schema::DataType; +use num_traits::ToPrimitive; + +use super::{LiquidArray, LiquidDataType}; +use crate::liquid_array::raw::BitPackedArray; +use crate::utils::get_bit_width; + +#[derive(Debug, Clone, Copy)] +struct DecimalMeta { + precision: u8, + scale: i8, + is_256: bool, +} + +impl DecimalMeta { + fn from_data_type(data_type: &DataType) -> Self { + match data_type { + DataType::Decimal128(precision, scale) => Self { + precision: *precision, + scale: *scale, + is_256: false, + }, + DataType::Decimal256(precision, scale) => Self { + precision: *precision, + scale: *scale, + is_256: true, + }, + _ => panic!("unsupported decimal data type: {data_type:?}"), + } + } + + fn data_type(&self) -> DataType { + if self.is_256 { + DataType::Decimal256(self.precision, self.scale) + } else { + DataType::Decimal128(self.precision, self.scale) + } + } +} + +/// Liquid decimal array stored as a compressed u64 primitive. +#[derive(Debug)] +pub struct LiquidDecimalArray { + meta: DecimalMeta, + bit_packed: BitPackedArray, + reference_value: u64, +} + +impl LiquidDecimalArray { + pub(crate) fn fits_u64(array: &PrimitiveArray) -> bool + where + T::Native: ToPrimitive, + { + array.iter().flatten().all(|v| v.to_u64().is_some()) + } + + pub(crate) fn from_decimal_array(array: &PrimitiveArray) -> Self + where + T::Native: ToPrimitive, + { + debug_assert!(Self::fits_u64(array)); + let meta = DecimalMeta::from_data_type(array.data_type()); + if array.null_count() == array.len() { + return Self { + meta, + bit_packed: BitPackedArray::new_null_array(array.len()), + reference_value: 0, + }; + } + + let nulls = array.nulls().cloned(); + let mut min = u64::MAX; + let mut max = 0u64; + let values: Vec = array + .iter() + .map(|v| match v { + Some(v) => { + let value = v.to_u64().expect("decimal fits u64"); + if value < min { + min = value; + } + if value > max { + max = value; + } + value + } + None => 0, + }) + .collect(); + + let bit_width = get_bit_width(max - min); + let offsets = ScalarBuffer::from_iter(values.iter().map(|v| v.saturating_sub(min))); + let unsigned_array = PrimitiveArray::::new(offsets, nulls); + let bit_packed = BitPackedArray::from_primitive(unsigned_array, bit_width); + + Self { + meta, + bit_packed, + reference_value: min, + } + } + + fn to_u64_array(&self) -> PrimitiveArray { + let unsigned_array = self.bit_packed.to_primitive(); + let (_data_type, values, _nulls) = unsigned_array.into_parts(); + let nulls = self.bit_packed.nulls(); + let values = if self.reference_value != 0 { + let reference_value = self.reference_value; + ScalarBuffer::from_iter(values.iter().map(|v| v.wrapping_add(reference_value))) + } else { + values + }; + PrimitiveArray::::new(values, nulls.cloned()) + } +} + +impl LiquidArray for LiquidDecimalArray { + fn as_any(&self) -> &dyn Any { + self + } + + fn get_array_memory_size(&self) -> usize { + self.bit_packed.get_array_memory_size() + size_of::() + size_of::() + } + + fn len(&self) -> usize { + self.bit_packed.len() + } + + fn to_arrow_array(&self) -> ArrayRef { + let u64_array = self.to_u64_array(); + let (_data_type, values, nulls) = u64_array.into_parts(); + let data_type = self.meta.data_type(); + if self.meta.is_256 { + let values_i256 = + ScalarBuffer::from_iter(values.iter().map(|v| i256::from_i128(*v as i128))); + let array = PrimitiveArray::::new(values_i256, nulls); + Arc::new(array.with_data_type(data_type)) + } else { + let values_i128 = ScalarBuffer::from_iter(values.iter().map(|v| *v as i128)); + let array = PrimitiveArray::::new(values_i128, nulls); + Arc::new(array.with_data_type(data_type)) + } + } + + fn original_arrow_data_type(&self) -> DataType { + self.meta.data_type() + } + + fn data_type(&self) -> LiquidDataType { + LiquidDataType::Decimal + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cache::LiquidExpr; + use arrow::array::{BooleanArray, Decimal128Builder}; + use arrow::buffer::BooleanBuffer; + use datafusion_common::ScalarValue; + use datafusion_expr_common::operator::Operator as DFOperator; + use datafusion_physical_expr::PhysicalExpr; + use datafusion_physical_expr::expressions::{BinaryExpr, Column, Literal}; + use std::sync::Arc; + + #[test] + fn decimal_u64_roundtrip() { + let mut builder = Decimal128Builder::new(); + builder.append_value(100_i128); + builder.append_null(); + builder.append_value(250_i128); + let original = builder.finish().with_precision_and_scale(10, 2).unwrap(); + + let liquid = LiquidDecimalArray::from_decimal_array(&original); + let arrow = liquid.to_arrow_array(); + assert_eq!(arrow.as_ref(), &original); + } + + #[test] + fn decimal_predicate_eval() { + let mut builder = Decimal128Builder::new(); + builder.append_value(100_i128); + builder.append_value(200_i128); + builder.append_null(); + builder.append_value(300_i128); + let original = builder.finish().with_precision_and_scale(10, 2).unwrap(); + + let liquid = LiquidDecimalArray::from_decimal_array(&original); + + let mask = BooleanBuffer::new_set(original.len()); + let lit = Arc::new(Literal::new(ScalarValue::Decimal128(Some(150_i128), 10, 2))); + let col = Arc::new(Column::new("col", 0)); + let expr: Arc = Arc::new(BinaryExpr::new(col, DFOperator::GtEq, lit)); + + let got = liquid.try_eval_predicate(&LiquidExpr::new_unchecked(expr), &mask); + let expected = BooleanArray::from(vec![Some(false), Some(true), None, Some(true)]); + assert_eq!(got, expected); + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/liquid_array/float_array.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/liquid_array/float_array.rs new file mode 100644 index 0000000000000..932ca04685d05 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/liquid_array/float_array.rs @@ -0,0 +1,590 @@ +/// +/// Acknowledgement: +/// The ALP compression implemented in this file is based on the Rust implementation available at https://github.com/spiraldb/alp +/// +use std::{any::Any, fmt::Debug, ops::Mul, sync::Arc}; + +use arrow::{ + array::{Array, ArrayRef, ArrowNativeTypeOp, ArrowPrimitiveType, BooleanArray, PrimitiveArray}, + buffer::{BooleanBuffer, ScalarBuffer}, + datatypes::{ + ArrowNativeType, Float32Type, Float64Type, Int32Type, Int64Type, UInt32Type, UInt64Type, + }, +}; +use arrow_schema::DataType; +use fastlanes::BitPacking; +use num_traits::{AsPrimitive, Float, FromPrimitive}; + +use super::LiquidDataType; +use crate::cache::LiquidExpr; +use crate::liquid_array::LiquidArray; +use crate::liquid_array::eval_predicate_on_array; +use crate::liquid_array::raw::BitPackedArray; +use crate::utils::get_bit_width; + +mod private { + use arrow::{ + array::ArrowNumericType, + datatypes::{Float32Type, Float64Type}, + }; + use num_traits::AsPrimitive; + + pub trait Sealed: ArrowNumericType + AsPrimitive> {} + + impl Sealed for Float32Type {} + impl Sealed for Float64Type {} +} + +const NUM_SAMPLES: usize = 1024; // we use FASTLANES to encode array, the sample size needs to be at least 1024 to get a good estimate of the best exponents + +/// LiquidFloatType is a sealed trait that represents all the float types supported by Liquid. +/// Implementors are Float32Type and Float64Type. TODO(): What about Float16Type, decimal types? +pub trait LiquidFloatType: + ArrowPrimitiveType< + Native: AsPrimitive< + ::Native // Native must be convertible to the Native type of Self::UnSignedType + > + + AsPrimitive<::Native> + + FromPrimitive + + AsPrimitive<::Native> + + Mul<::Native> + + Float // required for decode_single and encode_single_unchecked + > + + private::Sealed + + Debug +{ + type UnsignedIntType: + ArrowPrimitiveType< + Native: BitPacking + + AsPrimitive<::Native> + + AsPrimitive<::Native> + + AsPrimitive + > + + Debug; + type SignedIntType: + ArrowPrimitiveType< + Native: AsPrimitive<::Native> + + AsPrimitive<::Native> + + Ord + + From + > + + Debug + Sync + Send; + + const SWEET: ::Native; + const MAX_EXPONENT: u8; + const FRACTIONAL_BITS: u8; + const F10: &'static [::Native]; + const IF10: &'static [::Native]; + + #[inline] + fn fast_round(val: ::Native) -> ::Native { + ((val + Self::SWEET) - Self::SWEET).as_() + } + + #[inline] + fn encode_single_unchecked(val: &::Native, exp: &Exponents) -> ::Native { + Self::fast_round(*val * Self::F10[exp.e as usize] * Self::IF10[exp.f as usize]) + } + + #[inline] + fn decode_single(val: &::Native, exp: &Exponents) -> ::Native { + let decoded_float: ::Native = (*val).as_(); + decoded_float * Self::F10[exp.f as usize] * Self::IF10[exp.e as usize] + } + +} + +impl LiquidFloatType for Float32Type { + type UnsignedIntType = UInt32Type; + type SignedIntType = Int32Type; + const FRACTIONAL_BITS: u8 = 23; + const MAX_EXPONENT: u8 = 10; + const SWEET: ::Native = (1 << Self::FRACTIONAL_BITS) + as ::Native + + (1 << (Self::FRACTIONAL_BITS - 1)) as ::Native; + const F10: &'static [::Native] = &[ + 1.0, + 10.0, + 100.0, + 1000.0, + 10000.0, + 100000.0, + 1000000.0, + 10000000.0, + 100000000.0, + 1000000000.0, + 10000000000.0, // 10^10 + ]; + const IF10: &'static [::Native] = &[ + 1.0, + 0.1, + 0.01, + 0.001, + 0.0001, + 0.00001, + 0.000001, + 0.0000001, + 0.00000001, + 0.000000001, + 0.0000000001, // 10^-10 + ]; +} + +impl LiquidFloatType for Float64Type { + type UnsignedIntType = UInt64Type; + type SignedIntType = Int64Type; + const FRACTIONAL_BITS: u8 = 52; + const MAX_EXPONENT: u8 = 18; + const SWEET: ::Native = (1u64 << Self::FRACTIONAL_BITS) + as ::Native + + (1u64 << (Self::FRACTIONAL_BITS - 1)) as ::Native; + const F10: &'static [::Native] = &[ + 1.0, + 10.0, + 100.0, + 1000.0, + 10000.0, + 100000.0, + 1000000.0, + 10000000.0, + 100000000.0, + 1000000000.0, + 10000000000.0, + 100000000000.0, + 1000000000000.0, + 10000000000000.0, + 100000000000000.0, + 1000000000000000.0, + 10000000000000000.0, + 100000000000000000.0, + 1000000000000000000.0, + 10000000000000000000.0, + 100000000000000000000.0, + 1000000000000000000000.0, + 10000000000000000000000.0, + 100000000000000000000000.0, // 10^23 + ]; + + const IF10: &'static [::Native] = &[ + 1.0, + 0.1, + 0.01, + 0.001, + 0.0001, + 0.00001, + 0.000001, + 0.0000001, + 0.00000001, + 0.000000001, + 0.0000000001, + 0.00000000001, + 0.000000000001, + 0.0000000000001, + 0.00000000000001, + 0.000000000000001, + 0.0000000000000001, + 0.00000000000000001, + 0.000000000000000001, + 0.0000000000000000001, + 0.00000000000000000001, + 0.000000000000000000001, + 0.0000000000000000000001, + 0.00000000000000000000001, // 10^-23 + ]; +} + +/// Liquid's single-precision floating point array +pub type LiquidFloat32Array = LiquidFloatArray; +/// Liquid's double precision floating point array +pub type LiquidFloat64Array = LiquidFloatArray; + +/// An array that stores floats in ALP +#[derive(Debug, Clone)] +pub struct LiquidFloatArray { + exponent: Exponents, + bit_packed: BitPackedArray, + patch_indices: Vec, + patch_values: Vec, + reference_value: ::Native, +} + +impl LiquidFloatArray +where + T: LiquidFloatType, +{ + /// Check if the Liquid float array is empty. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Get the length of the Liquid float array. + pub fn len(&self) -> usize { + self.bit_packed.len() + } + + /// Get the memory size of the Liquid primitive array. + pub fn get_array_memory_size(&self) -> usize { + self.bit_packed.get_array_memory_size() + + size_of::() + + self.patch_indices.capacity() * size_of::() + + self.patch_values.capacity() * size_of::() + + size_of::<::Native>() + } + + /// Create a Liquid primitive array from an Arrow float array. + pub fn from_arrow_array(arrow_array: arrow::array::PrimitiveArray) -> LiquidFloatArray { + let best_exponents = get_best_exponents::(&arrow_array); + encode_arrow_array(&arrow_array, &best_exponents) + } +} + +impl LiquidArray for LiquidFloatArray +where + T: LiquidFloatType, +{ + fn as_any(&self) -> &dyn Any { + self + } + + fn get_array_memory_size(&self) -> usize { + self.get_array_memory_size() + } + + fn len(&self) -> usize { + self.len() + } + + #[inline] + fn to_arrow_array(&self) -> ArrayRef { + let unsigned_array = self.bit_packed.to_primitive(); + let (_data_type, values, _nulls) = unsigned_array.into_parts(); + let nulls = self.bit_packed.nulls(); + // TODO(): Check if we should align vectors to cache line boundary + let mut decoded_values = Vec::from_iter(values.iter().map(|v| { + let mut val: ::Native = (*v).as_(); + val = val.add_wrapping(self.reference_value); + T::decode_single(&val, &self.exponent) + })); + + // Patch values + if !self.patch_indices.is_empty() { + for i in 0..self.patch_indices.len() { + decoded_values[self.patch_indices[i].as_usize()] = self.patch_values[i]; + } + } + + Arc::new(PrimitiveArray::::new( + ScalarBuffer::<::Native>::from(decoded_values), + nulls.cloned(), + )) + } + + fn original_arrow_data_type(&self) -> DataType { + T::DATA_TYPE.clone() + } + + fn data_type(&self) -> LiquidDataType { + LiquidDataType::Float + } + + fn is_empty(&self) -> bool { + self.len() == 0 + } + + fn to_best_arrow_array(&self) -> ArrayRef { + self.to_arrow_array() + } + + fn try_eval_predicate(&self, predicate: &LiquidExpr, filter: &BooleanBuffer) -> BooleanArray { + let filtered = self.filter(filter); + eval_predicate_on_array(filtered, predicate) + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct Exponents { + pub(crate) e: u8, + pub(crate) f: u8, +} + +fn encode_arrow_array( + arrow_array: &PrimitiveArray, + exp: &Exponents, // fill_value: &mut Option<::Native> +) -> LiquidFloatArray { + let mut patch_indices: Vec = Vec::new(); + let mut patch_values: Vec = Vec::new(); + let mut patch_count: usize = 0; + let mut fill_value: Option<::Native> = None; + let values = arrow_array.values(); + let nulls = arrow_array.nulls(); + + // All values are null + if arrow_array.null_count() == arrow_array.len() { + return LiquidFloatArray:: { + bit_packed: BitPackedArray::new_null_array(arrow_array.len()), + exponent: Exponents { e: 0, f: 0 }, + patch_indices: Vec::new(), + patch_values: Vec::new(), + reference_value: ::Native::ZERO, + }; + } + + let mut encoded_values = Vec::with_capacity(arrow_array.len()); + for v in values.iter() { + let encoded = T::encode_single_unchecked(&v.as_(), exp); + let decoded = T::decode_single(&encoded, exp); + // TODO(): Check if this is a bitwise comparison + let neq = !decoded.eq(&v.as_()) as usize; + patch_count += neq; + encoded_values.push(encoded); + } + + if patch_count > 0 { + patch_indices.resize_with(patch_count + 1, Default::default); + patch_values.resize_with(patch_count + 1, Default::default); + let mut patch_index: usize = 0; + + for i in 0..encoded_values.len() { + let decoded = T::decode_single(&encoded_values[i], exp); + patch_indices[patch_index] = i.as_(); + patch_values[patch_index] = arrow_array.value(i).as_(); + patch_index += !(decoded.eq(&values[i].as_())) as usize; + } + assert_eq!(patch_index, patch_count); + unsafe { + patch_indices.set_len(patch_count); + patch_values.set_len(patch_count); + } + } + + // find the first successfully encoded value (i.e., not patched) + // this is our fill value for missing values + if patch_count > 0 && patch_count < arrow_array.len() { + for i in 0..encoded_values.len() { + if i >= patch_indices.len() || patch_indices[i] != i as u64 { + fill_value = encoded_values.get(i).copied(); + break; + } + } + } + + // replace the patched values in the encoded array with the fill value + // for better downstream compression + if let Some(fill_value) = fill_value { + // handle the edge case where the first N >= 1 chunks are all patches + for patch_idx in &patch_indices { + encoded_values[*patch_idx as usize] = fill_value; + } + } + + let min = *encoded_values + .iter() + .min() + .expect("`encoded_values` shouldn't be all nulls"); + let max = *encoded_values + .iter() + .max() + .expect("`encoded_values` shouldn't be all nulls"); + let sub: ::Native = max.sub_wrapping(min).as_(); + + let unsigned_encoded_values = encoded_values + .iter() + .map(|v| { + let k: ::Native = v.sub_wrapping(min).as_(); + k + }) + .collect::>(); + let encoded_output = PrimitiveArray::<::UnsignedIntType>::new( + ScalarBuffer::from(unsigned_encoded_values), + nulls.cloned(), + ); + + let bit_width = get_bit_width(sub.as_()); + let bit_packed_array = BitPackedArray::from_primitive(encoded_output, bit_width); + + LiquidFloatArray:: { + bit_packed: bit_packed_array, + exponent: *exp, + patch_indices, + patch_values, + reference_value: min, + } +} + +fn get_best_exponents(arrow_array: &PrimitiveArray) -> Exponents { + let mut best_exponents = Exponents { e: 0, f: 0 }; + let mut min_encoded_size: usize = usize::MAX; + + let sample_arrow_array: Option> = + (arrow_array.len() > NUM_SAMPLES).then(|| { + arrow_array + .iter() + .step_by(arrow_array.len() / NUM_SAMPLES) + .filter(|s| s.is_some()) + .collect() + }); + + for e in 0..T::MAX_EXPONENT { + for f in 0..e { + let exp = Exponents { e, f }; + let liquid_array = + encode_arrow_array(sample_arrow_array.as_ref().unwrap_or(arrow_array), &exp); + if liquid_array.get_array_memory_size() < min_encoded_size { + best_exponents = exp; + min_encoded_size = liquid_array.get_array_memory_size(); + } + } + } + best_exponents +} + +#[cfg(test)] +mod tests { + use super::*; + + macro_rules! test_roundtrip { + ($test_name: ident, $type:ty, $values: expr) => { + #[test] + fn $test_name() { + let original: Vec::Native>> = $values; + let array = PrimitiveArray::<$type>::from(original.clone()); + + // Convert to Liquid array and back + let liquid_array = LiquidFloatArray::<$type>::from_arrow_array(array.clone()); + let result_array = liquid_array.to_arrow_array(); + + assert_eq!(result_array.as_ref(), &array); + } + }; + } + + // Test cases for Float32 + test_roundtrip!( + test_float32_roundtrip_basic, + Float32Type, + vec![Some(-1.0), Some(1.0), Some(0.0)] + ); + + test_roundtrip!( + test_float32_roundtrip_with_nones, + Float32Type, + vec![Some(-1.0), Some(1.0), Some(0.0), None] + ); + + test_roundtrip!( + test_float32_roundtrip_all_nones, + Float32Type, + vec![None, None, None, None] + ); + + test_roundtrip!(test_float32_roundtrip_empty, Float32Type, vec![]); + + // Test cases for Float64 + test_roundtrip!( + test_float64_roundtrip_basic, + Float64Type, + vec![Some(-1.0), Some(1.0), Some(0.0)] + ); + + test_roundtrip!( + test_float64_roundtrip_with_nones, + Float64Type, + vec![Some(-1.0), Some(1.0), Some(0.0), None] + ); + + test_roundtrip!( + test_float64_roundtrip_all_nones, + Float64Type, + vec![None, None, None, None] + ); + + test_roundtrip!(test_float64_roundtrip_empty, Float64Type, vec![]); + + // Tests with ilters + #[test] + fn test_filter_basic() { + // Create original array with some values + let original = vec![Some(1.0), Some(2.1), Some(3.2), None, Some(5.5)]; + let array = PrimitiveArray::::from(original); + let liquid_array = LiquidFloatArray::::from_arrow_array(array); + + // Create selection mask: keep indices 0, 2, and 4 + let selection = BooleanBuffer::from(vec![true, false, true, false, true]); + + // Apply filter + let result_array = liquid_array.filter(&selection); + + // Expected result after filtering + let expected = PrimitiveArray::::from(vec![Some(1.0), Some(3.2), Some(5.5)]); + + assert_eq!(result_array.as_ref(), &expected); + } + + #[test] + fn test_original_arrow_data_type_returns_float32() { + let array = PrimitiveArray::::from(vec![Some(1.0), Some(2.5)]); + let liquid = LiquidFloatArray::::from_arrow_array(array); + assert_eq!(liquid.original_arrow_data_type(), DataType::Float32); + } + + #[test] + fn test_filter_all_nulls() { + // Create array with all nulls + let original = vec![None, None, None, None]; + let array = PrimitiveArray::::from(original); + let liquid_array = LiquidFloatArray::::from_arrow_array(array); + + // Keep first and last elements + let selection = BooleanBuffer::from(vec![true, false, false, true]); + + let result_array = liquid_array.filter(&selection); + + let expected = PrimitiveArray::::from(vec![None, None]); + + assert_eq!(result_array.as_ref(), &expected); + } + + #[test] + fn test_filter_empty_result() { + let original = vec![Some(1.0), Some(2.1), Some(3.3)]; + let array = PrimitiveArray::::from(original); + let liquid_array = LiquidFloatArray::::from_arrow_array(array); + + // Filter out all elements + let selection = BooleanBuffer::from(vec![false, false, false]); + + let result_array = liquid_array.filter(&selection); + + assert_eq!(result_array.len(), 0); + } + + #[test] + fn test_compression_f32_f64() { + fn run_compression_test( + type_name: &str, + data_fn: impl Fn(usize) -> T::Native, + ) { + let original: Vec = (0..2000).map(data_fn).collect(); + let array = PrimitiveArray::::from_iter_values(original); + let uncompressed_size = array.get_array_memory_size(); + + let liquid_array = LiquidFloatArray::::from_arrow_array(array); + let compressed_size = liquid_array.get_array_memory_size(); + + println!( + "Type: {type_name}, uncompressed_size: {uncompressed_size}, compressed_size: {compressed_size}" + ); + // Assert that compression actually reduced the size + assert!( + compressed_size < uncompressed_size, + "{type_name} compression failed to reduce size" + ); + } + + // Run for f32 + run_compression_test::("f32", |i| i as f32); + + // Run for f64 + run_compression_test::("f64", |i| i as f64); + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/liquid_array/linear_integer_array.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/liquid_array/linear_integer_array.rs new file mode 100644 index 0000000000000..695e8be37018b --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/liquid_array/linear_integer_array.rs @@ -0,0 +1,655 @@ +use std::any::Any; +use std::fmt::Debug; +use std::marker::PhantomData; +use std::sync::Arc; + +use super::PrimitiveKind; +use super::{LiquidArray, LiquidDataType, LiquidPrimitiveType}; +use crate::cache::LiquidExpr; +use crate::liquid_array::LiquidPrimitiveArray; +use crate::liquid_array::eval_predicate_on_array; +use arrow::array::{ + Array, ArrayRef, ArrowPrimitiveType, BooleanArray, PrimitiveArray, + cast::AsArray, + types::{ + Date32Type, Date64Type, Int8Type, Int16Type, Int32Type, Int64Type, UInt8Type, UInt16Type, + UInt32Type, UInt64Type, + }, +}; +use arrow::buffer::{BooleanBuffer, ScalarBuffer}; +use arrow::compute::kernels::filter; +use arrow_schema::DataType; +use num_traits::{AsPrimitive, Bounded, FromPrimitive}; + +/// A linear-model based integer array, **only use it when you know the array is monotonic** and **you don't care about encoding speed!**. +/// +/// Under the hood, it uses a linear model to predict the values and store the residuals: +/// value\[i\] = intercept + round(slope * i) + residual\[i\] +/// +/// Where `intercept` and `slope` are computed using a L-infinity linear fit, **this is time-consuming!**. +/// +/// This array is only recommended if you know the array follows a linear model, e.g., kinetic values, offsets, etc. +/// +/// Examples of not recommended use cases: random values like ids, categorical values, etc. +#[derive(Debug)] +pub struct LiquidLinearArray +where + T::Native: AsPrimitive + FromPrimitive + Bounded, +{ + // Signed residuals, bit-packed as a Liquid primitive array of i64. + residuals: LiquidPrimitiveArray, + // Intercept term stored as f64 for simpler math/IO. + intercept: f64, + // Slope term of the linear model. + slope: f64, + // Keep the logical type parameter. + _phantom: PhantomData, +} + +/// Backward-compatible alias for i32. +pub type LiquidLinearI32Array = LiquidLinearArray; +/// Linear-model array for `i8`. +pub type LiquidLinearI8Array = LiquidLinearArray; +/// Linear-model array for `i16`. +pub type LiquidLinearI16Array = LiquidLinearArray; +/// Linear-model array for `i64`. +pub type LiquidLinearI64Array = LiquidLinearArray; +/// Linear-model array for `u8`. +pub type LiquidLinearU8Array = LiquidLinearArray; +/// Linear-model array for `u16`. +pub type LiquidLinearU16Array = LiquidLinearArray; +/// Linear-model array for `u32`. +pub type LiquidLinearU32Array = LiquidLinearArray; +/// Linear-model array for `u64`. +pub type LiquidLinearU64Array = LiquidLinearArray; +/// Linear-model array for `Date32` (days since epoch). +pub type LiquidLinearDate32Array = LiquidLinearArray; +/// Linear-model array for `Date64` (ms since epoch). +pub type LiquidLinearDate64Array = LiquidLinearArray; + +impl LiquidLinearArray +where + T: LiquidPrimitiveType, + T::Native: AsPrimitive + FromPrimitive + Bounded, +{ + /// Build from an Arrow `PrimitiveArray` by training a linear model + /// using a fast L-infinity fit (Option 3) and storing residuals. + pub fn from_arrow_array(arrow_array: PrimitiveArray) -> Self { + let len = arrow_array.len(); + + // All nulls + if arrow_array.null_count() == len { + // All nulls + let res = PrimitiveArray::::new_null(len); + return Self { + residuals: LiquidPrimitiveArray::::from_arrow_array(res), + intercept: 0.0, // arbitrary, unused since all nulls + slope: 0.0, + _phantom: PhantomData, + }; + } + + // Prepare compact non-null buffers for fast fitting (avoid iterator Option cost). + let (nn_values, nn_indices) = collect_non_null_f64_and_indices::(&arrow_array); + + // Option 3 parameters: L-infinity (Chebyshev) regression. + let (mut intercept, mut slope) = fit_linf(&nn_values, &nn_indices); + + // Compute residuals in one unified loop; also track ranges for fallback decision. + let mut residuals: Vec = Vec::with_capacity(len); + let is_unsigned = ::IS_UNSIGNED; + let vals = arrow_array.values(); + let nulls_opt = arrow_array.nulls(); + + // Original value range + let mut orig_min_u64 = u64::MAX; + let mut orig_max_u64 = 0u64; + let mut orig_min_i64 = i64::MAX; + let mut orig_max_i64 = i64::MIN; + // Residual range + let mut res_min = i64::MAX; + let mut res_max = i64::MIN; + + if is_unsigned { + let max_u64: u64 = ::MAX_U64; + for i in 0..len { + let valid = nulls_opt.as_ref().is_none_or(|n| n.is_valid(i)); + if valid { + type U = + <::UnSignedType as ArrowPrimitiveType>::Native; + let v_u: U = vals[i].as_(); + let v_u64: u64 = v_u.as_(); + if v_u64 < orig_min_u64 { + orig_min_u64 = v_u64; + } + if v_u64 > orig_max_u64 { + orig_max_u64 = v_u64; + } + let pr = slope * (i as f64) + intercept; + let p = predict_u64_saturated(pr, max_u64); + let (pos, mag) = if v_u64 >= p { + (true, v_u64 - p) + } else { + (false, p - v_u64) + }; + let m = (mag & (i64::MAX as u64)) as i64; + let r = if pos { m } else { -m }; + if r < res_min { + res_min = r; + } + if r > res_max { + res_max = r; + } + residuals.push(r); + } else { + residuals.push(0); + } + } + } else { + let (min_i64, max_i64): (i64, i64) = + (::MIN_I64, ::MAX_I64); + for i in 0..len { + let valid = nulls_opt.as_ref().is_none_or(|n| n.is_valid(i)); + if valid { + let v_i64: i64 = vals[i].as_(); + if v_i64 < orig_min_i64 { + orig_min_i64 = v_i64; + } + if v_i64 > orig_max_i64 { + orig_max_i64 = v_i64; + } + let pr = slope * (i as f64) + intercept; + let p = predict_i64_saturated(pr, min_i64, max_i64); + let r = v_i64 - p; + if r < res_min { + res_min = r; + } + if r > res_max { + res_max = r; + } + residuals.push(r); + } else { + residuals.push(0); + } + } + } + + // Fallback: ensure residual range is strictly smaller than original range + let res_width: u128 = (res_max as i128 - res_min as i128) as u128; + let orig_width: u128 = if is_unsigned { + (orig_max_u64 as u128).saturating_sub(orig_min_u64 as u128) + } else { + (orig_max_i64 as i128 - orig_min_i64 as i128) as u128 + }; + if res_width >= orig_width { + // Rebuild residuals with zero model + intercept = 0.0; + slope = 0.0; + residuals.clear(); + if is_unsigned { + for i in 0..len { + let valid = nulls_opt.as_ref().is_none_or(|n| n.is_valid(i)); + if valid { + type U = <::UnSignedType as ArrowPrimitiveType>::Native; + let v_u: U = vals[i].as_(); + let v_u64: u64 = v_u.as_(); + let r = (v_u64 & (i64::MAX as u64)) as i64; + residuals.push(r); + } else { + residuals.push(0); + } + } + } else { + for i in 0..len { + let valid = nulls_opt.as_ref().is_none_or(|n| n.is_valid(i)); + if valid { + let v_i64: i64 = vals[i].as_(); + residuals.push(v_i64); + } else { + residuals.push(0); + } + } + } + } + let residuals_buf: ScalarBuffer = ScalarBuffer::from(residuals); + let nulls = arrow_array.nulls().cloned(); + let res_prim = PrimitiveArray::::new(residuals_buf, nulls); + let residuals = LiquidPrimitiveArray::::from_arrow_array(res_prim); + + Self { + residuals, + intercept, + slope, + _phantom: PhantomData, + } + } + + fn len(&self) -> usize { + self.residuals.len() + } +} + +impl LiquidArray for LiquidLinearArray +where + T: LiquidPrimitiveType, + T::Native: AsPrimitive + FromPrimitive + Bounded, +{ + fn as_any(&self) -> &dyn Any { + self + } + + fn original_arrow_data_type(&self) -> DataType { + T::DATA_TYPE.clone() + } + + fn get_array_memory_size(&self) -> usize { + self.residuals.get_array_memory_size() + + std::mem::size_of::() // intercept + + std::mem::size_of::() // slope + } + + fn len(&self) -> usize { + self.len() + } + + fn to_arrow_array(&self) -> ArrayRef { + let arr = self.residuals.to_arrow_array(); + let (_dt, residuals, nulls) = arr.as_primitive::().clone().into_parts(); + + // Reconstruct final values: predicted(i) +/- |residual_i| + let mut final_values = Vec::::with_capacity(self.len()); + let is_unsigned = ::IS_UNSIGNED; + if is_unsigned { + let max_u64: u64 = ::MAX_U64; + for (i, &e) in residuals.iter().enumerate() { + let pr = self.slope * (i as f64) + self.intercept; + let p = predict_u64_saturated(pr, max_u64); + let mag = e.unsigned_abs(); + let sum = if e >= 0 { + p.saturating_add(mag) + } else { + p.saturating_sub(mag) + }; + final_values.push(T::Native::from_u64(sum).unwrap()); + } + } else { + let (min_i64, max_i64): (i64, i64) = + (::MIN_I64, ::MAX_I64); + for (i, &e) in residuals.iter().enumerate() { + let pr = self.slope * (i as f64) + self.intercept; + let p = predict_i64_saturated(pr, min_i64, max_i64); + let sum = p.saturating_add(e); + final_values.push(T::Native::from_i64(sum).unwrap()); + } + } + + let values_buf: ScalarBuffer = ScalarBuffer::from(final_values); + Arc::new(PrimitiveArray::::new(values_buf, nulls)) + } + + fn filter(&self, selection: &BooleanBuffer) -> ArrayRef { + let arr = self.to_arrow_array(); + let selection = BooleanArray::new(selection.clone(), None); + filter::filter(&arr, &selection).unwrap() + } + + fn try_eval_predicate(&self, predicate: &LiquidExpr, filter: &BooleanBuffer) -> BooleanArray { + let arr = self.filter(filter); + eval_predicate_on_array(arr, predicate) + } + + fn data_type(&self) -> LiquidDataType { + LiquidDataType::LinearInteger + } +} + +#[inline] +fn predict_u64_saturated(pred: f64, max_u64: u64) -> u64 { + if !pred.is_finite() || pred <= 0.0 { + 0 + } else if pred >= max_u64 as f64 { + max_u64 + } else { + pred.round() as u64 + } +} + +#[inline] +fn predict_i64_saturated(pred: f64, min_i64: i64, max_i64: i64) -> i64 { + if !pred.is_finite() { + 0 + } else if pred <= min_i64 as f64 { + min_i64 + } else if pred >= max_i64 as f64 { + max_i64 + } else { + pred.round() as i64 + } +} + +/// L-infinity linear fit for y\[i\] ≈ intercept + slope * i (before rounding), +/// minimizing the maximum absolute error over all non-null points. +/// +/// Approach: minimize R(m) = max_i (y_i - m i) - min_i (y_i - m i), which is +/// convex in m. Given m, the best intercept is b = (max_i s_i + min_i s_i)/2 +/// where s_i = y_i - m i. We find m by a few rounds of bisection using the +/// subgradient sign derived from the argmax/argmin indices. O(n) per iteration. +fn fit_linf(values: &[f64], idxs: &[u32]) -> (f64, f64) { + let n = values.len(); + assert_eq!(values.len(), idxs.len()); + if n == 0 { + return (0.0, 0.0); + } + if n == 1 { + return (values[0], 0.0); + } + + let mut slope_min = f64::INFINITY; + let mut slope_max = f64::NEG_INFINITY; + for k in 1..n { + let di = (idxs[k] - idxs[k - 1]) as f64; + if di > 0.0 { + let dv = values[k] - values[k - 1]; + let s = dv / di; + if s < slope_min { + slope_min = s; + } + if s > slope_max { + slope_max = s; + } + } + } + if !slope_min.is_finite() || !slope_max.is_finite() { + slope_min = 0.0; + slope_max = 0.0; + } + + let mut lo = slope_min.min(slope_max); + let mut hi = slope_min.max(slope_max); + if (hi - lo).abs() < 1e-12 { + let pad = if hi.abs() < 1.0 { 1.0 } else { hi.abs() * 1e-6 }; + lo -= pad; + hi += pad; + } + + #[inline] + fn range_stats(values: &[f64], idxs: &[u32], m: f64) -> (f64, u32, f64, u32) { + let mut min_s = f64::INFINITY; + let mut max_s = f64::NEG_INFINITY; + let mut i_min = 0u32; + let mut i_max = 0u32; + for k in 0..values.len() { + let i = idxs[k] as f64; + let s = values[k] - m * i; + if s < min_s { + min_s = s; + i_min = idxs[k]; + } + if s > max_s { + max_s = s; + i_max = idxs[k]; + } + } + (min_s, i_min, max_s, i_max) + } + + const MAX_ITERS: usize = 8; + for _ in 0..MAX_ITERS { + let m = 0.5 * (lo + hi); + let (_min_s, i_min, _max_s, i_max) = range_stats(values, idxs, m); + let g = (i_min as i64) - (i_max as i64); + if g > 0 { + hi = m; + } else if g < 0 { + lo = m; + } else { + lo = m; + hi = m; + break; + } + if (hi - lo).abs() < 1e-12 { + break; + } + } + + let m = 0.5 * (lo + hi); + let (min_s, _i_min, max_s, _i_max) = range_stats(values, idxs, m); + let b = 0.5 * (max_s + min_s); + (b, m) +} + +#[inline] +fn collect_non_null_f64_and_indices(arr: &PrimitiveArray) -> (Vec, Vec) +where + T: LiquidPrimitiveType, + T::Native: AsPrimitive, +{ + let nn = arr.len() - arr.null_count(); + let mut values = Vec::with_capacity(nn); + let mut idxs = Vec::with_capacity(nn); + let vals = arr.values(); + if arr.null_count() == 0 { + for (i, v) in vals.iter().enumerate() { + values.push(v.as_()); + idxs.push(i as u32); + } + } else { + let nulls = arr.nulls().unwrap(); + for (i, v) in vals.iter().enumerate() { + if nulls.is_valid(i) { + values.push(v.as_()); + idxs.push(i as u32); + } + } + } + (values, idxs) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn roundtrip_eq(values: Vec>) { + let arr = PrimitiveArray::::from(values.clone()); + let linear = LiquidLinearI32Array::from_arrow_array(arr.clone()); + let decoded = linear.to_arrow_array(); + assert_eq!(decoded.as_ref(), &arr); + } + + macro_rules! roundtrip_eq_t { + ($T:ty, $values:expr) => {{ + let arr = PrimitiveArray::<$T>::from(($values).clone()); + let linear = LiquidLinearArray::<$T>::from_arrow_array(arr.clone()); + let decoded = linear.to_arrow_array(); + assert_eq!(decoded.as_ref(), &arr); + }}; + } + + #[test] + fn test_roundtrip_basic() { + // Non-monotonic values to ensure we don't rely on simple increasing sequences + roundtrip_eq(vec![ + Some(10), + Some(15), + Some(14), + Some(20), + Some(18), + Some(25), + Some(24), + ]); + } + + #[test] + fn test_roundtrip_with_nulls() { + roundtrip_eq(vec![Some(10), None, Some(30), None, Some(50), Some(70)]); + } + + #[test] + fn test_all_nulls() { + roundtrip_eq(vec![None, None, None, None]); + } + + #[test] + fn test_single_value() { + roundtrip_eq(vec![Some(42)]); + } + + #[test] + fn test_empty() { + roundtrip_eq(vec![]); + } + + #[test] + fn test_negative_values() { + roundtrip_eq(vec![ + Some(-100), + Some(-50), + Some(0), + Some(50), + Some(25), + None, + Some(-25), + ]); + } + + #[test] + fn test_filter_basic() { + let original: Vec> = vec![Some(1), Some(2), Some(3), None, Some(5), Some(8)]; + let arr = PrimitiveArray::::from(original.clone()); + let linear = LiquidLinearI32Array::from_arrow_array(arr); + let selection = BooleanBuffer::from(vec![true, false, true, false, true, false]); + let result = linear.filter(&selection); + let expected = PrimitiveArray::::from(vec![Some(1), Some(3), Some(5)]); + assert_eq!(result.as_ref(), &expected); + } + + #[test] + fn test_original_arrow_data_type_returns_int32() { + let arr = PrimitiveArray::::from(vec![Some(1), Some(2)]); + let linear = LiquidLinearI32Array::from_arrow_array(arr); + assert_eq!(linear.original_arrow_data_type(), DataType::Int32); + } + + #[test] + fn test_roundtrip_i8() { + roundtrip_eq_t!(Int8Type, vec![Some(-10), Some(0), Some(10), None, Some(20)]); + } + + #[test] + fn test_roundtrip_i16() { + roundtrip_eq_t!( + Int16Type, + vec![Some(-1000), Some(0), Some(1000), None, Some(2000)] + ); + } + + #[test] + fn test_roundtrip_i64() { + roundtrip_eq_t!( + Int64Type, + vec![ + Some(-10_000_000_000), + Some(0), + Some(10_000_000_000), + None, + Some(20_000_000_000), + ] + ); + } + + #[test] + fn test_roundtrip_u8() { + roundtrip_eq_t!( + UInt8Type, + vec![Some(0), Some(10), Some(200), None, Some(255)] + ); + } + + #[test] + fn test_roundtrip_u16() { + roundtrip_eq_t!( + UInt16Type, + vec![Some(0), Some(1000), Some(60000), None, Some(500)] + ); + } + + #[test] + fn test_roundtrip_u32() { + roundtrip_eq_t!( + UInt32Type, + vec![ + Some(0), + Some(1_000_000), + Some(3_000_000_000), + None, + Some(123_456_789), + ] + ); + } + + #[test] + fn test_roundtrip_u64() { + roundtrip_eq_t!( + UInt64Type, + vec![ + Some(0), + Some(10_000_000_000), + Some(9_000_000_000_000_000_000u64), + None, + Some(42), + ] + ); + } + + #[test] + fn test_roundtrip_date32() { + roundtrip_eq_t!( + Date32Type, + vec![Some(-365), Some(0), Some(365), None, Some(18262)] + ); + } + + #[test] + fn test_roundtrip_date64() { + roundtrip_eq_t!( + Date64Type, + vec![ + Some(-86_400_000), + Some(0), + Some(86_400_000), + None, + Some(1_000_000_000_000), + ] + ); + } + + #[test] + fn test_compression() { + let original = (0..1_000_000).step_by(100).collect::>(); + + let original = PrimitiveArray::::from_iter_values(original); + let arrow_size = original.get_array_memory_size(); + + let liquid_linear = LiquidLinearI32Array::from_arrow_array(original.clone()); + let liquid_linear_size = liquid_linear.get_array_memory_size(); + + let liquid_primitive = + LiquidPrimitiveArray::::from_arrow_array(original.clone()); + let liquid_primitive_size = liquid_primitive.get_array_memory_size(); + + println!( + "arrow_size: {arrow_size}, liquid_linear_size: {liquid_linear_size}, liquid_primitive_size: {liquid_primitive_size}", + ); + + assert!(liquid_linear_size < arrow_size); + assert!(liquid_primitive_size < arrow_size); + assert!(liquid_linear_size < liquid_primitive_size); + + let original: ArrayRef = Arc::new(original); + assert_eq!(original.as_ref(), liquid_linear.to_arrow_array().as_ref()); + assert_eq!( + original.as_ref(), + liquid_primitive.to_arrow_array().as_ref() + ); + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/liquid_array/mod.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/liquid_array/mod.rs new file mode 100644 index 0000000000000..d6b3d607b1982 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/liquid_array/mod.rs @@ -0,0 +1,173 @@ +//! LiquidArray is the core data structure of LiquidCache. +//! You should not use this module directly. +//! Instead, use `liquid_cache_datafusion_server` or `liquid_cache_datafusion_client` to interact with LiquidCache. +mod decimal_array; +mod float_array; +mod linear_integer_array; +mod primitive_array; +pub mod raw; + +use std::{any::Any, sync::Arc}; + +use arrow::{ + array::{ArrayRef, BooleanArray, cast::AsArray}, + buffer::BooleanBuffer, + record_batch::RecordBatch, +}; +use arrow_schema::{DataType, Field, Schema}; +pub use decimal_array::LiquidDecimalArray; +pub use float_array::{LiquidFloat32Array, LiquidFloat64Array, LiquidFloatArray}; +pub use linear_integer_array::{ + LiquidLinearArray, LiquidLinearDate32Array, LiquidLinearDate64Array, LiquidLinearI8Array, + LiquidLinearI16Array, LiquidLinearI32Array, LiquidLinearI64Array, LiquidLinearU8Array, + LiquidLinearU16Array, LiquidLinearU32Array, LiquidLinearU64Array, +}; +pub use primitive_array::{ + LiquidDate32Array, LiquidDate64Array, LiquidI8Array, LiquidI16Array, LiquidI32Array, + LiquidI64Array, LiquidPrimitiveArray, LiquidPrimitiveDeltaArray, LiquidPrimitiveType, + LiquidU8Array, LiquidU16Array, LiquidU32Array, LiquidU64Array, +}; + +use crate::cache::LiquidExpr; + +/// Liquid data type is only logical type +#[derive(Debug, Clone, Copy)] +#[repr(u16)] +pub enum LiquidDataType { + /// An integer. + Integer = 1, + /// A float. + Float = 2, + /// A linear-model based integer (signed residuals + model params). + LinearInteger = 5, + /// A decimal encoded as a primitive u64 array. + Decimal = 6, +} + +/// A Liquid array. +pub trait LiquidArray: std::fmt::Debug + Send + Sync { + /// Get the underlying any type. + fn as_any(&self) -> &dyn Any; + + /// Get the memory size of the Liquid array. + fn get_array_memory_size(&self) -> usize; + + /// Get the length of the Liquid array. + fn len(&self) -> usize; + + /// Check if the Liquid array is empty. + fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Convert the Liquid array to an Arrow array. + fn to_arrow_array(&self) -> ArrayRef; + + /// Convert the Liquid array to an Arrow array. + /// Except that it will pick the best encoding for the arrow array. + /// Meaning that it may not obey the data type of the original arrow array. + fn to_best_arrow_array(&self) -> ArrayRef { + self.to_arrow_array() + } + + /// Get the logical data type of the Liquid array. + fn data_type(&self) -> LiquidDataType; + + /// Get the original arrow data type of the Liquid array. + fn original_arrow_data_type(&self) -> DataType; + + /// Filter the Liquid array with a boolean array and return an **arrow array**. + fn filter(&self, selection: &BooleanBuffer) -> ArrayRef { + let arrow_array = self.to_arrow_array(); + let selection = BooleanArray::new(selection.clone(), None); + arrow::compute::kernels::filter::filter(&arrow_array, &selection).unwrap() + } + + /// Evaluate a predicate on the Liquid array with a filter. + /// + /// Note that the filter is a boolean buffer, not a boolean array, i.e., filter can't be nullable. + /// The returned boolean mask is nullable if the the original array is nullable. + fn try_eval_predicate(&self, predicate: &LiquidExpr, filter: &BooleanBuffer) -> BooleanArray { + let filtered = self.filter(filter); + eval_predicate_on_array(filtered, predicate) + } +} + +/// A reference to a Liquid array. +pub type LiquidArrayRef = Arc; + +pub(crate) fn eval_predicate_on_array(array: ArrayRef, predicate: &LiquidExpr) -> BooleanArray { + let schema = Arc::new(Schema::new(vec![Field::new( + "liquid_predicate_col", + array.data_type().clone(), + true, + )])); + let record_batch = RecordBatch::try_new(schema, vec![array]).expect("predicate input batch"); + let result = predicate + .physical_expr() + .evaluate(&record_batch) + .expect("validated LiquidExpr must evaluate"); + let boolean_array = result + .into_array(record_batch.num_rows()) + .expect("predicate output must be an array"); + boolean_array.as_boolean().clone() +} + +/// Compile-time info about primitive kind (signed vs unsigned) and bounds. +/// Implemented for all Liquid-supported primitive integer and date types. +pub trait PrimitiveKind { + /// Whether the logical type is unsigned (true for u8/u16/u32/u64). + const IS_UNSIGNED: bool; + /// Maximum representable value as u64 for unsigned types (unused for signed). + const MAX_U64: u64; + /// Minimum representable value as i64 for signed/date types (unused for unsigned). + const MIN_I64: i64; + /// Maximum representable value as i64 for signed/date types (unused for unsigned). + const MAX_I64: i64; +} + +macro_rules! impl_unsigned_kind { + ($t:ty, $max:expr) => { + impl PrimitiveKind for $t { + const IS_UNSIGNED: bool = true; + const MAX_U64: u64 = $max as u64; + const MIN_I64: i64 = 0; // unused + const MAX_I64: i64 = 0; // unused + } + }; +} + +macro_rules! impl_signed_kind { + ($t:ty, $min:expr, $max:expr) => { + impl PrimitiveKind for $t { + const IS_UNSIGNED: bool = false; + const MAX_U64: u64 = 0; // unused + const MIN_I64: i64 = $min as i64; + const MAX_I64: i64 = $max as i64; + } + }; +} + +use arrow::datatypes::{ + Date32Type, Date64Type, Int8Type, Int16Type, Int32Type, Int64Type, TimestampMicrosecondType, + TimestampMillisecondType, TimestampNanosecondType, TimestampSecondType, UInt8Type, UInt16Type, + UInt32Type, UInt64Type, +}; + +impl_unsigned_kind!(UInt8Type, u8::MAX); +impl_unsigned_kind!(UInt16Type, u16::MAX); +impl_unsigned_kind!(UInt32Type, u32::MAX); +impl_unsigned_kind!(UInt64Type, u64::MAX); + +impl_signed_kind!(Int8Type, i8::MIN, i8::MAX); +impl_signed_kind!(Int16Type, i16::MIN, i16::MAX); +impl_signed_kind!(Int32Type, i32::MIN, i32::MAX); +impl_signed_kind!(Int64Type, i64::MIN, i64::MAX); + +// Dates are logically signed in Arrow (Date32: i32 days, Date64: i64 ms) +impl_signed_kind!(Date32Type, i32::MIN, i32::MAX); +impl_signed_kind!(Date64Type, i64::MIN, i64::MAX); +impl_signed_kind!(TimestampSecondType, i64::MIN, i64::MAX); +impl_signed_kind!(TimestampMillisecondType, i64::MIN, i64::MAX); +impl_signed_kind!(TimestampMicrosecondType, i64::MIN, i64::MAX); +impl_signed_kind!(TimestampNanosecondType, i64::MIN, i64::MAX); diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/liquid_array/primitive_array.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/liquid_array/primitive_array.rs new file mode 100644 index 0000000000000..3eab53acb5f6a --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/liquid_array/primitive_array.rs @@ -0,0 +1,706 @@ +use std::any::Any; +use std::fmt::{Debug, Display}; +use std::sync::Arc; + +use arrow::array::{ + ArrayRef, ArrowNativeTypeOp, ArrowPrimitiveType, BooleanArray, PrimitiveArray, + types::{ + Date32Type, Date64Type, Int8Type, Int16Type, Int32Type, Int64Type, + TimestampMicrosecondType, TimestampMillisecondType, TimestampNanosecondType, + TimestampSecondType, UInt8Type, UInt16Type, UInt32Type, UInt64Type, + }, +}; +use arrow::buffer::{BooleanBuffer, ScalarBuffer}; +use arrow_schema::DataType; +use fastlanes::BitPacking; +use num_traits::{AsPrimitive, FromPrimitive}; + +use super::LiquidDataType; +use crate::cache::LiquidExpr; +use crate::liquid_array::raw::BitPackedArray; +use crate::liquid_array::{LiquidArray, PrimitiveKind, eval_predicate_on_array}; +use crate::utils::get_bit_width; +use arrow::datatypes::ArrowNativeType; + +mod private { + pub trait Sealed {} +} + +/// LiquidPrimitiveType is a sealed trait that represents the primitive types supported by Liquid. +/// Implemented for all supported integer, date, and timestamp Arrow primitive types. +/// +/// I have to admit this trait is super complicated. +/// Luckily users never have to worry about it, they can just use the types that are already implemented. +/// We could have implemented this as a macro, but macro is ugly. +/// Type is spec, code is proof. +pub trait LiquidPrimitiveType: + ArrowPrimitiveType< + Native: AsPrimitive<::Native> + + AsPrimitive + + FromPrimitive + + Display, + > + Debug + + Send + + Sync + + private::Sealed + + PrimitiveKind +{ + /// The unsigned type that can be used to represent the signed type. + type UnSignedType: ArrowPrimitiveType + AsPrimitive + BitPacking> + + Debug; +} + +macro_rules! impl_has_unsigned_type { + ($($signed:ty => $unsigned:ty),*) => { + $( + impl private::Sealed for $signed {} + impl LiquidPrimitiveType for $signed { + type UnSignedType = $unsigned; + } + )* + } +} + +impl_has_unsigned_type! { + Int32Type => UInt32Type, + Int64Type => UInt64Type, + Int16Type => UInt16Type, + Int8Type => UInt8Type, + UInt32Type => UInt32Type, + UInt64Type => UInt64Type, + UInt16Type => UInt16Type, + UInt8Type => UInt8Type, + Date64Type => UInt64Type, + Date32Type => UInt32Type, + TimestampSecondType => UInt64Type, + TimestampMillisecondType => UInt64Type, + TimestampMicrosecondType => UInt64Type, + TimestampNanosecondType => UInt64Type +} + +/// Liquid's unsigned 8-bit integer array. +pub type LiquidU8Array = LiquidPrimitiveArray; +/// Liquid's unsigned 16-bit integer array. +pub type LiquidU16Array = LiquidPrimitiveArray; +/// Liquid's unsigned 32-bit integer array. +pub type LiquidU32Array = LiquidPrimitiveArray; +/// Liquid's unsigned 64-bit integer array. +pub type LiquidU64Array = LiquidPrimitiveArray; +/// Liquid's signed 8-bit integer array. +pub type LiquidI8Array = LiquidPrimitiveArray; +/// Liquid's signed 16-bit integer array. +pub type LiquidI16Array = LiquidPrimitiveArray; +/// Liquid's signed 32-bit integer array. +pub type LiquidI32Array = LiquidPrimitiveArray; +/// Liquid's signed 64-bit integer array. +pub type LiquidI64Array = LiquidPrimitiveArray; +/// Liquid's 32-bit date array. +pub type LiquidDate32Array = LiquidPrimitiveArray; +/// Liquid's 64-bit date array. +pub type LiquidDate64Array = LiquidPrimitiveArray; + +/// Liquid's primitive array +#[derive(Debug)] +pub struct LiquidPrimitiveArray { + bit_packed: BitPackedArray, + reference_value: T::Native, +} + +/// Liquid's primitive array which uses delta encoding for compression +#[derive(Debug, Clone)] +pub struct LiquidPrimitiveDeltaArray { + bit_packed: BitPackedArray, + reference_value: T::Native, +} + +impl LiquidPrimitiveArray +where + T: LiquidPrimitiveType, +{ + /// Get the memory size of the Liquid primitive array. + pub fn get_array_memory_size(&self) -> usize { + self.bit_packed.get_array_memory_size() + std::mem::size_of::() + } + + /// Get the length of the Liquid primitive array. + pub fn len(&self) -> usize { + self.bit_packed.len() + } + + /// Check if the Liquid primitive array is empty. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Create a Liquid primitive array from an Arrow primitive array. + pub fn from_arrow_array(arrow_array: PrimitiveArray) -> LiquidPrimitiveArray { + let min = match arrow::compute::kernels::aggregate::min(&arrow_array) { + Some(v) => v, + None => { + // entire array is null + return Self { + bit_packed: BitPackedArray::new_null_array(arrow_array.len()), + reference_value: T::Native::ZERO, + }; + } + }; + let max = arrow::compute::kernels::aggregate::max(&arrow_array).unwrap(); + + // be careful of overflow: + // Want: 127i8 - (-128i8) -> 255u64, + // but we get -1i8 + // (-1i8) as u8 as u64 -> 255u64 + let sub = max.sub_wrapping(min) as ::Native; + let sub: <::UnSignedType as ArrowPrimitiveType>::Native = + sub.as_(); + let bit_width = get_bit_width(sub.as_()); + + let (_data_type, values, nulls) = arrow_array.clone().into_parts(); + let values = if min != T::Native::ZERO { + ScalarBuffer::from_iter(values.iter().map(|v| { + let k: <::UnSignedType as ArrowPrimitiveType>::Native = + v.sub_wrapping(min).as_(); + k + })) + } else { + #[allow(clippy::missing_transmute_annotations)] + unsafe { + std::mem::transmute(values) + } + }; + + let unsigned_array = + PrimitiveArray::<::UnSignedType>::new(values, nulls); + + let bit_packed_array = BitPackedArray::from_primitive(unsigned_array, bit_width); + + Self { + bit_packed: bit_packed_array, + reference_value: min, + } + } +} + +impl LiquidPrimitiveDeltaArray +where + T: LiquidPrimitiveType, +{ + /// Get the memory size of the Liquid primitive delta array. + pub fn get_array_memory_size(&self) -> usize { + self.bit_packed.get_array_memory_size() + std::mem::size_of::() + } + + /// Get the length of the Liquid primitive delta array. + pub fn len(&self) -> usize { + self.bit_packed.len() + } + + /// Check if the Liquid primitive delta array is empty. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Create a Liquid primitive delta array from an Arrow primitive array. + pub fn from_arrow_array(arrow_array: PrimitiveArray) -> LiquidPrimitiveDeltaArray { + use arrow::array::Array; + + let len = arrow_array.len(); + // check if entire array is already null + if arrow_array.null_count() == len { + return Self { + bit_packed: BitPackedArray::new_null_array(len), + reference_value: T::Native::ZERO, + }; + } + + let (_dt, values, nulls) = arrow_array.clone().into_parts(); + let vals: Vec = values.to_vec(); + + type UnsignedNative = + <::UnSignedType as ArrowPrimitiveType>::Native; + let mut out: Vec> = Vec::with_capacity(len); + let mut max_value: UnsignedNative = UnsignedNative::::ZERO; + let mut anchor: T::Native = T::Native::ZERO; + + if let Some(_nb) = &nulls { + // Nulls present: write 0 for nulls; prev will the last prev non-null value + let nb = nulls.as_ref().unwrap(); + let mut have_prev = false; + let mut prev: T::Native = T::Native::ZERO; + + for (i, &cur) in vals.iter().enumerate() { + if !nb.is_valid(i) { + out.push(UnsignedNative::::ZERO); + continue; + } + if !have_prev { + anchor = cur; + prev = cur; + have_prev = true; + out.push(UnsignedNative::::ZERO); + continue; + } + let delta: T::Native = cur.sub_wrapping(prev); + // zig zag encoding + let delta_i64: i64 = delta.as_(); + let zigzag: u64 = ((delta_i64 << 1) ^ (delta_i64 >> 63)) as u64; + let delta_unsigned: UnsignedNative = + UnsignedNative::::usize_as(zigzag as usize); + if delta_unsigned > max_value { + max_value = delta_unsigned; + } + out.push(delta_unsigned); + prev = cur; + } + } else { + // No nulls: first value is anchor, remainder are deltas with their previous values + anchor = vals[0]; + let mut prev: T::Native = anchor; + out.push(UnsignedNative::::ZERO); // anchor will have a difference of 0 + for &cur in vals.iter().skip(1) { + let delta: T::Native = cur.sub_wrapping(prev); + // zig zag encoding + let delta_i64: i64 = delta.as_(); + let zigzag: u64 = ((delta_i64 << 1) ^ (delta_i64 >> 63)) as u64; + let delta_unsigned: UnsignedNative = + UnsignedNative::::usize_as(zigzag as usize); + if delta_unsigned > max_value { + max_value = delta_unsigned; + } + out.push(delta_unsigned); + prev = cur; + } + } + + let bit_width = get_bit_width(max_value.as_()); + let values = ScalarBuffer::from_iter(out); + let unsigned_array = + PrimitiveArray::<::UnSignedType>::new(values, nulls); + let bit_packed_array = BitPackedArray::from_primitive(unsigned_array, bit_width); + + Self { + bit_packed: bit_packed_array, + reference_value: anchor, + } + } +} + +impl LiquidArray for LiquidPrimitiveArray +where + T: LiquidPrimitiveType + super::PrimitiveKind, +{ + fn get_array_memory_size(&self) -> usize { + self.get_array_memory_size() + } + + fn len(&self) -> usize { + self.len() + } + + fn original_arrow_data_type(&self) -> DataType { + T::DATA_TYPE.clone() + } + + fn as_any(&self) -> &dyn Any { + self + } + + #[inline] + fn to_arrow_array(&self) -> ArrayRef { + let unsigned_array = self.bit_packed.to_primitive(); + let (_data_type, values, _nulls) = unsigned_array.into_parts(); + let nulls = self.bit_packed.nulls(); + let values = if self.reference_value != T::Native::ZERO { + let reference_v = self.reference_value.as_(); + ScalarBuffer::from_iter(values.iter().map(|v| { + let k: ::Native = (*v).add_wrapping(reference_v).as_(); + k + })) + } else { + #[allow(clippy::missing_transmute_annotations)] + unsafe { + std::mem::transmute(values) + } + }; + + Arc::new(PrimitiveArray::::new(values, nulls.cloned())) + } + + fn filter(&self, selection: &BooleanBuffer) -> ArrayRef { + let arrow_array = self.to_arrow_array(); + let selection = BooleanArray::new(selection.clone(), None); + arrow::compute::kernels::filter::filter(&arrow_array, &selection).unwrap() + } + + fn try_eval_predicate(&self, predicate: &LiquidExpr, filter: &BooleanBuffer) -> BooleanArray { + let filtered = self.filter(filter); + eval_predicate_on_array(filtered, predicate) + } + + fn data_type(&self) -> LiquidDataType { + LiquidDataType::Integer + } +} + +impl LiquidArray for LiquidPrimitiveDeltaArray +where + T: LiquidPrimitiveType + super::PrimitiveKind, +{ + fn get_array_memory_size(&self) -> usize { + self.get_array_memory_size() + } + + fn len(&self) -> usize { + self.len() + } + + fn original_arrow_data_type(&self) -> DataType { + T::DATA_TYPE.clone() + } + + fn as_any(&self) -> &dyn Any { + self + } + + #[inline] + fn to_arrow_array(&self) -> ArrayRef { + // Reconstruct original values from deltas + let unsigned_array = self.bit_packed.to_primitive(); + let (_data_type, delta_values, _nulls) = unsigned_array.into_parts(); + let nulls = self.bit_packed.nulls(); + + // Reconstruct original values by applying deltas + let mut reconstructed = Vec::with_capacity(delta_values.len()); + let mut current_value = self.reference_value; // anchor + + if let Some(nulls) = nulls { + let mut have_prev = false; + for (i, &delta_unsigned) in delta_values.iter().enumerate() { + if !nulls.is_valid(i) { + reconstructed.push(T::Native::ZERO); // Will be masked out by nulls + continue; + } + if !have_prev { + // First non-null value is the anchor + reconstructed.push(current_value); + have_prev = true; + } else { + // Apply delta to get next value + let zigzag: u64 = delta_unsigned.as_(); + let delta_i64 = (zigzag >> 1) as i64 ^ -((zigzag & 1) as i64); + let delta: T::Native = T::Native::from_i64(delta_i64).unwrap(); + current_value = current_value.add_wrapping(delta); + reconstructed.push(current_value); + } + } + } else { + // No nulls case + reconstructed.push(current_value); // First value is anchor + for &delta_unsigned in delta_values.iter().skip(1) { + let zigzag: u64 = delta_unsigned.as_(); + let delta_i64 = (zigzag >> 1) as i64 ^ -((zigzag & 1) as i64); + let delta: T::Native = T::Native::from_i64(delta_i64).unwrap(); + current_value = current_value.add_wrapping(delta); + reconstructed.push(current_value); + } + } + + let values = ScalarBuffer::from_iter(reconstructed); + Arc::new(PrimitiveArray::::new(values, nulls.cloned())) + } + + fn filter(&self, selection: &BooleanBuffer) -> ArrayRef { + let arrow_array = self.to_arrow_array(); + let selection = BooleanArray::new(selection.clone(), None); + arrow::compute::kernels::filter::filter(&arrow_array, &selection).unwrap() + } + + fn try_eval_predicate(&self, predicate: &LiquidExpr, filter: &BooleanBuffer) -> BooleanArray { + let filtered = self.filter(filter); + eval_predicate_on_array(filtered, predicate) + } + + fn data_type(&self) -> LiquidDataType { + LiquidDataType::Integer + } +} + +#[cfg(test)] +mod tests { + use super::*; + use arrow::array::Array; + + macro_rules! test_roundtrip { + ($test_name:ident, $type:ty, $values:expr) => { + #[test] + fn $test_name() { + // Create the original array + let original: Vec::Native>> = $values; + let array = PrimitiveArray::<$type>::from(original.clone()); + + // Convert to Liquid array and back + let liquid_array = LiquidPrimitiveArray::<$type>::from_arrow_array(array.clone()); + let result_array = liquid_array.to_arrow_array(); + + assert_eq!(result_array.as_ref(), &array); + } + }; + } + + // Test cases for Int8Type + test_roundtrip!( + test_int8_roundtrip_basic, + Int8Type, + vec![Some(1), Some(2), Some(3), None, Some(5)] + ); + test_roundtrip!( + test_int8_roundtrip_negative, + Int8Type, + vec![Some(-128), Some(-64), Some(0), Some(63), Some(127)] + ); + + // Test cases for Int16Type + test_roundtrip!( + test_int16_roundtrip_basic, + Int16Type, + vec![Some(1), Some(2), Some(3), None, Some(5)] + ); + test_roundtrip!( + test_int16_roundtrip_negative, + Int16Type, + vec![ + Some(-32768), + Some(-16384), + Some(0), + Some(16383), + Some(32767) + ] + ); + + // Test cases for Int32Type + test_roundtrip!( + test_int32_roundtrip_basic, + Int32Type, + vec![Some(1), Some(2), Some(3), None, Some(5)] + ); + test_roundtrip!( + test_int32_roundtrip_negative, + Int32Type, + vec![ + Some(-2147483648), + Some(-1073741824), + Some(0), + Some(1073741823), + Some(2147483647) + ] + ); + + // Test cases for Int64Type + test_roundtrip!( + test_int64_roundtrip_basic, + Int64Type, + vec![Some(1), Some(2), Some(3), None, Some(5)] + ); + test_roundtrip!( + test_int64_roundtrip_negative, + Int64Type, + vec![ + Some(-9223372036854775808), + Some(-4611686018427387904), + Some(0), + Some(4611686018427387903), + Some(9223372036854775807) + ] + ); + + // Test cases for unsigned types + test_roundtrip!( + test_uint8_roundtrip, + UInt8Type, + vec![Some(0), Some(128), Some(255), None, Some(64)] + ); + test_roundtrip!( + test_uint16_roundtrip, + UInt16Type, + vec![Some(0), Some(32768), Some(65535), None, Some(16384)] + ); + test_roundtrip!( + test_uint32_roundtrip, + UInt32Type, + vec![ + Some(0), + Some(2147483648), + Some(4294967295), + None, + Some(1073741824) + ] + ); + test_roundtrip!( + test_uint64_roundtrip, + UInt64Type, + vec![ + Some(0), + Some(9223372036854775808), + Some(18446744073709551615), + None, + Some(4611686018427387904) + ] + ); + + test_roundtrip!( + test_date32_roundtrip, + Date32Type, + vec![Some(-365), Some(0), Some(365), None, Some(18262)] + ); + + test_roundtrip!( + test_date64_roundtrip, + Date64Type, + vec![Some(-365), Some(0), Some(365), None, Some(18262)] + ); + + // Edge cases + #[test] + fn test_all_nulls() { + let original: Vec> = vec![None, None, None]; + let array = PrimitiveArray::::from(original.clone()); + let liquid_array = LiquidPrimitiveArray::::from_arrow_array(array); + let result_array = liquid_array.to_arrow_array(); + + assert_eq!(result_array.len(), original.len()); + assert_eq!(result_array.null_count(), original.len()); + } + + #[test] + fn test_all_nulls_filter() { + let original: Vec> = vec![None, None, None]; + let array = PrimitiveArray::::from(original.clone()); + let liquid_array = LiquidPrimitiveArray::::from_arrow_array(array); + let result_array = liquid_array.filter(&BooleanBuffer::from(vec![true, false, true])); + + assert_eq!(result_array.len(), 2); + assert_eq!(result_array.null_count(), 2); + } + + #[test] + fn test_zero_reference_value() { + let original: Vec> = vec![Some(0), Some(1), Some(2), None, Some(4)]; + let array = PrimitiveArray::::from(original.clone()); + let liquid_array = LiquidPrimitiveArray::::from_arrow_array(array.clone()); + let result_array = liquid_array.to_arrow_array(); + + assert_eq!(liquid_array.reference_value, 0); + assert_eq!(result_array.as_ref(), &array); + } + + #[test] + fn test_single_value() { + let original: Vec> = vec![Some(42)]; + let array = PrimitiveArray::::from(original.clone()); + let liquid_array = LiquidPrimitiveArray::::from_arrow_array(array.clone()); + let result_array = liquid_array.to_arrow_array(); + + assert_eq!(result_array.as_ref(), &array); + } + + #[test] + fn test_filter_basic() { + // Create original array with some values + let original = vec![Some(1), Some(2), Some(3), None, Some(5)]; + let array = PrimitiveArray::::from(original); + let liquid_array = LiquidPrimitiveArray::::from_arrow_array(array); + + // Create selection mask: keep indices 0, 2, and 4 + let selection = BooleanBuffer::from(vec![true, false, true, false, true]); + + // Apply filter + let result_array = liquid_array.filter(&selection); + + // Expected result after filtering + let expected = PrimitiveArray::::from(vec![Some(1), Some(3), Some(5)]); + + assert_eq!(result_array.as_ref(), &expected); + } + + #[test] + fn test_original_arrow_data_type_returns_int32() { + let array = PrimitiveArray::::from(vec![Some(1), Some(2)]); + let liquid = LiquidPrimitiveArray::::from_arrow_array(array); + assert_eq!(liquid.original_arrow_data_type(), DataType::Int32); + } + + #[test] + fn test_filter_all_nulls() { + // Create array with all nulls + let original = vec![None, None, None, None]; + let array = PrimitiveArray::::from(original); + let liquid_array = LiquidPrimitiveArray::::from_arrow_array(array); + + // Keep first and last elements + let selection = BooleanBuffer::from(vec![true, false, false, true]); + + let result_array = liquid_array.filter(&selection); + + let expected = PrimitiveArray::::from(vec![None, None]); + + assert_eq!(result_array.as_ref(), &expected); + } + + #[test] + fn test_filter_empty_result() { + let original = vec![Some(1), Some(2), Some(3)]; + let array = PrimitiveArray::::from(original); + let liquid_array = LiquidPrimitiveArray::::from_arrow_array(array); + + // Filter out all elements + let selection = BooleanBuffer::from(vec![false, false, false]); + + let result_array = liquid_array.filter(&selection); + + assert_eq!(result_array.len(), 0); + } + + #[test] + fn test_delta_encoding_basic_roundtrip() { + let original = vec![Some(1), Some(3), Some(6), Some(10), Some(15)]; + let array = PrimitiveArray::::from(original.clone()); + + let liquid_delta = LiquidPrimitiveDeltaArray::::from_arrow_array(array.clone()); + let result_array = liquid_delta.to_arrow_array(); + + assert_eq!(result_array.as_ref(), &array); + } + + #[test] + fn test_delta_encoding_with_nulls() { + let original = vec![Some(1), None, Some(4), Some(7), None, Some(12)]; + let array = PrimitiveArray::::from(original.clone()); + + let liquid_delta = LiquidPrimitiveDeltaArray::::from_arrow_array(array.clone()); + let result_array = liquid_delta.to_arrow_array(); + + assert_eq!(result_array.as_ref(), &array); + } + + #[test] + fn test_memory_comparison_sequential_data() { + // Sequential data: delta encoding performs better + let sequential_data: Vec> = (0..1000).map(Some).collect(); + let array = PrimitiveArray::::from(sequential_data); + + let liquid_regular = LiquidPrimitiveArray::::from_arrow_array(array.clone()); + let liquid_delta = LiquidPrimitiveDeltaArray::::from_arrow_array(array); + + let regular_size = liquid_regular.get_array_memory_size(); + let delta_size = liquid_delta.get_array_memory_size(); + + println!( + "Sequential data - Regular: {} bytes, Delta: {} bytes", + regular_size, delta_size + ); + assert!( + delta_size <= regular_size, + "Delta encoding should be more efficient for sequential data" + ); + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/liquid_array/raw/bit_pack_array.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/liquid_array/raw/bit_pack_array.rs new file mode 100644 index 0000000000000..9c693616db3e3 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/liquid_array/raw/bit_pack_array.rs @@ -0,0 +1,347 @@ +use std::mem::size_of; +use std::num::NonZero; + +use arrow::array::{ArrowPrimitiveType, PrimitiveArray}; +use arrow::buffer::{Buffer, NullBuffer, ScalarBuffer}; +use arrow::datatypes::ArrowNativeType; +use fastlanes::BitPacking; + +/// A bit-packed array. +#[derive(Debug)] +pub struct BitPackedArray +where + T::Native: BitPacking, +{ + packed_values: ScalarBuffer, + nulls: Option, + bit_width: Option>, // if None, the array is entirely null + original_len: usize, +} + +/// Implement Clone for any T that implements ArrowPrimitiveType and BitPacking +/// This allows us to clone it without requiring T to implement Clone +impl Clone for BitPackedArray +where + T::Native: BitPacking, +{ + fn clone(&self) -> Self { + Self { + packed_values: self.packed_values.clone(), + nulls: self.nulls.clone(), + bit_width: self.bit_width, + original_len: self.original_len, + } + } +} + +impl BitPackedArray +where + T::Native: BitPacking, +{ + /// Creates a new null array with the given length. + pub fn new_null_array(len: usize) -> Self { + Self { + packed_values: vec![T::Native::usize_as(0); len].into(), + nulls: Some(NullBuffer::new_null(len)), + bit_width: None, + original_len: len, + } + } + + pub(crate) fn len(&self) -> usize { + self.original_len + } + + pub(crate) fn nulls(&self) -> Option<&NullBuffer> { + self.nulls.as_ref() + } + + /// Returns true if the array is nullable. + #[cfg(test)] + fn is_nullable(&self) -> bool { + self.nulls.is_some() + } + + /// Creates a new bit-packed array from a primitive array and a bit width. + pub fn from_primitive(array: PrimitiveArray, bit_width: NonZero) -> Self { + let original_len = array.len(); + let (_data_type, values, nulls) = array.into_parts(); + + let bit_width_usize = bit_width.get() as usize; + let num_chunks = original_len.div_ceil(1024); + let num_full_chunks = original_len / 1024; + let packed_len = (1024 * bit_width_usize).div_ceil(size_of::() * 8); + + let mut output = Vec::::with_capacity(num_chunks * packed_len); + + (0..num_full_chunks).for_each(|i| { + let start_elem = i * 1024; + + output.reserve(packed_len); + let output_len = output.len(); + unsafe { + output.set_len(output_len + packed_len); + BitPacking::unchecked_pack( + bit_width_usize, + &values[start_elem..][..1024], + &mut output[output_len..][..packed_len], + ); + } + }); + + if num_chunks != num_full_chunks { + let last_chunk_size = values.len() % 1024; + let mut last_chunk = vec![T::Native::default(); 1024]; + last_chunk[..last_chunk_size] + .copy_from_slice(&values[values.len() - last_chunk_size..]); + + output.reserve(packed_len); + let output_len = output.len(); + unsafe { + output.set_len(output_len + packed_len); + BitPacking::unchecked_pack( + bit_width_usize, + &last_chunk, + &mut output[output_len..][..packed_len], + ); + } + } + + let buffer = Buffer::from(output); + let scalar_buffer = ScalarBuffer::new(buffer, 0, num_chunks * packed_len); + + Self { + packed_values: scalar_buffer, + nulls, + bit_width: Some(bit_width), + original_len, + } + } + + /// Converts the bit-packed array to a primitive array. + pub fn to_primitive(&self) -> PrimitiveArray { + // Special case for all nulls, don't unpack + let bit_width = if let Some(bit_width) = self.bit_width { + bit_width.get() as usize + } else { + return PrimitiveArray::::new_null(self.original_len); + }; + let packed = self.packed_values.as_ref(); + let length = self.original_len; + let offset = 0; + + let num_chunks = (offset + length).div_ceil(1024); + let elements_per_chunk = (1024 * bit_width).div_ceil(size_of::() * 8); + + let mut output = Vec::::with_capacity(num_chunks * 1024 - offset); + + let first_full_chunk = if offset != 0 { + let chunk: &[T::Native] = &packed[0..elements_per_chunk]; + let mut decoded = vec![T::Native::default(); 1024]; + unsafe { BitPacking::unchecked_unpack(bit_width, chunk, &mut decoded) }; + output.extend_from_slice(&decoded[offset..]); + 1 + } else { + 0 + }; + + (first_full_chunk..num_chunks).for_each(|i| { + let chunk: &[T::Native] = &packed[i * elements_per_chunk..][0..elements_per_chunk]; + unsafe { + let output_len = output.len(); + output.set_len(output_len + 1024); + BitPacking::unchecked_unpack(bit_width, chunk, &mut output[output_len..][..1024]); + } + }); + + output.truncate(length); + if output.len() < 1024 { + output.shrink_to_fit(); + } + + let nulls = self.nulls.clone(); + PrimitiveArray::::new(ScalarBuffer::from(output), nulls) + } + + /// Returns the memory size of the bit-packed array. + pub fn get_array_memory_size(&self) -> usize { + std::mem::size_of::() + + self.packed_values.inner().capacity() + + self + .nulls + .as_ref() + .map_or(0, |nulls| nulls.buffer().capacity()) + } +} + +#[allow(dead_code)] +fn best_arrow_primitive_width(bit_width: NonZero) -> usize { + match bit_width.get() { + 0..=8 => 8, + 9..=16 => 16, + 17..=32 => 32, + 33..=64 => 64, + _ => panic!("Unsupported bit width: {}", bit_width.get()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use arrow::{ + array::Array, + datatypes::{UInt16Type, UInt32Type}, + }; + + #[test] + fn test_bit_pack_roundtrip() { + // Test with a full chunk (1024 elements) + let values: Vec = (0..1024).collect(); + + let array = PrimitiveArray::::from(values); + let before_size = array.get_array_memory_size(); + let bit_packed = BitPackedArray::from_primitive(array, NonZero::new(10).unwrap()); + let after_size = bit_packed.get_array_memory_size(); + println!("before: {before_size}, after: {after_size}"); + let unpacked = bit_packed.to_primitive(); + + assert_eq!(unpacked.len(), 1024); + for i in 0..1024 { + assert_eq!(unpacked.value(i), i as u32); + } + } + + #[test] + fn test_bit_pack_partial_chunk() { + // Test with a partial chunk (500 elements) + let values: Vec = (0..500).collect(); + let array = PrimitiveArray::::from(values); + let bit_packed = BitPackedArray::from_primitive(array, NonZero::new(10).unwrap()); + let unpacked = bit_packed.to_primitive(); + + assert_eq!(unpacked.len(), 500); + for i in 0..500 { + assert_eq!(unpacked.value(i), i as u32); + } + } + + #[test] + fn test_bit_pack_multiple_chunks() { + // Test with multiple chunks (2048 elements = 2 full chunks) + let values: Vec = (0..2048).collect(); + let array = PrimitiveArray::::from(values); + let bit_packed = BitPackedArray::from_primitive(array, NonZero::new(11).unwrap()); + let unpacked = bit_packed.to_primitive(); + + assert_eq!(unpacked.len(), 2048); + for i in 0..2048 { + assert_eq!(unpacked.value(i), i as u32); + } + } + + #[test] + fn test_bit_pack_with_nulls() { + let values: Vec> = (0..1000) + .map(|i| if i % 2 == 0 { Some(i as u32) } else { None }) + .collect(); + let array = PrimitiveArray::::from(values); + let bit_packed = BitPackedArray::from_primitive(array, NonZero::new(10).unwrap()); + let unpacked = bit_packed.to_primitive(); + + assert_eq!(unpacked.len(), 1000); + for i in 0..1000_usize { + if i.is_multiple_of(2) { + assert_eq!(unpacked.value(i), i as u32); + } else { + assert!(unpacked.is_null(i)); + } + } + } + + #[test] + fn test_different_bit_widths() { + // Test with different bit widths + let values: Vec = (0..100).map(|i| i * 2).collect(); + let array = PrimitiveArray::::from(values); + + for bit_width in [8, 16, 24, 32] { + let bit_packed = + BitPackedArray::from_primitive(array.clone(), NonZero::new(bit_width).unwrap()); + let unpacked = bit_packed.to_primitive(); + + assert_eq!(unpacked.len(), 100); + for i in 0..100 { + assert_eq!(unpacked.value(i), i as u32 * 2); + } + } + } + + #[test] + fn test_roundtrip_with_nulls_and_offset() { + let values: Vec> = (0..32) + .map(|i| if i % 3 == 0 { None } else { Some(i as u16) }) + .collect(); + let array = PrimitiveArray::::from(values); + + // Slice to create a non-zero offset (and therefore a non-zero null bitmap bit offset). + let sliced = array.slice(1, 23); + + let bit_width = NonZero::new(16).unwrap(); + let original = BitPackedArray::from_primitive(sliced.clone(), bit_width); + assert!(original.is_nullable()); + + let roundtripped = original.to_primitive(); + assert_eq!(roundtripped, sliced); + } + + #[test] + fn test_memory_size_calculation() { + use super::*; + use arrow::buffer::{Buffer, NullBuffer, ScalarBuffer}; + use arrow::datatypes::UInt32Type; + + let scalar_buffer = ScalarBuffer::::new(Buffer::from(vec![0; 1024]), 0, 1024); + + // --- Test without nulls --- + let bit_packed_no_nulls = BitPackedArray:: { + packed_values: scalar_buffer.clone(), + nulls: None, + bit_width: Some(NonZero::new(10).unwrap()), + original_len: 1024, + }; + + let expected_size_no_nulls = + size_of::>() + scalar_buffer.inner().capacity(); + assert_eq!( + bit_packed_no_nulls.get_array_memory_size(), + expected_size_no_nulls, + "Memory size mismatch without nulls" + ); + + // --- Test with nulls --- + // Create dummy null buffer + let null_buffer = NullBuffer::new_null(1024); + let nulls = Some(null_buffer); + + let bit_packed_with_nulls = BitPackedArray:: { + packed_values: scalar_buffer.clone(), + nulls: nulls.clone(), // Clone the Option + bit_width: Some(NonZero::new(10).unwrap()), + original_len: 1024, + }; + + // Calculate expected size including null buffer + // Note: Arrow's Buffer might allocate slightly more than null_bitmap_len_bytes + // We use the actual buffer capacity for a more precise comparison + let actual_null_buffer_size = nulls.as_ref().map_or(0, |nb| nb.buffer().capacity()); + let expected_size_with_nulls = size_of::>() + + scalar_buffer.inner().capacity() + + actual_null_buffer_size; + + assert_eq!( + bit_packed_with_nulls.get_array_memory_size(), + expected_size_with_nulls, + "Memory size mismatch with nulls" + ); + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/liquid_array/raw/mod.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/liquid_array/raw/mod.rs new file mode 100644 index 0000000000000..c7414ae8bc691 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/liquid_array/raw/mod.rs @@ -0,0 +1,5 @@ +//! Low level array primitives. +//! You should not use this module directly. +//! Instead, use `liquid_cache_datafusion_server` or `liquid_cache_datafusion_client` to interact with LiquidCache. +pub(super) mod bit_pack_array; +pub use bit_pack_array::BitPackedArray; diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/sync.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/sync.rs new file mode 100644 index 0000000000000..4e2a741253538 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/sync.rs @@ -0,0 +1,2 @@ +#[allow(unused_imports)] +pub use std::{sync::*, thread}; diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/utils/mod.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/utils/mod.rs new file mode 100644 index 0000000000000..3e8c51a66771b --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/core/src/utils/mod.rs @@ -0,0 +1,32 @@ +//! Utility functions for the storage module. + +use std::num::NonZero; + +use datafusion_common::ScalarValue; + +/// Get the bit width for a given max value. +/// Returns 1 if the max value is 0. +/// Returns 64 - max_value.leading_zeros() as u8 otherwise. +pub(crate) fn get_bit_width(max_value: u64) -> NonZero { + if max_value == 0 { + // todo: here we actually should return 0, as we should just use constant encoding. + // but that's not implemented yet. + NonZero::new(1).unwrap() + } else { + NonZero::new(64 - max_value.leading_zeros() as u8).unwrap() + } +} + +pub(crate) fn get_bytes_needle(value: &ScalarValue) -> Option> { + match value { + ScalarValue::Utf8(Some(v)) => Some(v.as_bytes().to_vec()), + ScalarValue::Utf8View(Some(v)) => Some(v.as_bytes().to_vec()), + ScalarValue::LargeUtf8(Some(v)) => Some(v.as_bytes().to_vec()), + ScalarValue::Binary(Some(v)) => Some(v.clone()), + ScalarValue::BinaryView(Some(v)) => Some(v.clone()), + ScalarValue::FixedSizeBinary(_, Some(v)) => Some(v.clone()), + ScalarValue::LargeBinary(Some(v)) => Some(v.clone()), + ScalarValue::Dictionary(_, value) => get_bytes_needle(value.as_ref()), + _ => None, + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/Cargo.toml b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/Cargo.toml new file mode 100644 index 0000000000000..80950a688c28d --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/Cargo.toml @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# Vendored in-memory subset of liquid-cache DataFusion integration. +# Provenance: https://github.com/cocosz/liquid-cache branch lc-opensearch-df54-v2 +# commit 8311bccc258756127adbebee3e54140b60fc09ba. See ../README.md. + +[package] +name = "opensearch-liquid-cache-datafusion" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +publish = false + +[lib] +# Keep the upstream crate name so the integration keeps its +# `use liquid_cache_datafusion::...` paths. See ../README.md. +name = "liquid_cache_datafusion" +path = "src/lib.rs" + +[dependencies] +ahash = { workspace = true } +arrow = { workspace = true } +arrow-schema = { workspace = true } +bytes = { workspace = true } +datafusion = { workspace = true } +futures = { workspace = true } +log = { workspace = true } +object_store = { workspace = true } +parquet = { workspace = true } +tokio = { workspace = true } +opensearch-liquid-cache-core = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/cache/column.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/cache/column.rs new file mode 100644 index 0000000000000..ea029cfb61e13 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/cache/column.rs @@ -0,0 +1,196 @@ +use arrow::{ + array::{Array, ArrayRef, BooleanArray}, + buffer::BooleanBuffer, + compute::prep_null_mask_filter, + record_batch::RecordBatch, +}; +use arrow_schema::{ArrowError, DataType, Field, Schema}; +use liquid_cache::cache::{CacheFull, LiquidCache, LiquidExpr}; +use parquet::arrow::arrow_reader::ArrowPredicate; + +use crate::{ + LiquidPredicate, + cache::{BatchID, ColumnAccessPath, ParquetArrayID}, +}; +use std::sync::Arc; + +/// A column in the cache. +#[derive(Debug)] +pub struct CachedColumn { + cache_store: Arc, + field: Arc, + column_path: ColumnAccessPath, + /// Whether this column is used in a predicate (WHERE clause). + /// In predicate-only mode, only predicate columns are cached. + is_predicate_column: bool, +} + +/// A reference to a cached column. +pub type CachedColumnRef = Arc; + +/// Error type for inserting an arrow array into the cache. +#[derive(Debug)] +pub enum InsertArrowArrayError { + /// The array is already cached. + AlreadyCached, + /// The cache does not have enough memory budget to accept the array. + CacheFull, +} + +impl From for InsertArrowArrayError { + fn from(_: CacheFull) -> Self { + Self::CacheFull + } +} + +impl CachedColumn { + pub(crate) fn new( + field: Arc, + cache_store: Arc, + column_access_path: ColumnAccessPath, + is_predicate_column: bool, + ) -> Self { + Self { + field, + cache_store, + column_path: column_access_path, + is_predicate_column, + } + } + + /// row_id must be on a batch boundary. + pub(crate) fn entry_id(&self, batch_id: BatchID) -> ParquetArrayID { + self.column_path.entry_id(batch_id) + } + + pub(crate) fn is_cached(&self, batch_id: BatchID) -> bool { + self.cache_store.is_cached(&self.entry_id(batch_id).into()) + } + + /// Returns the Arrow field metadata for this cached column. + pub fn field(&self) -> Arc { + self.field.clone() + } + + /// Returns whether this column is a predicate column (used in WHERE clause). + pub fn is_predicate_column(&self) -> bool { + self.is_predicate_column + } + + fn array_to_record_batch(&self, array: ArrayRef) -> RecordBatch { + let schema = Arc::new(Schema::new(vec![self.field.clone()])); + RecordBatch::try_new(schema, vec![array]).unwrap() + } + + /// Evaluates a predicate on a cached column. + pub fn eval_predicate_with_filter( + &self, + batch_id: BatchID, + filter: &BooleanBuffer, + predicate: &mut LiquidPredicate, + ) -> Option> { + let entry_id = self.entry_id(batch_id).into(); + let liquid_expr = LiquidExpr::try_new( + Arc::clone(predicate.physical_expr()), + self.field.data_type(), + ); + + if let Some(liquid_expr) = liquid_expr + && let Some(boolean_array) = self + .cache_store + .eval_predicate(&entry_id, &liquid_expr) + .with_selection(filter) + .read() + { + let predicate_filter = match boolean_array.null_count() { + 0 => boolean_array, + _ => prep_null_mask_filter(&boolean_array), + }; + return Some(Ok(predicate_filter)); + } + + let array = self.get_arrow_array_with_filter(batch_id, filter)?; + let record_batch = self.array_to_record_batch(array); + let boolean_array = match predicate.evaluate(record_batch) { + Ok(arr) => arr, + Err(err) => return Some(Err(err)), + }; + let predicate_filter = match boolean_array.null_count() { + 0 => boolean_array, + _ => prep_null_mask_filter(&boolean_array), + }; + Some(Ok(predicate_filter)) + } + + pub(crate) fn liquid_expr_for_predicate( + &self, + expr: Arc, + ) -> Option { + LiquidExpr::try_new(expr, self.field.data_type()) + } + + /// Get an arrow array with a filter applied. + /// Returns None for non-predicate columns or string predicate columns + /// (only numeric predicate columns are cached and served from cache). + pub fn get_arrow_array_with_filter( + &self, + batch_id: BatchID, + filter: &BooleanBuffer, + ) -> Option { + if !self.is_predicate_column || is_string_type(self.field.data_type()) { + return None; + } + let entry_id = self.entry_id(batch_id).into(); + let result = self + .cache_store + .get(&entry_id) + .with_selection(filter) + .read(); + if result.is_some() { + self.cache_store.observer().runtime_stats().incr_cache_hit(); + } else { + self.cache_store + .observer() + .runtime_stats() + .incr_cache_miss(); + } + result + } + + #[cfg(test)] + pub(crate) fn get_arrow_array_test_only(&self, batch_id: BatchID) -> Option { + let entry_id = self.entry_id(batch_id).into(); + self.cache_store.get(&entry_id).read() + } + + /// Insert an array into the cache. + /// Only numeric predicate columns are cached; string predicates and + /// non-predicate columns return CacheFull. + pub fn insert( + self: &Arc, + batch_id: BatchID, + array: ArrayRef, + ) -> Result<(), InsertArrowArrayError> { + if !self.is_predicate_column || is_string_type(self.field.data_type()) { + return Err(InsertArrowArrayError::CacheFull); + } + + if self.is_cached(batch_id) { + return Err(InsertArrowArrayError::AlreadyCached); + } + + self.cache_store + .insert(self.entry_id(batch_id).into(), array) + .execute()?; + Ok(()) + } +} + +fn is_string_type(data_type: &DataType) -> bool { + match data_type { + DataType::Utf8 | DataType::Utf8View | DataType::LargeUtf8 => true, + DataType::Binary | DataType::BinaryView | DataType::LargeBinary => true, + DataType::Dictionary(_, value_type) => is_string_type(value_type.as_ref()), + _ => false, + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/cache/id.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/cache/id.rs new file mode 100644 index 0000000000000..b52379dea05be --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/cache/id.rs @@ -0,0 +1,310 @@ +use std::ops::Deref; + +use liquid_cache::cache::EntryID; + +/// This is a unique identifier for a row in a parquet file. +#[repr(C, align(8))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)] +pub struct ParquetArrayID { + file_id: u16, + rg_id: u16, + col_id: u16, + batch_id: BatchID, +} + +impl From for usize { + fn from(id: ParquetArrayID) -> Self { + (id.file_id as usize) << 48 + | (id.rg_id as usize) << 32 + | (id.col_id as usize) << 16 + | (id.batch_id.v as usize) + } +} + +impl From for ParquetArrayID { + fn from(value: usize) -> Self { + Self { + file_id: (value >> 48) as u16, + rg_id: ((value >> 32) & 0xFFFF) as u16, + col_id: ((value >> 16) & 0xFFFF) as u16, + batch_id: BatchID::from_raw((value & 0xFFFF) as u16), + } + } +} + +impl ParquetArrayID {} + +impl From for EntryID { + fn from(id: ParquetArrayID) -> Self { + EntryID::from(usize::from(id)) + } +} + +impl From for ParquetArrayID { + fn from(id: EntryID) -> Self { + ParquetArrayID::from(usize::from(id)) + } +} + +const _: () = assert!(std::mem::size_of::() == 8); +const _: () = assert!(std::mem::align_of::() == 8); + +impl ParquetArrayID { + /// Creates a new CacheEntryID. + pub fn new(file_id: u64, row_group_id: u64, column_id: u64, batch_id: BatchID) -> Self { + debug_assert!(file_id <= u16::MAX as u64); + debug_assert!(row_group_id <= u16::MAX as u64); + debug_assert!(column_id <= u16::MAX as u64); + Self { + file_id: file_id as u16, + rg_id: row_group_id as u16, + col_id: column_id as u16, + batch_id, + } + } + + /// Get the batch id. + pub fn batch_id_inner(&self) -> u64 { + self.batch_id.v as u64 + } + + /// Get the file id. + pub fn file_id_inner(&self) -> u64 { + self.file_id as u64 + } + + /// Get the row group id. + pub fn row_group_id_inner(&self) -> u64 { + self.rg_id as u64 + } + + /// Get the column id. + pub fn column_id_inner(&self) -> u64 { + self.col_id as u64 + } + + /// Returns a human-readable string representation of this entry + /// (e.g. `file_1/rg_2/col_3/batch_4`). + pub fn display_path(&self) -> String { + format!( + "file_{}/rg_{}/col_{}/batch_{}", + self.file_id_inner(), + self.row_group_id_inner(), + self.column_id_inner(), + self.batch_id_inner() + ) + } +} + +/// BatchID is a unique identifier for a batch of rows, +/// it is row id divided by the batch size. +/// +// It's very easy to misinterpret this as row id, so we use new type idiom to avoid confusion: +// https://doc.rust-lang.org/rust-by-example/generics/new_types.html +#[repr(C, align(2))] +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Ord, PartialOrd)] +pub struct BatchID { + v: u16, +} + +impl BatchID { + /// Creates a new BatchID from a row id and a batch size. + /// The row id is at the boundary of the batch. + pub fn from_row_id(row_id: usize, batch_size: usize) -> Self { + Self { + v: (row_id / batch_size) as u16, + } + } + + /// Creates a new BatchID from a raw value. + pub fn from_raw(v: u16) -> Self { + Self { v } + } + + /// Increment the batch id. + pub fn inc(&mut self) { + debug_assert!(self.v < u16::MAX); + self.v += 1; + } +} + +impl Deref for BatchID { + type Target = u16; + + fn deref(&self) -> &Self::Target { + &self.v + } +} + +/// Column access path. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)] +pub struct ColumnAccessPath { + file_id: u16, + rg_id: u16, + col_id: u16, +} + +impl ColumnAccessPath { + /// Create a new instance of ColumnAccessPath. + pub fn new(file_id: u64, row_group_id: u64, column_id: u64) -> Self { + debug_assert!(file_id <= u16::MAX as u64); + debug_assert!(row_group_id <= u16::MAX as u64); + debug_assert!(column_id <= u16::MAX as u64); + Self { + file_id: file_id as u16, + rg_id: row_group_id as u16, + col_id: column_id as u16, + } + } + + /// Get the file id. + fn file_id_inner(&self) -> u64 { + self.file_id as u64 + } + + /// Get the row group id. + fn row_group_id_inner(&self) -> u64 { + self.rg_id as u64 + } + + /// Get the column id. + fn column_id_inner(&self) -> u64 { + self.col_id as u64 + } + + /// Get the entry id. + pub fn entry_id(&self, batch_id: BatchID) -> ParquetArrayID { + ParquetArrayID::new( + self.file_id_inner(), + self.row_group_id_inner(), + self.column_id_inner(), + batch_id, + ) + } +} + +impl From for ColumnAccessPath { + fn from(value: ParquetArrayID) -> Self { + Self { + file_id: value.file_id_inner() as u16, + rg_id: value.row_group_id_inner() as u16, + col_id: value.column_id_inner() as u16, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cache_entry_id_new_and_getters() { + let file_id = 10u64; + let row_group_id = 20u64; + let column_id = 30u64; + let batch_id = BatchID::from_raw(40); + let entry_id = ParquetArrayID::new(file_id, row_group_id, column_id, batch_id); + + assert_eq!(entry_id.file_id_inner(), file_id); + assert_eq!(entry_id.row_group_id_inner(), row_group_id); + assert_eq!(entry_id.column_id_inner(), column_id); + assert_eq!(entry_id.batch_id_inner(), *batch_id as u64); + } + + #[test] + fn test_cache_entry_id_boundaries() { + let file_id = u16::MAX as u64; + let row_group_id = 0u64; + let column_id = u16::MAX as u64; + let batch_id = BatchID::from_raw(0); + let entry_id = ParquetArrayID::new(file_id, row_group_id, column_id, batch_id); + + assert_eq!(entry_id.file_id_inner(), file_id); + assert_eq!(entry_id.row_group_id_inner(), row_group_id); + assert_eq!(entry_id.column_id_inner(), column_id); + assert_eq!(entry_id.batch_id_inner(), *batch_id as u64); + } + + #[test] + #[should_panic] + fn test_cache_entry_id_new_panic_file_id() { + ParquetArrayID::new((u16::MAX as u64) + 1, 0, 0, BatchID::from_raw(0)); + } + + #[test] + #[should_panic] + fn test_cache_entry_id_new_panic_row_group_id() { + ParquetArrayID::new(0, (u16::MAX as u64) + 1, 0, BatchID::from_raw(0)); + } + + #[test] + #[should_panic] + fn test_cache_entry_id_new_panic_column_id() { + ParquetArrayID::new(0, 0, (u16::MAX as u64) + 1, BatchID::from_raw(0)); + } + + #[test] + fn test_cache_entry_id_display_path() { + let entry_id = ParquetArrayID::new(1, 2, 3, BatchID::from_raw(4)); + assert_eq!(entry_id.display_path(), "file_1/rg_2/col_3/batch_4"); + } + + #[test] + fn test_batch_id_from_row_id() { + let batch_id = BatchID::from_row_id(256, 128); + assert_eq!(batch_id.v, 2); + } + + #[test] + fn test_batch_id_from_raw() { + let batch_id = BatchID::from_raw(5); + assert_eq!(batch_id.v, 5); + } + + #[test] + fn test_batch_id_inc() { + let mut batch_id = BatchID::from_raw(10); + batch_id.inc(); + assert_eq!(batch_id.v, 11); + } + + #[test] + #[should_panic] + fn test_batch_id_inc_overflow() { + let mut batch_id = BatchID::from_raw(u16::MAX); + // Should panic because incrementing exceeds u16::MAX + batch_id.inc(); + } + + #[test] + fn test_batch_id_deref() { + let batch_id = BatchID::from_raw(15); + assert_eq!(*batch_id, 15); + } + + #[test] + fn test_column_path_from_cache_entry_id() { + let entry_id = ParquetArrayID::new(1, 2, 3, BatchID::from_raw(4)); + let column_path: ColumnAccessPath = entry_id.into(); + + assert_eq!(column_path.file_id, 1); + assert_eq!(column_path.rg_id, 2); + assert_eq!(column_path.col_id, 3); + } + + #[test] + fn test_column_path_entry_id() { + let file_id = 5u64; + let row_group_id = 6u64; + let column_id = 7u64; + let column_path = ColumnAccessPath::new(file_id, row_group_id, column_id); + + let batch_id = BatchID::from_raw(8); + let entry_id = column_path.entry_id(batch_id); + + assert_eq!(entry_id.file_id_inner(), file_id); + assert_eq!(entry_id.row_group_id_inner(), row_group_id); + assert_eq!(entry_id.column_id_inner(), column_id); + assert_eq!(entry_id.batch_id_inner(), *batch_id as u64); + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/cache/mod.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/cache/mod.rs new file mode 100644 index 0000000000000..04f6f3161c885 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/cache/mod.rs @@ -0,0 +1,480 @@ +//! This module contains the cache implementation for the Parquet reader. +//! + +use crate::reader::{LiquidPredicate, extract_multi_column_or}; +use crate::sync::Mutex; +use ahash::AHashMap; +use arrow::array::{BooleanArray, RecordBatch}; +use arrow::buffer::BooleanBuffer; +use arrow_schema::{ArrowError, Field, Schema, SchemaRef}; +use liquid_cache::cache::{ + CachePolicy, CacheStats, LiquidCache, LiquidCacheBuilder, SqueezePolicy, +}; +use parquet::arrow::arrow_reader::ArrowPredicate; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +mod column; +mod id; +mod stats; + +pub use column::{CachedColumn, CachedColumnRef, InsertArrowArrayError}; +pub(crate) use id::ColumnAccessPath; +pub use id::{BatchID, ParquetArrayID}; + +#[derive(Default, Debug)] +struct ColumnMaps { + // invariant: Arc::ptr_eq(map[field.name()], map[field.id()]) + by_id: AHashMap, + by_name: AHashMap, +} + +/// A row group in the cache. +#[derive(Debug)] +pub struct CachedRowGroup { + columns: ColumnMaps, + cache_store: Arc, +} + +impl CachedRowGroup { + /// Create a new row group. + /// The column_ids are the indices of the columns in the file schema. + /// So they may not start from 0. + fn new( + cache_store: Arc, + row_group_idx: u64, + file_idx: u64, + columns: &[(u64, Arc, bool)], + ) -> Self { + let mut column_maps = ColumnMaps::default(); + for (column_id, field, is_predicate_column) in columns { + let column_access_path = ColumnAccessPath::new(file_idx, row_group_idx, *column_id); + let column = Arc::new(CachedColumn::new( + Arc::clone(field), + Arc::clone(&cache_store), + column_access_path, + *is_predicate_column, + )); + column_maps.by_id.insert(*column_id, column.clone()); + column_maps.by_name.insert(field.name().to_string(), column); + } + + Self { + columns: column_maps, + cache_store, + } + } + + /// Returns the batch size configured for this cached row group. + pub fn batch_size(&self) -> usize { + self.cache_store.config().batch_size() + } + + /// Get a column from the row group. + pub fn get_column(&self, column_id: u64) -> Option { + self.columns.by_id.get(&column_id).cloned() + } + + /// Get a column from the row group by its field name. + pub fn get_column_by_name(&self, column_name: &str) -> Option { + if let Some(column) = self.columns.by_name.get(column_name) { + return Some(column.clone()); + } + + // DataFusion may carry qualified names in physical expressions + // (e.g. "table.col"), while cache fields are keyed by file schema names. + let unqualified = column_name.rsplit('.').next().unwrap_or(column_name); + self.columns.by_name.get(unqualified).cloned() + } + + /// Evaluate a predicate on a row group. + pub fn evaluate_selection_with_predicate( + &self, + batch_id: BatchID, + selection: &BooleanBuffer, + predicate: &mut LiquidPredicate, + ) -> Option> { + let column_ids = predicate.predicate_column_ids(); + + if column_ids.len() == 1 { + // If we only have one column, we can short-circuit and try to evaluate the predicate on encoded data. + let column_id = column_ids[0]; + let cache = self.get_column(column_id as u64)?; + return cache.eval_predicate_with_filter(batch_id, selection, predicate); + } else if column_ids.len() >= 2 { + // Try to extract multiple column-literal expressions from OR structure + if let Some(column_exprs) = + extract_multi_column_or(predicate.physical_expr_physical_column_index()) + { + let mut combined_buffer: Option = None; + + for (col_name, expr) in column_exprs { + let column = self.get_column_by_name(col_name)?; + let liquid_expr = column.liquid_expr_for_predicate(Arc::clone(&expr)); + let liquid_expr = match liquid_expr { + Some(expr) => expr, + None => { + combined_buffer = None; + break; + } + }; + let entry_id = column.entry_id(batch_id).into(); + let liquid_array = self.cache_store.try_read_liquid(&entry_id); + let liquid_array = match liquid_array { + None => { + combined_buffer = None; + break; + } + Some(array) => array, + }; + let buffer = liquid_array.try_eval_predicate(&liquid_expr, selection); + + combined_buffer = Some(match combined_buffer { + None => buffer, + Some(existing) => { + arrow::compute::kernels::boolean::or_kleene(&existing, &buffer).ok()? + } + }); + } + + if let Some(result) = combined_buffer { + return Some(Ok(result)); + } + } + } + // Otherwise, we need to first convert the data into arrow arrays. + let mut arrays = Vec::new(); + let mut fields = Vec::new(); + for column_id in column_ids { + let column = self.get_column(column_id as u64)?; + let array = column.get_arrow_array_with_filter(batch_id, selection)?; + arrays.push(array); + fields.push(column.field()); + } + let schema = Arc::new(Schema::new(fields)); + let record_batch = RecordBatch::try_new(schema, arrays).unwrap(); + let boolean_array = predicate.evaluate(record_batch).unwrap(); + Some(Ok(boolean_array)) + } +} + +pub(crate) type CachedRowGroupRef = Arc; + +/// A file in the cache. +#[derive(Debug)] +pub struct CachedFile { + cache_store: Arc, + file_id: u64, + file_schema: SchemaRef, +} + +impl CachedFile { + fn new(cache_store: Arc, file_id: u64, file_schema: SchemaRef) -> Self { + Self { + cache_store, + file_id, + file_schema, + } + } + + /// Create a row group handle scoped to the current query context. + pub fn create_row_group( + &self, + row_group_id: u64, + predicate_column_ids: Vec, + ) -> CachedRowGroupRef { + let columns: Vec<(u64, Arc, bool)> = self + .file_schema + .fields() + .iter() + .enumerate() + .map(|(idx, field)| { + let is_predicate_column = predicate_column_ids.contains(&idx); + (idx as u64, Arc::clone(field), is_predicate_column) + }) + .collect(); + + Arc::new(CachedRowGroup::new( + self.cache_store.clone(), + row_group_id, + self.file_id, + &columns, + )) + } + + /// Return the configured cache batch size. + pub fn batch_size(&self) -> usize { + self.cache_store.config().batch_size() + } + + /// Return the full file schema tracked by the cache entry. + pub fn schema(&self) -> SchemaRef { + Arc::clone(&self.file_schema) + } +} + +/// A reference to a cached file. +pub(crate) type CachedFileRef = Arc; + +/// The main cache structure. +#[derive(Debug)] +pub struct LiquidCacheParquet { + /// Map file path to file id. + files: Mutex>, + + cache_store: Arc, + + current_file_id: AtomicU64, +} + +/// A reference to the main cache structure. +pub type LiquidCacheParquetRef = Arc; + +impl LiquidCacheParquet { + /// Create a new in-memory cache for parquet files. + pub fn new( + batch_size: usize, + max_memory_bytes: usize, + cache_policy: Box, + squeeze_policy: Box, + ) -> Self { + assert!(batch_size.is_power_of_two()); + let cache_storage = LiquidCacheBuilder::new() + .with_batch_size(batch_size) + .with_max_memory_bytes(max_memory_bytes) + .with_squeeze_policy(squeeze_policy) + .with_cache_policy(cache_policy) + .build(); + + LiquidCacheParquet { + files: Mutex::new(AHashMap::new()), + cache_store: cache_storage, + current_file_id: AtomicU64::new(0), + } + } + + /// Register a file in the cache. + pub fn register_or_get_file( + &self, + file_path: String, + full_file_schema: SchemaRef, + ) -> CachedFileRef { + let mut files = self.files.lock().unwrap(); + let file_id = *files + .entry(file_path.clone()) + .or_insert_with(|| self.current_file_id.fetch_add(1, Ordering::Relaxed)); + drop(files); + + Arc::new(CachedFile::new( + self.cache_store.clone(), + file_id, + full_file_schema, + )) + } + + /// Get the batch size of the cache. + pub fn batch_size(&self) -> usize { + self.cache_store.config().batch_size() + } + + /// Get the max memory bytes of the cache. + pub fn max_memory_bytes(&self) -> usize { + self.cache_store.config().max_memory_bytes() + } + + /// Get the memory usage of the cache in bytes. + pub fn memory_usage_bytes(&self) -> usize { + self.cache_store.budget().memory_usage_bytes() + } + + /// Get a snapshot of the cache statistics. + pub fn stats(&self) -> CacheStats { + self.cache_store.stats() + } + + /// Reset the cache. + /// + /// # Safety + /// This is unsafe because resetting the cache while other threads are using the cache may cause undefined behavior. + /// You should only call this when no one else is using the cache. + pub unsafe fn reset(&self) { + let mut files = self.files.lock().unwrap(); + files.clear(); + self.cache_store.reset(); + } + + /// Get the storage of the cache. + pub fn storage(&self) -> &Arc { + &self.cache_store + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cache::{CachedRowGroupRef, LiquidCacheParquet}; + use crate::reader::FilterCandidateBuilder; + use arrow::array::Int32Array; + use arrow::buffer::BooleanBuffer; + use arrow::datatypes::{DataType, Field, Schema}; + use arrow::record_batch::RecordBatch; + use datafusion::common::ScalarValue; + use datafusion::logical_expr::Operator; + use datafusion::physical_expr::PhysicalExpr; + use datafusion::physical_expr::expressions::{BinaryExpr, Literal}; + use datafusion::physical_plan::expressions::Column; + use liquid_cache::cache::TranscodeEvict; + use liquid_cache::cache_policies::LiquidPolicy; + use parquet::arrow::ArrowWriter; + use parquet::arrow::arrow_reader::{ArrowReaderMetadata, ArrowReaderOptions}; + use std::sync::Arc; + + fn setup_cache(batch_size: usize, schema: SchemaRef) -> CachedRowGroupRef { + let cache = LiquidCacheParquet::new( + batch_size, + usize::MAX, + Box::new(LiquidPolicy::new()), + Box::new(TranscodeEvict), + ); + let file = cache.register_or_get_file("test".to_string(), schema); + // Mark all columns as predicate columns so they are cacheable in tests. + let all_columns: Vec = (0..file.schema().fields().len()).collect(); + file.create_row_group(0, all_columns) + } + + #[test] + fn evaluate_or_on_cached_columns() { + let batch_size = 4; + + let schema = Arc::new(Schema::new(vec![ + Field::new("a", DataType::Int32, false), + Field::new("b", DataType::Int32, false), + ])); + let row_group = setup_cache(batch_size, schema.clone()); + + let col_a = row_group.get_column(0).unwrap(); + let col_b = row_group.get_column(1).unwrap(); + + let batch_id = BatchID::from_row_id(0, batch_size); + + let array_a = Arc::new(Int32Array::from(vec![1, 2, 3, 4])); + let array_b = Arc::new(Int32Array::from(vec![10, 20, 30, 40])); + + assert!(col_a.insert(batch_id, array_a.clone()).is_ok()); + assert!(col_b.insert(batch_id, array_b.clone()).is_ok()); + + // build parquet metadata for predicate construction + let tmp_meta = tempfile::NamedTempFile::new().unwrap(); + let mut writer = + ArrowWriter::try_new(tmp_meta.reopen().unwrap(), Arc::clone(&schema), None).unwrap(); + let batch = RecordBatch::try_new(Arc::clone(&schema), vec![array_a, array_b]).unwrap(); + writer.write(&batch).unwrap(); + writer.close().unwrap(); + let file_reader = std::fs::File::open(tmp_meta.path()).unwrap(); + let metadata = ArrowReaderMetadata::load(&file_reader, ArrowReaderOptions::new()).unwrap(); + + // expression a = 3 OR b = 20 + let expr_a: Arc = Arc::new(BinaryExpr::new( + Arc::new(Column::new("a", 0)), + Operator::Eq, + Arc::new(Literal::new(ScalarValue::Int32(Some(3)))), + )); + let expr_b: Arc = Arc::new(BinaryExpr::new( + Arc::new(Column::new("b", 1)), + Operator::Eq, + Arc::new(Literal::new(ScalarValue::Int32(Some(20)))), + )); + let expr: Arc = Arc::new(BinaryExpr::new(expr_a, Operator::Or, expr_b)); + + let builder = FilterCandidateBuilder::new(expr, Arc::clone(&schema)); + let candidate = builder.build(metadata.metadata()).unwrap().unwrap(); + let projection = candidate.projection(metadata.metadata()); + let mut predicate = LiquidPredicate::try_new(candidate, projection).unwrap(); + + let selection = BooleanBuffer::new_set(batch_size); + let result = row_group + .evaluate_selection_with_predicate(batch_id, &selection, &mut predicate) + .unwrap() + .unwrap(); + + let expected = BooleanBuffer::collect_bool(batch_size, |i| i == 1 || i == 2).into(); + assert_eq!(result, expected); + } + + #[test] + fn evaluate_three_column_or() { + let batch_size = 8; + + let schema = Arc::new(Schema::new(vec![ + Field::new("a", DataType::Int32, false), + Field::new("b", DataType::Int32, false), + Field::new("c", DataType::Int32, false), + ])); + + let row_group = setup_cache(batch_size, schema.clone()); + + let col_a = row_group.get_column(0).unwrap(); + let col_b = row_group.get_column(1).unwrap(); + let col_c = row_group.get_column(2).unwrap(); + + let batch_id = BatchID::from_row_id(0, batch_size); + + let array_a = Arc::new(Int32Array::from(vec![1, 2, 3, 4, 5, 6, 7, 8])); + let array_b = Arc::new(Int32Array::from(vec![10, 20, 30, 40, 50, 60, 70, 80])); + let array_c = Arc::new(Int32Array::from(vec![ + 100, 200, 300, 400, 500, 600, 700, 800, + ])); + + assert!(col_a.insert(batch_id, array_a.clone()).is_ok()); + assert!(col_b.insert(batch_id, array_b.clone()).is_ok()); + assert!(col_c.insert(batch_id, array_c.clone()).is_ok()); + + // build parquet metadata for predicate construction + let tmp_meta = tempfile::NamedTempFile::new().unwrap(); + let mut writer = + ArrowWriter::try_new(tmp_meta.reopen().unwrap(), Arc::clone(&schema), None).unwrap(); + let batch = + RecordBatch::try_new(Arc::clone(&schema), vec![array_a, array_b, array_c]).unwrap(); + writer.write(&batch).unwrap(); + writer.close().unwrap(); + let file_reader = std::fs::File::open(tmp_meta.path()).unwrap(); + let metadata = ArrowReaderMetadata::load(&file_reader, ArrowReaderOptions::new()).unwrap(); + + // expression: a = 2 OR b = 40 OR c = 600 + let expr_a: Arc = Arc::new(BinaryExpr::new( + Arc::new(Column::new("a", 0)), + Operator::Eq, + Arc::new(Literal::new(ScalarValue::Int32(Some(2)))), + )); + let expr_b: Arc = Arc::new(BinaryExpr::new( + Arc::new(Column::new("b", 1)), + Operator::Eq, + Arc::new(Literal::new(ScalarValue::Int32(Some(40)))), + )); + let expr_c: Arc = Arc::new(BinaryExpr::new( + Arc::new(Column::new("c", 2)), + Operator::Eq, + Arc::new(Literal::new(ScalarValue::Int32(Some(600)))), + )); + + // Build nested OR: (a = 2 OR b = 40) OR c = 600 + let expr_ab = Arc::new(BinaryExpr::new(expr_a, Operator::Or, expr_b)); + let expr: Arc = Arc::new(BinaryExpr::new(expr_ab, Operator::Or, expr_c)); + + let builder = FilterCandidateBuilder::new(expr, Arc::clone(&schema)); + let candidate = builder.build(metadata.metadata()).unwrap().unwrap(); + let projection = candidate.projection(metadata.metadata()); + let mut predicate = LiquidPredicate::try_new(candidate, projection).unwrap(); + + let selection = BooleanBuffer::new_set(batch_size); + let result = row_group + .evaluate_selection_with_predicate(batch_id, &selection, &mut predicate) + .unwrap() + .unwrap(); + + // Expected: row 1 (a=2), row 3 (b=40), row 5 (c=600) -> indices 1, 3, 5 + let expected = + BooleanBuffer::collect_bool(batch_size, |i| i == 1 || i == 3 || i == 5).into(); + assert_eq!(result, expected); + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/cache/stats.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/cache/stats.rs new file mode 100644 index 0000000000000..59191edee813a --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/cache/stats.rs @@ -0,0 +1,268 @@ +use super::LiquidCacheParquet; +use crate::{cache::id::ParquetArrayID, sync::Arc}; +use arrow::array::{ArrayBuilder, RecordBatch, StringBuilder, UInt64Builder}; +use arrow_schema::{DataType, Field, Schema, SchemaRef}; +use liquid_cache::cache::CacheEntry; +use parquet::{ + arrow::ArrowWriter, basic::Compression, errors::ParquetError, + file::properties::WriterProperties, +}; +use std::{fs::File, path::Path}; + +struct StatsWriter { + writer: ArrowWriter, + schema: SchemaRef, + file_path_builder: StringBuilder, + row_group_id_builder: UInt64Builder, + column_id_builder: UInt64Builder, + row_start_id_builder: UInt64Builder, + row_count_builder: UInt64Builder, + memory_size_builder: UInt64Builder, + cache_type_builder: StringBuilder, + reference_count_builder: UInt64Builder, +} + +impl StatsWriter { + fn new(file_path: impl AsRef) -> Result { + let schema = Arc::new(Schema::new(vec![ + Field::new("row_group_id", DataType::UInt64, false), + Field::new("column_id", DataType::UInt64, false), + Field::new("row_start_id", DataType::UInt64, false), + Field::new("row_count", DataType::UInt64, true), + Field::new("memory_size", DataType::UInt64, false), + Field::new("cache_type", DataType::Utf8, false), + Field::new("file_path", DataType::Utf8, false), + Field::new("reference_count", DataType::UInt64, false), + ])); + + let file = File::create(file_path)?; + let write_props = WriterProperties::builder() + .set_compression(Compression::LZ4) + .set_created_by("liquid-cache-stats".to_string()) + .build(); + let writer = ArrowWriter::try_new(file, schema.clone(), Some(write_props))?; + Ok(Self { + writer, + schema, + file_path_builder: StringBuilder::with_capacity(8192, 8192), + row_group_id_builder: UInt64Builder::new(), + column_id_builder: UInt64Builder::new(), + row_start_id_builder: UInt64Builder::new(), + row_count_builder: UInt64Builder::new(), + memory_size_builder: UInt64Builder::new(), + cache_type_builder: StringBuilder::with_capacity(8192, 8192), + reference_count_builder: UInt64Builder::new(), + }) + } + + fn build_batch(&mut self) -> Result { + let row_group_id_array = self.row_group_id_builder.finish(); + let column_id_array = self.column_id_builder.finish(); + let row_start_id_array = self.row_start_id_builder.finish(); + let row_count_array = self.row_count_builder.finish(); + let memory_size_array = self.memory_size_builder.finish(); + let cache_type_array = self.cache_type_builder.finish(); + let file_path_array = self.file_path_builder.finish(); + let reference_count_array = self.reference_count_builder.finish(); + Ok(RecordBatch::try_new( + self.schema.clone(), + vec![ + Arc::new(row_group_id_array), + Arc::new(column_id_array), + Arc::new(row_start_id_array), + Arc::new(row_count_array), + Arc::new(memory_size_array), + Arc::new(cache_type_array), + Arc::new(file_path_array), + Arc::new(reference_count_array), + ], + )?) + } + + #[allow(clippy::too_many_arguments)] + fn append_entry( + &mut self, + file_path: &str, + row_group_id: u64, + column_id: u64, + row_start_id: u64, + row_count: Option, + memory_size: u64, + cache_type: &str, + reference_count: u64, + ) -> Result<(), ParquetError> { + self.row_group_id_builder.append_value(row_group_id); + self.column_id_builder.append_value(column_id); + self.row_start_id_builder.append_value(row_start_id); + self.row_count_builder.append_option(row_count); + self.memory_size_builder.append_value(memory_size); + self.cache_type_builder.append_value(cache_type); + self.reference_count_builder.append_value(reference_count); + self.file_path_builder.append_value(file_path); + if self.row_start_id_builder.len() >= 8192 { + let batch = self.build_batch()?; + self.writer.write(&batch)?; + } + Ok(()) + } + + fn finish(mut self) -> Result<(), ParquetError> { + let batch = self.build_batch()?; + self.writer.write(&batch)?; + self.writer.close()?; + Ok(()) + } +} + +impl LiquidCacheParquet { + /// Get the memory usage of the cache in bytes. + pub fn compute_memory_usage_bytes(&self) -> u64 { + self.cache_store.budget().memory_usage_bytes() as u64 + } + + /// Write the stats of the cache to a parquet file. + pub fn write_stats(&self, parquet_file_path: impl AsRef) -> Result<(), ParquetError> { + let mut writer = StatsWriter::new(parquet_file_path)?; + self.cache_store.for_each_entry(|entry_id, cached_batch| { + let memory_size = cached_batch.memory_usage_bytes(); + let row_count = match cached_batch { + CacheEntry::MemoryArrow(array) => Some(array.len() as u64), + CacheEntry::MemoryLiquid(array) => Some(array.len() as u64), + }; + let cache_type = match cached_batch { + CacheEntry::MemoryArrow(_) => "InMemory", + CacheEntry::MemoryLiquid(_) => "LiquidMemory", + }; + let reference_count = cached_batch.reference_count(); + let entry_id = ParquetArrayID::from(*entry_id); + writer + .append_entry( + &entry_id.display_path(), + entry_id.row_group_id_inner(), + entry_id.column_id_inner(), + entry_id.batch_id_inner() * self.batch_size() as u64, + row_count, + memory_size as u64, + cache_type, + reference_count as u64, + ) + .unwrap(); + }); + + writer.finish()?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::io::Read; + + use crate::cache::id::BatchID; + + use super::*; + use arrow::{ + array::{Array, AsArray}, + datatypes::UInt64Type, + }; + use bytes::Bytes; + use liquid_cache::{cache::Evict, cache_policies::LiquidPolicy}; + use parquet::arrow::arrow_reader::ParquetRecordBatchReader; + use tempfile::NamedTempFile; + + #[test] + fn test_stats_writer() -> Result<(), ParquetError> { + let cache = LiquidCacheParquet::new( + 1024, + usize::MAX, + Box::new(LiquidPolicy::new()), + Box::new(Evict), + ); + let fields: Vec = (0..8) + .map(|i| Field::new(format!("test_{i}"), DataType::Int32, false)) + .collect(); + let schema = Arc::new(Schema::new(fields)); + let array = Arc::new(arrow::array::Int32Array::from(vec![1, 2, 3])); + let num_rows = 8 * 8 * 8 * 8; + + let mut row_group_id_sum = 0; + let mut column_id_sum = 0; + let mut row_start_id_sum = 0; + let mut row_count_sum = 0; + let mut memory_size_sum = 0; + for file_no in 0..8 { + let file_name = format!("test_{file_no}.parquet"); + let file = cache.register_or_get_file(file_name, schema.clone()); + for rg in 0..8 { + // Mark all columns as predicate columns so inserts are accepted. + let row_group = file.create_row_group(rg, (0..8).collect()); + for col in 0..8 { + let column = row_group.get_column(col).unwrap(); + for batch in 0..8 { + let batch_id = BatchID::from_raw(batch); + assert!(column.insert(batch_id, array.clone()).is_ok()); + row_group_id_sum += rg; + column_id_sum += col; + row_start_id_sum += *batch_id as u64 * cache.batch_size() as u64; + row_count_sum += array.len() as u64; + memory_size_sum += array.get_array_memory_size(); + + if batch.is_multiple_of(2) { + _ = column.get_arrow_array_test_only(batch_id).unwrap(); + } + } + } + } + } + + let mut tmp_file = NamedTempFile::new()?; + cache.write_stats(tmp_file.path())?; + + // Read and verify stats + let mut bytes = Vec::new(); + tmp_file.read_to_end(&mut bytes)?; + let bytes = Bytes::from(bytes); + let reader = ParquetRecordBatchReader::try_new(bytes, 8192)?; + + let batch = reader.into_iter().next().unwrap()?; + assert_eq!(batch.num_rows(), num_rows); + + macro_rules! uint64_col { + ($batch:expr, $col_idx:expr) => { + $batch + .column_by_name($col_idx) + .unwrap() + .as_primitive::() + }; + } + + let row_group_id_array = uint64_col!(batch, "row_group_id"); + let column_id_array = uint64_col!(batch, "column_id"); + let row_start_id_array = uint64_col!(batch, "row_start_id"); + let row_count_array = uint64_col!(batch, "row_count"); + let memory_size_array = uint64_col!(batch, "memory_size"); + + assert_eq!( + row_group_id_array.iter().map(|v| v.unwrap()).sum::(), + row_group_id_sum + ); + assert_eq!( + column_id_array.iter().map(|v| v.unwrap()).sum::(), + column_id_sum + ); + assert_eq!( + row_start_id_array.iter().map(|v| v.unwrap()).sum::(), + row_start_id_sum + ); + assert_eq!( + row_count_array.iter().map(|v| v.unwrap()).sum::(), + row_count_sum + ); + assert_eq!( + memory_size_array.iter().map(|v| v.unwrap()).sum::(), + memory_size_sum as u64 + ); + + Ok(()) + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/lib.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/lib.rs new file mode 100644 index 0000000000000..2c7356525d901 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/lib.rs @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +// Vendored in-memory subset of liquid-cache DataFusion integration. See ../README.md for provenance. + +//! DataFusion integration for the vendored in-memory liquid cache: +//! a Parquet [`FileSource`](datafusion::datasource::physical_plan::FileSource) +//! replacement that serves decoded batches from the cache, plus a physical +//! optimizer rule that rewrites `ParquetSource` scans to use it. +#![warn(missing_docs)] + +pub mod optimizers; +mod reader; +mod sync; +pub(crate) mod utils; + +pub mod cache; +pub use cache::{LiquidCacheParquet, LiquidCacheParquetRef}; +pub use optimizers::{LocalModeOptimizer, rewrite_data_source_plan}; +pub use reader::plantime::engagement_policy::{ + AlwaysEngagePolicy, CacheEngagementPolicy, DEFAULT_SELECTIVITY_THRESHOLD, EngagementContext, + EngagementDecision, NeverEngagePolicy, SelectivityThresholdPolicy, default_engagement_policy, +}; +pub use reader::plantime::source::pre_seed_metadata_cache; +pub use reader::{FilterCandidateBuilder, LiquidParquetSource, LiquidPredicate, LiquidRowFilter}; +pub use utils::boolean_buffer_and_then; diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/optimizers/mod.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/optimizers/mod.rs new file mode 100644 index 0000000000000..95896c6dc7993 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/optimizers/mod.rs @@ -0,0 +1,314 @@ +//! Optimizers for the Parquet module + +use std::sync::Arc; + +use datafusion::{ + catalog::memory::DataSourceExec, + common::tree_node::{Transformed, TreeNode, TreeNodeRecursion}, + config::ConfigOptions, + datasource::{ + physical_plan::{FileSource, ParquetSource}, + source::DataSource, + }, + physical_optimizer::PhysicalOptimizerRule, + physical_plan::ExecutionPlan, +}; + +use crate::{LiquidCacheParquetRef, LiquidParquetSource}; + +/// Physical optimizer rule for local mode liquid cache +/// +/// This optimizer rewrites DataSourceExec nodes that read Parquet files +/// to use LiquidParquetSource instead of the default ParquetSource +#[derive(Debug)] +pub struct LocalModeOptimizer { + cache: LiquidCacheParquetRef, +} + +impl LocalModeOptimizer { + /// Create an optimizer with an existing cache instance + pub fn new(cache: LiquidCacheParquetRef) -> Self { + Self { cache } + } + + /// Create an optimizer with an existing cache instance + pub fn with_cache(cache: LiquidCacheParquetRef) -> Self { + Self { cache } + } +} + +impl PhysicalOptimizerRule for LocalModeOptimizer { + fn optimize( + &self, + plan: Arc, + _config: &ConfigOptions, + ) -> Result, datafusion::error::DataFusionError> { + Ok(rewrite_data_source_plan(plan, &self.cache)) + } + + fn name(&self) -> &str { + "LocalModeLiquidCacheOptimizer" + } + + fn schema_check(&self) -> bool { + true + } +} + +/// Rewrite the data source plan to use liquid cache. +pub fn rewrite_data_source_plan( + plan: Arc, + cache: &LiquidCacheParquetRef, +) -> Arc { + let rewritten = plan + .transform_up(|node| try_optimize_parquet_source(node, cache)) + .unwrap(); + rewritten.data +} + +/// Returns true if a data type is uncacheable by LC (string/binary). +fn is_uncacheable_type(dt: &arrow_schema::DataType) -> bool { + use arrow_schema::DataType; + matches!( + dt, + DataType::Utf8 + | DataType::Utf8View + | DataType::LargeUtf8 + | DataType::Binary + | DataType::BinaryView + | DataType::LargeBinary + ) || matches!(dt, DataType::Dictionary(_, v) if is_uncacheable_type(v)) +} + +/// Max number of output columns for which LC wrapping is worthwhile. +/// Per-column cache overhead exceeds decode savings for wide projections. +const MAX_LC_COLUMNS: usize = 4; + +fn try_optimize_parquet_source( + plan: Arc, + cache: &LiquidCacheParquetRef, +) -> Result>, datafusion::error::DataFusionError> { + if let Some(data_source_exec) = plan.downcast_ref::() + && let Some((file_scan_config, parquet_source)) = + data_source_exec.downcast_to_file_source::() + { + // Skip LC wrapping if: + // - Output has zero columns (COUNT(*) — just needs row count from metadata) + // - Too many output columns (cache overhead exceeds decode savings) + // - ANY output column is string/binary (LC can't cache, fallback negates hits) + // - Predicate references a string column + let output_schema = plan.schema(); + if output_schema.fields().is_empty() { + log::debug!("[LC-Optimizer] SKIP: empty projection (COUNT(*))"); + return Ok(Transformed::no(plan)); + } + + if output_schema.fields().len() > MAX_LC_COLUMNS { + log::debug!( + "[LC-Optimizer] SKIP: too many columns ({} > {})", + output_schema.fields().len(), + MAX_LC_COLUMNS + ); + return Ok(Transformed::no(plan)); + } + + let has_string_output = output_schema + .fields() + .iter() + .any(|f| is_uncacheable_type(f.data_type())); + + let predicate_has_string = parquet_source.filter().is_some_and(|pred| { + use datafusion::physical_expr::utils::collect_columns; + let file_schema = file_scan_config.file_schema(); + let cols = collect_columns(&pred); + cols.iter().any(|col| { + file_schema + .fields() + .get(col.index()) + .is_some_and(|f| is_uncacheable_type(f.data_type())) + }) + }); + + if has_string_output || predicate_has_string { + log::debug!( + "[LC-Optimizer] SKIP: string_in_output={}, string_in_predicate={}, output_cols={}", + has_string_output, + predicate_has_string, + output_schema.fields().len() + ); + return Ok(Transformed::no(plan)); + } + + let num_fields = output_schema.fields().len(); + let has_predicate = parquet_source.filter().is_some(); + log::debug!( + "[LC-Optimizer] WRAP: all {} output columns cacheable, predicate={}", + num_fields, + has_predicate + ); + + let mut new_config = file_scan_config.clone(); + let new_source = + LiquidParquetSource::from_parquet_source(parquet_source.clone(), cache.clone()); + + new_config.file_source = Arc::new(new_source); + let new_file_source: Arc = Arc::new(new_config); + let new_plan = Arc::new(DataSourceExec::new(new_file_source)); + + return Ok(Transformed::new( + new_plan, + true, + TreeNodeRecursion::Continue, + )); + } + Ok(Transformed::no(plan)) +} + +#[cfg(test)] +mod tests { + use arrow::array::{Int32Array, RecordBatch, StringArray}; + use arrow_schema::{DataType, Field, Schema}; + use datafusion::{datasource::physical_plan::FileScanConfig, prelude::SessionContext}; + use liquid_cache::{cache::TranscodeEvict, cache_policies::LiquidPolicy}; + use parquet::arrow::ArrowWriter; + + use crate::LiquidCacheParquet; + + use super::*; + + fn test_cache() -> LiquidCacheParquetRef { + Arc::new(LiquidCacheParquet::new( + 8192, + 1_000_000, + Box::new(LiquidPolicy::new()), + Box::new(TranscodeEvict), + )) + } + + /// Write a small parquet file with numeric and string columns. + fn write_test_parquet(dir: &std::path::Path) -> String { + let schema = Arc::new(Schema::new(vec![ + Field::new("a", DataType::Int32, false), + Field::new("b", DataType::Int32, false), + Field::new("c", DataType::Int32, false), + Field::new("d", DataType::Int32, false), + Field::new("e", DataType::Int32, false), + Field::new("s", DataType::Utf8, false), + ])); + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from(vec![1, 2, 3, 4])), + Arc::new(Int32Array::from(vec![10, 20, 30, 40])), + Arc::new(Int32Array::from(vec![100, 200, 300, 400])), + Arc::new(Int32Array::from(vec![5, 6, 7, 8])), + Arc::new(Int32Array::from(vec![50, 60, 70, 80])), + Arc::new(StringArray::from(vec!["w", "x", "y", "z"])), + ], + ) + .unwrap(); + let path = dir.join("data.parquet"); + let file = std::fs::File::create(&path).unwrap(); + let mut writer = ArrowWriter::try_new(file, schema, None).unwrap(); + writer.write(&batch).unwrap(); + writer.close().unwrap(); + path.to_string_lossy().into_owned() + } + + fn count_sources(plan: &Arc) -> (usize, usize) { + let mut liquid = 0; + let mut parquet = 0; + plan.apply(|node| { + if let Some(exec) = node.downcast_ref::() { + let data_source = exec.data_source(); + if let Some(config) = data_source.downcast_ref::() { + let file_source = config.file_source(); + if file_source.downcast_ref::().is_some() { + liquid += 1; + } else if file_source.downcast_ref::().is_some() { + parquet += 1; + } + } + } + Ok(TreeNodeRecursion::Continue) + }) + .unwrap(); + (liquid, parquet) + } + + async fn plan_for_sql(sql: &str, path: &str) -> Arc { + let ctx = SessionContext::new(); + ctx.register_parquet("t", path, Default::default()) + .await + .unwrap(); + let df = ctx.sql(sql).await.unwrap(); + df.create_physical_plan().await.unwrap() + } + + #[tokio::test] + async fn test_plan_rewrite_wraps_numeric_scan() { + let tmp_dir = tempfile::tempdir().unwrap(); + let path = write_test_parquet(tmp_dir.path()); + let plan = plan_for_sql("SELECT a, b FROM t WHERE a > 1", &path).await; + let expected_schema = plan.schema(); + + let rewritten = rewrite_data_source_plan(plan, &test_cache()); + + let (liquid, parquet) = count_sources(&rewritten); + assert_eq!(liquid, 1); + assert_eq!(parquet, 0); + assert_eq!(rewritten.schema(), expected_schema); + } + + #[tokio::test] + async fn test_plan_rewrite_skips_string_output() { + let tmp_dir = tempfile::tempdir().unwrap(); + let path = write_test_parquet(tmp_dir.path()); + let plan = plan_for_sql("SELECT a, s FROM t WHERE a > 1", &path).await; + + let rewritten = rewrite_data_source_plan(plan, &test_cache()); + + let (liquid, parquet) = count_sources(&rewritten); + assert_eq!(liquid, 0); + assert_eq!(parquet, 1); + } + + #[tokio::test] + async fn test_plan_rewrite_skips_string_predicate() { + let tmp_dir = tempfile::tempdir().unwrap(); + let path = write_test_parquet(tmp_dir.path()); + let plan = plan_for_sql("SELECT a, b FROM t WHERE s = 'x'", &path).await; + + let rewritten = rewrite_data_source_plan(plan, &test_cache()); + + let (liquid, parquet) = count_sources(&rewritten); + assert_eq!(liquid, 0); + assert_eq!(parquet, 1); + } + + #[tokio::test] + async fn test_plan_rewrite_skips_wide_projection() { + let tmp_dir = tempfile::tempdir().unwrap(); + let path = write_test_parquet(tmp_dir.path()); + // 5 numeric columns > MAX_LC_COLUMNS (4) + let plan = plan_for_sql("SELECT a, b, c, d, e FROM t", &path).await; + + let rewritten = rewrite_data_source_plan(plan, &test_cache()); + + let (liquid, parquet) = count_sources(&rewritten); + assert_eq!(liquid, 0); + assert_eq!(parquet, 1); + } + + #[tokio::test] + async fn test_plan_rewrite_skips_empty_projection() { + let tmp_dir = tempfile::tempdir().unwrap(); + let path = write_test_parquet(tmp_dir.path()); + let plan = plan_for_sql("SELECT COUNT(*) FROM t", &path).await; + + let rewritten = rewrite_data_source_plan(plan, &test_cache()); + + let (liquid, _parquet) = count_sources(&rewritten); + assert_eq!(liquid, 0); + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/mod.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/mod.rs new file mode 100644 index 0000000000000..ac32eac7ebdd9 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/mod.rs @@ -0,0 +1,6 @@ +pub mod plantime; +mod runtime; +mod utils; + +pub use plantime::{FilterCandidateBuilder, LiquidParquetSource, LiquidPredicate, LiquidRowFilter}; +pub(crate) use runtime::extract_multi_column_or; diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/plantime/engagement_policy.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/plantime/engagement_policy.rs new file mode 100644 index 0000000000000..e8b2428777431 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/plantime/engagement_policy.rs @@ -0,0 +1,95 @@ +//! Cache engagement policy — decides per-file whether LC should serve +//! decoded batches from cache or delegate to plain parquet. + +use std::fmt::Debug; +use std::sync::Arc; + +/// Context passed to the policy at file-open time. +#[derive(Debug, Clone)] +pub struct EngagementContext { + /// Fraction of rows surviving RG + page pruning (0.0–1.0). + pub estimated_selectivity: f64, + /// Whether the query has a pushdown predicate on this file. + pub has_predicate: bool, + /// Total rows across selected row groups. + pub total_rows: usize, + /// Rows that survived pruning. + pub selected_rows: usize, + /// File path for logging. + pub file_path: String, +} + +/// Decision returned by the policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EngagementDecision { + /// Serve decoded batches from cache, fill on miss. + UseLiquidCache, + /// Bypass LC, delegate to plain parquet reader. + DelegateToParquet, +} + +/// Decides per-file whether Liquid Cache should engage or delegate. +pub trait CacheEngagementPolicy: Send + Sync + Debug { + /// Decide whether to use LC or delegate to plain parquet for this file. + fn decide(&self, ctx: &EngagementContext) -> EngagementDecision; +} + +/// Default selectivity threshold below which LC delegates to parquet. +pub const DEFAULT_SELECTIVITY_THRESHOLD: f64 = 0.5; + +/// Delegate when selectivity < threshold AND a predicate exists. +#[derive(Debug, Clone)] +pub struct SelectivityThresholdPolicy { + /// Selectivity below which LC delegates to plain parquet. + pub threshold: f64, +} + +impl Default for SelectivityThresholdPolicy { + fn default() -> Self { + Self { + threshold: DEFAULT_SELECTIVITY_THRESHOLD, + } + } +} + +impl SelectivityThresholdPolicy { + /// Create a policy with the given selectivity threshold. + pub fn new(threshold: f64) -> Self { + Self { threshold } + } +} + +impl CacheEngagementPolicy for SelectivityThresholdPolicy { + fn decide(&self, ctx: &EngagementContext) -> EngagementDecision { + if ctx.estimated_selectivity < self.threshold && ctx.has_predicate { + EngagementDecision::DelegateToParquet + } else { + EngagementDecision::UseLiquidCache + } + } +} + +/// Always use LC regardless of selectivity. +#[derive(Debug, Clone)] +pub struct AlwaysEngagePolicy; + +impl CacheEngagementPolicy for AlwaysEngagePolicy { + fn decide(&self, _ctx: &EngagementContext) -> EngagementDecision { + EngagementDecision::UseLiquidCache + } +} + +/// Never use LC — always delegate to plain parquet. +#[derive(Debug, Clone)] +pub struct NeverEngagePolicy; + +impl CacheEngagementPolicy for NeverEngagePolicy { + fn decide(&self, _ctx: &EngagementContext) -> EngagementDecision { + EngagementDecision::DelegateToParquet + } +} + +/// Returns the default engagement policy. +pub fn default_engagement_policy() -> Arc { + Arc::new(SelectivityThresholdPolicy::default()) +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/plantime/mod.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/plantime/mod.rs new file mode 100644 index 0000000000000..0f22e9ae5be9a --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/plantime/mod.rs @@ -0,0 +1,12 @@ +#[cfg(test)] +pub(crate) use source::CachedMetaReaderFactory; +pub use source::LiquidParquetSource; +pub(crate) use source::ParquetMetadataCacheReader; + +pub mod engagement_policy; +mod opener; +mod row_filter; +mod row_group_filter; +pub mod source; + +pub use row_filter::{FilterCandidateBuilder, LiquidPredicate, LiquidRowFilter}; diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/plantime/opener.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/plantime/opener.rs new file mode 100644 index 0000000000000..af0fac5393811 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/plantime/opener.rs @@ -0,0 +1,509 @@ +use std::sync::Arc; + +use crate::{ + cache::LiquidCacheParquetRef, + reader::{ + plantime::{ + engagement_policy::{CacheEngagementPolicy, EngagementContext, EngagementDecision}, + row_filter::build_row_filter, + row_group_filter::RowGroupAccessPlanFilter, + }, + runtime::LiquidStreamBuilder, + }, +}; +use arrow::array::{RecordBatch, RecordBatchOptions}; +use arrow_schema::SchemaRef; +use datafusion::{ + common::exec_err, + datasource::{ + listing::PartitionedFile, + physical_plan::{ + FileOpenFuture, FileOpener, ParquetFileMetrics, ParquetFileReaderFactory, + parquet::{PagePruningAccessPlanFilter, ParquetAccessPlan}, + }, + table_schema::TableSchema, + }, + error::DataFusionError, + physical_expr::PhysicalExprSimplifier, + physical_expr::projection::ProjectionExprs, + physical_expr::utils::reassign_expr_columns, + physical_expr_adapter::{PhysicalExprAdapterFactory, replace_columns_with_literals}, + physical_expr_common::physical_expr::is_dynamic_physical_expr, + physical_optimizer::pruning::{FilePruner, PruningPredicate, build_pruning_predicate}, + physical_plan::{ + PhysicalExpr, + metrics::{Count, ExecutionPlanMetricsSet, MetricBuilder}, + }, +}; +use futures::StreamExt; +use futures::TryStreamExt; +use log::debug; +use parquet::arrow::{ + ParquetRecordBatchStreamBuilder, ProjectionMask, + arrow_reader::{ArrowReaderMetadata, ArrowReaderOptions}, +}; +use parquet::file::metadata::ParquetMetaData; + +use super::source::CachedMetaReaderFactory; + +pub struct LiquidParquetOpener { + partition_index: usize, + projection: ProjectionExprs, + batch_size: usize, + limit: Option, + predicate: Option>, + table_schema: TableSchema, + metrics: ExecutionPlanMetricsSet, + parquet_file_reader_factory: Arc, + reorder_filters: bool, + liquid_cache: LiquidCacheParquetRef, + expr_adapter_factory: Arc, + /// Optional caller-provided reader factory with pre-loaded metadata. + caller_reader_factory: Option>, + /// Policy that decides whether to use LC stream or delegate to parquet. + engagement_policy: Arc, +} + +impl LiquidParquetOpener { + #[allow(clippy::too_many_arguments)] + pub fn new( + partition_index: usize, + projection: ProjectionExprs, + batch_size: usize, + limit: Option, + predicate: Option>, + table_schema: TableSchema, + metrics: ExecutionPlanMetricsSet, + liquid_cache: LiquidCacheParquetRef, + parquet_file_reader_factory: Arc, + reorder_filters: bool, + expr_adapter_factory: Arc, + caller_reader_factory: Option>, + engagement_policy: Arc, + ) -> Self { + Self { + partition_index, + projection, + batch_size, + limit, + predicate, + table_schema, + metrics, + liquid_cache, + parquet_file_reader_factory, + reorder_filters, + expr_adapter_factory, + caller_reader_factory, + engagement_policy, + } + } +} + +impl FileOpener for LiquidParquetOpener { + fn open(&self, partitioned_file: PartitionedFile) -> Result { + let file_range = partitioned_file.range.clone(); + let access_plan_ext = partitioned_file.extensions.get_arc::(); + let file_name = partitioned_file.object_meta.location.to_string(); + let file_metrics = ParquetFileMetrics::new(self.partition_index, &file_name, &self.metrics); + + let metadata_size_hint = partitioned_file.metadata_size_hint; + let has_predicate = self.predicate.is_some(); + log::debug!( + "[LC-Opener] open file={}, predicate={}, batch_size={}, limit={:?}", + file_name, + has_predicate, + self.batch_size, + self.limit + ); + + let lc = self.liquid_cache.clone(); + let file_loc = partitioned_file.object_meta.location.to_string(); + + // If caller provided a reader factory with pre-loaded metadata, create + // a reader from it. We'll use this for get_metadata() to avoid refetching. + let caller_metadata_reader = self.caller_reader_factory.as_ref().and_then(|factory| { + factory + .create_reader( + self.partition_index, + partitioned_file.clone(), + metadata_size_hint, + &self.metrics, + ) + .ok() + }); + + let mut async_file_reader = self.parquet_file_reader_factory.create_liquid_reader( + self.partition_index, + partitioned_file.clone(), + metadata_size_hint, + &self.metrics, + ); + + let batch_size = self.batch_size; + let logical_file_schema = Arc::clone(self.table_schema.file_schema()); + let output_schema = Arc::new( + self.projection + .project_schema(self.table_schema.table_schema())?, + ); + let mut projection = self.projection.clone(); + let mut predicate = self.predicate.clone(); + let mut literal_columns = std::collections::HashMap::new(); + for (field, value) in self + .table_schema + .table_partition_cols() + .iter() + .zip(partitioned_file.partition_values.iter()) + { + literal_columns.insert(field.name().clone(), value.clone()); + } + if !literal_columns.is_empty() { + projection = projection.try_map_exprs(|expr| { + replace_columns_with_literals(Arc::clone(&expr), &literal_columns) + })?; + predicate = predicate + .map(|p| replace_columns_with_literals(p, &literal_columns)) + .transpose()?; + } + let reorder_predicates = self.reorder_filters; + let limit = self.limit; + + let predicate_creation_errors = + MetricBuilder::new(&self.metrics).global_counter("num_predicate_creation_errors"); + + let expr_adapter_factory = Arc::clone(&self.expr_adapter_factory); + let engagement_policy = Arc::clone(&self.engagement_policy); + Ok(Box::pin(async move { + // Prune this file using the file level statistics and partition values. + // Since dynamic filters may have been updated since planning it is possible that we are able + // to prune files now that we couldn't prune at planning time. + // It is assumed that there is no point in doing pruning here if the predicate is not dynamic, + // as it would have been done at planning time. + // We'll also check this after every record batch we read, + // and if at some point we are able to prove we can prune the file using just the file level statistics + // we can end the stream early. + let mut file_pruner = predicate + .as_ref() + .filter(|p| is_dynamic_physical_expr(p) || partitioned_file.has_statistics()) + .and_then(|p| { + FilePruner::try_new( + Arc::clone(p), + &logical_file_schema, + &partitioned_file, + predicate_creation_errors.clone(), + ) + }); + + if let Some(file_pruner) = &mut file_pruner + && file_pruner.should_prune()? + { + file_metrics.files_ranges_pruned_statistics.add_pruned(1); + return Ok(futures::stream::empty().boxed()); + } + + file_metrics.files_ranges_pruned_statistics.add_matched(1); + + let options = ArrowReaderOptions::new() + .with_page_index_policy(parquet::file::metadata::PageIndexPolicy::Required); + let mut metadata_timer = file_metrics.metadata_load_time.timer(); + + // Try to get metadata from caller's pre-loaded reader first (instant). + // Fall back to loading from the object store if not available. + let reader_metadata = if let Some(mut caller_reader) = caller_metadata_reader { + match caller_reader.get_metadata(Some(&options)).await { + Ok(meta) => { + log::debug!("[LC-Meta] REUSE from caller factory: {}", file_name); + ArrowReaderMetadata::try_new(meta, options.clone())? + } + Err(_) => { + log::debug!( + "[LC-Meta] caller factory failed, loading from store: {}", + file_name + ); + ArrowReaderMetadata::load_async(&mut async_file_reader, options.clone()) + .await? + } + } + } else { + ArrowReaderMetadata::load_async(&mut async_file_reader, options.clone()).await? + }; + + // Note about schemas: we are actually dealing with **3 different schemas** here: + // - The table schema as defined by the TableProvider. + // This is what the user sees, what they get when they `SELECT * FROM table`, etc. + // - The logical file schema: this is the table schema minus any hive partition columns and projections. + // This is what the physical file schema is coerced to. + // - The physical file schema: this is the schema as defined by the parquet file. This is what the parquet file actually contains. + let physical_file_schema = Arc::clone(reader_metadata.schema()); + let cache_full_schema = Arc::clone(&physical_file_schema); + + let rewriter = expr_adapter_factory.create( + Arc::clone(&logical_file_schema), + Arc::clone(&physical_file_schema), + )?; + let simplifier = PhysicalExprSimplifier::new(&physical_file_schema); + predicate = predicate + .map(|p| simplifier.simplify(rewriter.rewrite(p)?)) + .transpose()?; + projection = projection.try_map_exprs(|p| simplifier.simplify(rewriter.rewrite(p)?))?; + + let (pruning_predicate, page_pruning_predicate) = build_pruning_predicates( + predicate.as_ref(), + &physical_file_schema, + &predicate_creation_errors, + ); + + metadata_timer.stop(); + + let mut builder = ParquetRecordBatchStreamBuilder::new_with_metadata( + async_file_reader.clone(), + reader_metadata.clone(), + ); + let indices = projection.column_indices(); + let mask = ProjectionMask::roots(builder.parquet_schema(), indices); + + // Filter pushdown: evaluate predicates during scan + let row_filter = predicate.as_ref().and_then(|p| { + let row_filter = build_row_filter( + p, + &physical_file_schema, + reader_metadata.metadata(), + reorder_predicates, + &file_metrics, + ); + + match row_filter { + Ok(Some(filter)) => Some(filter), + Ok(None) => None, + Err(e) => { + debug!("Ignoring error building row filter for '{predicate:?}': {e:?}"); + None + } + } + }); + + // Determine which row groups to actually read. The idea is to skip + // as many row groups as possible based on the metadata and query + let file_metadata: Arc = Arc::clone(builder.metadata()); + let predicate = pruning_predicate.as_ref().map(|p| p.as_ref()); + let rg_metadata = file_metadata.row_groups(); + // track which row groups to actually read + let access_plan = create_initial_plan(&file_name, access_plan_ext, rg_metadata.len())?; + let mut row_groups = RowGroupAccessPlanFilter::new(access_plan); + // if there is a range restricting what parts of the file to read + if let Some(range) = file_range.as_ref() { + row_groups.prune_by_range(rg_metadata, range); + } + // If there is a predicate that can be evaluated against the metadata + if let Some(predicate) = predicate.as_ref() { + row_groups.prune_by_statistics( + &physical_file_schema, + builder.parquet_schema(), + rg_metadata, + predicate, + &file_metrics, + ); + + if !row_groups.is_empty() { + row_groups + .prune_by_bloom_filters( + &physical_file_schema, + &mut builder, + predicate, + &file_metrics, + ) + .await; + } + } + + let mut access_plan = row_groups.build(); + + // page index pruning: if all data on individual pages can + // be ruled using page metadata, rows from other columns + // with that range can be skipped as well + if !access_plan.is_empty() + && let Some(p) = page_pruning_predicate + { + access_plan = p.prune_plan_with_page_index( + access_plan, + &physical_file_schema, + builder.parquet_schema(), + file_metadata.as_ref(), + &file_metrics, + ); + } + + let row_group_indexes = access_plan.row_group_indexes(); + + // Early exit: if all row groups were pruned, return empty stream + if row_group_indexes.is_empty() { + log::debug!("[LC-Opener] EMPTY: all RGs pruned, file={}", file_name); + return Ok(futures::stream::empty().boxed()); + } + + let row_selection = access_plan.into_overall_row_selection(rg_metadata)?; + + // Estimate selectivity from row_selection: how many rows survived + // RG pruning + page index pruning vs total rows in selected RGs. + let total_rows: usize = row_group_indexes + .iter() + .map(|&idx| rg_metadata[idx].num_rows() as usize) + .sum(); + let selected_rows = row_selection + .as_ref() + .map(|sel| sel.row_count()) + .unwrap_or(total_rows); + let estimated_selectivity = if total_rows > 0 { + selected_rows as f64 / total_rows as f64 + } else { + 1.0 + }; + + // If selectivity is low (few rows match), the decode cost is already + // minimal — LC cache overhead would dominate for negligible savings. + // Delegate to plain parquet for fast pass-through. + // For high selectivity (most rows match = lots of decode), LC cache + // saves significant decode work on warm iterations. + let engagement_ctx = EngagementContext { + estimated_selectivity, + has_predicate: predicate.is_some(), + total_rows, + selected_rows, + file_path: file_name.clone(), + }; + + match engagement_policy.decide(&engagement_ctx) { + EngagementDecision::DelegateToParquet => { + log::debug!( + "[LC-Opener] DELEGATE to plain parquet: selectivity={:.3}, file={}", + estimated_selectivity, + file_name + ); + let mut plain_builder = ParquetRecordBatchStreamBuilder::new_with_metadata( + async_file_reader, + reader_metadata, + ) + .with_batch_size(batch_size) + .with_projection(mask) + .with_row_groups(row_group_indexes); + + if let Some(sel) = row_selection { + plain_builder = plain_builder.with_row_selection(sel); + } + if let Some(lim) = limit { + plain_builder = plain_builder.with_limit(lim); + } + + let stream = plain_builder.build()?; + let adapted = stream.map_err(|e| DataFusionError::External(Box::new(e))); + return Ok(adapted.boxed()); + } + EngagementDecision::UseLiquidCache => { + log::debug!( + "[LC-Opener] LC STREAM: selectivity={:.3}, predicate={}, file={}", + estimated_selectivity, + predicate.is_some(), + file_name + ); + } + } + + let mut liquid_builder = + LiquidStreamBuilder::new(async_file_reader, Arc::clone(reader_metadata.metadata())) + .with_batch_size(batch_size) + .with_row_groups(row_group_indexes) + .with_projection(mask) + .with_selection(row_selection) + .with_limit(limit); + + if let Some(row_filter) = row_filter { + liquid_builder = liquid_builder.with_row_filter(row_filter); + } + + let liquid_cache = lc.register_or_get_file(file_loc, Arc::clone(&cache_full_schema)); + + let stream = liquid_builder.build(liquid_cache)?; + + let stream_schema = Arc::clone(stream.schema()); + let replace_schema = !stream_schema.eq(&output_schema); + let projection = + projection.try_map_exprs(|expr| reassign_expr_columns(expr, &stream_schema))?; + let projector = projection.make_projector(&stream_schema)?; + + let adapted = stream + .map_err(|e| DataFusionError::External(Box::new(e))) + .map(move |batch| { + batch.and_then(|batch| { + let batch = projector.project_batch(&batch)?; + if replace_schema { + let (_schema, arrays, num_rows) = batch.into_parts(); + let options = RecordBatchOptions::new().with_row_count(Some(num_rows)); + RecordBatch::try_new_with_options( + Arc::clone(&output_schema), + arrays, + &options, + ) + .map_err(Into::into) + } else { + Ok(batch) + } + }) + }); + + Ok(adapted.boxed()) + })) + } +} + +fn create_initial_plan( + file_name: &str, + access_plan: Option>, + row_group_count: usize, +) -> Result { + if let Some(access_plan) = access_plan { + let plan_len = access_plan.len(); + if plan_len != row_group_count { + return exec_err!( + "Invalid ParquetAccessPlan for {file_name}. Specified {plan_len} row groups, but file has {row_group_count}" + ); + } + + // check row group count matches the plan + return Ok(access_plan.as_ref().clone()); + } + + // default to scanning all row groups + Ok(ParquetAccessPlan::new_all(row_group_count)) +} + +pub(crate) fn build_pruning_predicates( + predicate: Option<&Arc>, + file_schema: &SchemaRef, + predicate_creation_errors: &Count, +) -> ( + Option>, + Option>, +) { + let Some(predicate) = predicate.as_ref() else { + return (None, None); + }; + let pruning_predicate = build_pruning_predicate( + Arc::clone(predicate), + file_schema, + predicate_creation_errors, + ); + let page_pruning_predicate = build_page_pruning_predicate(predicate, file_schema); + (pruning_predicate, Some(page_pruning_predicate)) +} + +/// Build a page pruning predicate from an optional predicate expression. +/// If the predicate is None or the predicate cannot be converted to a page pruning +/// predicate, return None. +pub(crate) fn build_page_pruning_predicate( + predicate: &Arc, + file_schema: &SchemaRef, +) -> Arc { + Arc::new(PagePruningAccessPlanFilter::new( + predicate, + Arc::clone(file_schema), + )) +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/plantime/row_filter.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/plantime/row_filter.rs new file mode 100644 index 0000000000000..fe9c5ea16246c --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/plantime/row_filter.rs @@ -0,0 +1,515 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Utilities to push down of DataFusion filter predicates (any DataFusion +//! `PhysicalExpr` that evaluates to a [`BooleanArray`]) to the parquet decoder +//! level in `arrow-rs`. +//! +//! DataFusion will use a `ParquetRecordBatchStream` to read data from parquet +//! into [`RecordBatch`]es. +//! +//! The `ParquetRecordBatchStream` takes an optional `RowFilter` which is itself +//! a Vec of `Box`. During decoding, the predicates are +//! evaluated in order, to generate a mask which is used to avoid decoding rows +//! in projected columns which do not pass the filter which can significantly +//! reduce the amount of compute required for decoding and thus improve query +//! performance. +//! +//! Since the predicates are applied serially in the order defined in the +//! `RowFilter`, the optimal ordering depends on the exact filters. The best +//! filters to execute first have two properties: +//! +//! 1. They are relatively inexpensive to evaluate (e.g. they read +//! column chunks which are relatively small) +//! +//! 2. They filter many (contiguous) rows, reducing the amount of decoding +//! required for subsequent filters and projected columns +//! +//! If requested, this code will reorder the filters based on heuristics try and +//! reduce the evaluation cost. +//! +//! The basic algorithm for constructing the `RowFilter` is as follows +//! +//! 1. Break conjunctions into separate predicates. An expression +//! like `a = 1 AND (b = 2 AND c = 3)` would be +//! separated into the expressions `a = 1`, `b = 2`, and `c = 3`. +//! 2. Determine whether each predicate can be evaluated as an `ArrowPredicate`. +//! 3. Determine, for each predicate, the total compressed size of all +//! columns required to evaluate the predicate. +//! 4. Determine, for each predicate, whether all columns required to +//! evaluate the expression are sorted. +//! 5. Re-order the predicate by total size (from step 3). +//! 6. Partition the predicates according to whether they are sorted (from step 4) +//! 7. "Compile" each predicate `Expr` to a `DatafusionArrowPredicate`. +//! 8. Build the `RowFilter` with the sorted predicates followed by +//! the unsorted predicates. Within each partition, predicates are +//! still be sorted by size. + +use std::cmp::Ordering; +use std::collections::BTreeSet; +use std::sync::Arc; + +use arrow::array::BooleanArray; +use arrow::datatypes::{DataType, Schema}; +use arrow::error::{ArrowError, Result as ArrowResult}; +use arrow::record_batch::RecordBatch; +use arrow_schema::SchemaRef; +use datafusion::datasource::physical_plan::ParquetFileMetrics; +use datafusion::logical_expr::Operator; +use datafusion::physical_expr::utils::reassign_expr_columns; +use datafusion::physical_plan::expressions::{BinaryExpr, LikeExpr}; +use datafusion::physical_plan::metrics; +use parquet::arrow::ProjectionMask; +use parquet::arrow::arrow_reader::ArrowPredicate; +use parquet::file::metadata::ParquetMetaData; + +use datafusion::common::Result; +use datafusion::common::cast::as_boolean_array; +use datafusion::common::tree_node::{TreeNode, TreeNodeRecursion, TreeNodeVisitor}; +use datafusion::physical_expr::expressions::Column; +use datafusion::physical_expr::{PhysicalExpr, split_conjunction}; + +/// A row filter that can be used to filter rows from a parquet file. +pub struct LiquidRowFilter { + predicates: Vec, +} + +impl LiquidRowFilter { + /// Create a new `LiquidRowFilter` from a vector of `LiquidPredicate`s. + pub fn new(predicates: Vec) -> Self { + Self { predicates } + } + + /// Get the predicates of the `LiquidRowFilter`. + pub fn predicates(&self) -> &[LiquidPredicate] { + &self.predicates + } + + /// Get the predicates of the `LiquidRowFilter` as mutable. + pub fn predicates_mut(&mut self) -> &mut [LiquidPredicate] { + &mut self.predicates + } +} + +pub(crate) fn get_predicate_column_id(projection: &parquet::arrow::ProjectionMask) -> Vec { + #[derive(Debug, Clone)] + struct ProjectionMaskLiquid { + mask: Option>, + } + let project_inner: &ProjectionMaskLiquid = unsafe { std::mem::transmute(projection) }; + project_inner + .mask + .as_ref() + .map(|m| { + m.iter() + .enumerate() + .filter_map(|(pos, &x)| if x { Some(pos) } else { None }) + .collect::>() + }) + .unwrap_or_default() +} + +/// A "compiled" predicate passed to `ParquetRecordBatchStream` to perform +/// row-level filtering during parquet decoding. +/// +/// See the module level documentation for more information. +/// +/// Implements the `ArrowPredicate` trait used by the parquet decoder +/// +/// An expression can be evaluated as a `DatafusionArrowPredicate` if it: +/// * Does not reference any projected columns +/// * Does not reference columns with non-primitive types (e.g. structs / lists) +#[derive(Debug, Clone)] +pub struct LiquidPredicate { + /// the filter expression + physical_expr: Arc, + /// the filter expression without reassigned index + physical_expr_physical_column_index: Arc, + /// Path to the columns in the parquet schema required to evaluate the + /// expression + projection_mask: ProjectionMask, + /// how many rows were filtered out by this predicate + rows_pruned: metrics::Count, + /// how many rows passed this predicate + rows_matched: metrics::Count, + /// how long was spent evaluating this predicate + time: metrics::Time, +} + +impl std::fmt::Display for LiquidPredicate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.physical_expr.as_ref()) + } +} + +impl LiquidPredicate { + /// Create a new `LiquidPredicate` from a `FilterCandidate` + pub fn try_new_with_metrics( + candidate: FilterCandidate, + projection: ProjectionMask, + rows_pruned: metrics::Count, + rows_matched: metrics::Count, + time: metrics::Time, + ) -> Result { + let physical_expr = + reassign_expr_columns(candidate.expr.clone(), &candidate.filter_schema)?; + + Ok(Self { + physical_expr, + physical_expr_physical_column_index: candidate.expr, + projection_mask: projection, + rows_pruned, + rows_matched, + time, + }) + } + + /// Create a new `LiquidPredicate` from a `FilterCandidate` + pub fn try_new(candidate: FilterCandidate, projection: ProjectionMask) -> Result { + Self::try_new_with_metrics( + candidate, + projection, + metrics::Count::new(), + metrics::Count::new(), + metrics::Time::new(), + ) + } + + /// Get the physical expression with physical column index. + pub fn physical_expr_physical_column_index(&self) -> &Arc { + &self.physical_expr_physical_column_index + } + + /// Get the physical expression rewritten to the projected batch schema. + pub fn physical_expr(&self) -> &Arc { + &self.physical_expr + } + + /// Get the column ids of the predicate. + pub fn predicate_column_ids(&self) -> Vec { + let projection = self.projection(); + get_predicate_column_id(projection) + } +} + +impl ArrowPredicate for LiquidPredicate { + fn projection(&self) -> &ProjectionMask { + &self.projection_mask + } + + fn evaluate(&mut self, batch: RecordBatch) -> ArrowResult { + // scoped timer updates on drop + let mut timer = self.time.timer(); + + self.physical_expr + .evaluate(&batch) + .and_then(|v| v.into_array(batch.num_rows())) + .and_then(|array| { + let bool_arr = as_boolean_array(&array)?.clone(); + let num_matched = bool_arr.true_count(); + let num_pruned = bool_arr.len() - num_matched; + self.rows_pruned.add(num_pruned); + self.rows_matched.add(num_matched); + timer.stop(); + Ok(bool_arr) + }) + .map_err(|e| { + ArrowError::ComputeError(format!("Error evaluating filter predicate: {e:?}")) + }) + } +} + +/// A candidate expression for creating a `RowFilter`. +/// +/// Each candidate contains the expression as well as data to estimate the cost +/// of evaluating the resulting expression. +/// +/// See the module level documentation for more information. +pub struct FilterCandidate { + expr: Arc, + required_bytes: usize, + can_use_index: bool, + projection: Vec, + /// The projected file schema that this filter references + filter_schema: SchemaRef, +} + +impl FilterCandidate { + pub fn projection(&self, metadata: &ParquetMetaData) -> ProjectionMask { + ProjectionMask::roots( + metadata.file_metadata().schema_descr(), + self.projection.iter().copied(), + ) + } +} + +/// Helper to build a `FilterCandidate`. +/// +/// This will do several things +/// 1. Determine the columns required to evaluate the expression +/// 2. Calculate data required to estimate the cost of evaluating the filter +pub struct FilterCandidateBuilder { + expr: Arc, + /// The schema of this parquet file. + /// Expressions are already adapted to this schema before row-filter construction. + file_schema: SchemaRef, +} + +impl FilterCandidateBuilder { + /// Create a new `FilterCandidateBuilder` + pub fn new(expr: Arc, file_schema: SchemaRef) -> Self { + Self { expr, file_schema } + } + + /// Attempt to build a `FilterCandidate` from the expression + /// + /// # Return values + /// + /// * `Ok(Some(candidate))` if the expression can be used as an ArrowFilter + /// * `Ok(None)` if the expression cannot be used as an ArrowFilter + /// * `Err(e)` if an error occurs while building the candidate + pub fn build(self, metadata: &ParquetMetaData) -> Result> { + let Some(required_indices_into_file_schema) = + pushdown_columns(&self.expr, &self.file_schema)? + else { + return Ok(None); + }; + + if required_indices_into_file_schema.is_empty() { + return Ok(None); + } + + let projected_file_schema = Arc::new( + self.file_schema + .project(&required_indices_into_file_schema)?, + ); + + let required_bytes = size_of_columns(&required_indices_into_file_schema, metadata)?; + let can_use_index = columns_sorted(&required_indices_into_file_schema, metadata)?; + + Ok(Some(FilterCandidate { + expr: self.expr, + required_bytes, + can_use_index, + projection: required_indices_into_file_schema, + filter_schema: Arc::clone(&projected_file_schema), + })) + } +} + +// a struct that implements TreeNodeRewriter to traverse a PhysicalExpr tree structure to determine +// if any column references in the expression would prevent it from being predicate-pushed-down. +// if non_primitive_columns || projected_columns, it can't be pushed down. +// can't be reused between calls to `rewrite`; each construction must be used only once. +struct PushdownChecker<'schema> { + /// Does the expression require any non-primitive columns (like structs)? + non_primitive_columns: bool, + /// Does the expression reference any columns that are not in the file schema? + projected_columns: bool, + // Indices into the file schema of the columns required to evaluate the expression + required_columns: BTreeSet, + file_schema: &'schema Schema, +} + +impl<'schema> PushdownChecker<'schema> { + fn new(file_schema: &'schema Schema) -> Self { + Self { + non_primitive_columns: false, + projected_columns: false, + required_columns: BTreeSet::default(), + file_schema, + } + } + + fn check_single_column(&mut self, column_name: &str) -> Option { + if let Ok(idx) = self.file_schema.index_of(column_name) { + self.required_columns.insert(idx); + if DataType::is_nested(self.file_schema.field(idx).data_type()) { + self.non_primitive_columns = true; + return Some(TreeNodeRecursion::Jump); + } + } else { + // If the column does not exist in the file schema then it cannot be pushed down. + self.projected_columns = true; + return Some(TreeNodeRecursion::Jump); + } + + None + } + + #[inline] + fn prevents_pushdown(&self) -> bool { + self.non_primitive_columns || self.projected_columns + } +} + +impl TreeNodeVisitor<'_> for PushdownChecker<'_> { + type Node = Arc; + + fn f_down(&mut self, node: &Self::Node) -> Result { + if let Some(column) = node.downcast_ref::() + && let Some(recursion) = self.check_single_column(column.name()) + { + return Ok(recursion); + } + + Ok(TreeNodeRecursion::Continue) + } +} + +// Checks if a given expression can be pushed down into `DataSourceExec` as opposed to being evaluated +// post-parquet-scan in a `FilterExec`. If it can be pushed down, this returns all the +// columns in the given expression so that they can be used in the parquet scanning, along with the +// expression rewritten as defined in [`PushdownChecker::f_up`] +fn pushdown_columns( + expr: &Arc, + file_schema: &Schema, +) -> Result>> { + let mut checker = PushdownChecker::new(file_schema); + expr.visit(&mut checker)?; + Ok((!checker.prevents_pushdown()).then_some(checker.required_columns.into_iter().collect())) +} + +/// Calculate the total compressed size of all `Column`'s required for +/// predicate `Expr`. +/// +/// This value represents the total amount of IO required to evaluate the +/// predicate. +fn size_of_columns(columns: &[usize], metadata: &ParquetMetaData) -> Result { + let mut total_size = 0; + let row_groups = metadata.row_groups(); + for idx in columns { + for rg in row_groups.iter() { + total_size += rg.column(*idx).compressed_size() as usize; + } + } + + Ok(total_size) +} + +/// For a given set of `Column`s required for predicate `Expr` determine whether +/// all columns are sorted. +/// +/// Sorted columns may be queried more efficiently in the presence of +/// a PageIndex. +fn columns_sorted(_columns: &[usize], _metadata: &ParquetMetaData) -> Result { + // TODO How do we know this? + Ok(false) +} + +/// Build a [`LiquidRowFilter`] from the given predicate `Expr` if possible +/// +/// # returns +/// * `Ok(Some(row_filter))` if the expression can be used as RowFilter +/// * `Ok(None)` if the expression cannot be used as an RowFilter +/// * `Err(e)` if an error occurs while building the filter +/// +/// Note that the returned `RowFilter` may not contains all conjuncts in the +/// original expression. This is because some conjuncts may not be able to be +/// evaluated as an `ArrowPredicate` and will be ignored. +/// +/// For example, if the expression is `a = 1 AND b = 2 AND c = 3` and `b = 2` +/// can not be evaluated for some reason, the returned `RowFilter` will contain +/// `a = 1` and `c = 3`. +pub fn build_row_filter( + expr: &Arc, + physical_file_schema: &SchemaRef, + metadata: &ParquetMetaData, + reorder_predicates: bool, + file_metrics: &ParquetFileMetrics, +) -> Result> { + let rows_pruned = &file_metrics.pushdown_rows_pruned; + let rows_matched = &file_metrics.pushdown_rows_matched; + let time = &file_metrics.row_pushdown_eval_time; + + // Split into conjuncts: + // `a = 1 AND b = 2 AND c = 3` -> [`a = 1`, `b = 2`, `c = 3`] + let predicates = split_conjunction(expr); + + // Determine which conjuncts can be evaluated as ArrowPredicates, if any + let mut candidates: Vec = predicates + .into_iter() + .map(|expr| { + FilterCandidateBuilder::new(Arc::clone(expr), physical_file_schema.clone()) + .build(metadata) + }) + .collect::, _>>()? + .into_iter() + .flatten() + .collect(); + + // no candidates + if candidates.is_empty() { + return Ok(None); + } + + if reorder_predicates { + candidates.sort_unstable_by(|c1, c2| match c1.can_use_index.cmp(&c2.can_use_index) { + Ordering::Equal => c1.required_bytes.cmp(&c2.required_bytes), + ord => ord, + }); + } + + candidates.sort_unstable_by(|c1, c2| { + let p1 = get_priority(&c1.expr); + let p2 = get_priority(&c2.expr); + p1.cmp(&p2) + }); + + log::debug!( + "Predicate eval order: {}", + candidates + .iter() + .map(|c| c.expr.as_ref().to_string()) + .collect::>() + .join(", ") + ); + + candidates + .into_iter() + .map(|candidate| { + let projection = ProjectionMask::roots( + metadata.file_metadata().schema_descr(), + candidate.projection.iter().copied(), + ); + LiquidPredicate::try_new_with_metrics( + candidate, + projection, + rows_pruned.clone(), + rows_matched.clone(), + time.clone(), + ) + }) + .collect::, _>>() + .map(|filters| Some(LiquidRowFilter::new(filters))) +} + +fn get_priority(expr: &Arc) -> u8 { + if let Some(binary) = expr.downcast_ref::() { + match binary.op() { + Operator::Eq | Operator::NotEq => 0, // Highest priority + Operator::LikeMatch | Operator::ILikeMatch => 1, + Operator::NotLikeMatch | Operator::NotILikeMatch => 2, + Operator::Lt | Operator::LtEq | Operator::Gt | Operator::GtEq => 3, + _ => 4, + } + } else if expr.is::() { + 1 // LIKE expressions + } else { + 5 // All other expression types + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/plantime/row_group_filter.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/plantime/row_group_filter.rs new file mode 100644 index 0000000000000..eb26b34b052ae --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/plantime/row_group_filter.rs @@ -0,0 +1,427 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use arrow::{array::ArrayRef, array::BooleanArray, array::UInt64Array, datatypes::Schema}; +use datafusion::common::{Column, Result, ScalarValue}; +use datafusion::datasource::listing::FileRange; +use datafusion::datasource::physical_plan::ParquetFileMetrics; +use datafusion::datasource::physical_plan::parquet::ParquetAccessPlan; +use datafusion::physical_optimizer::pruning::{PruningPredicate, PruningStatistics}; +use parquet::arrow::arrow_reader::statistics::StatisticsConverter; +use parquet::arrow::parquet_column; +use parquet::basic::Type; +use parquet::data_type::Decimal; +use parquet::schema::types::SchemaDescriptor; +use parquet::{ + arrow::{ParquetRecordBatchStreamBuilder, async_reader::AsyncFileReader}, + bloom_filter::Sbbf, + file::metadata::RowGroupMetaData, +}; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +/// Reduces the [`ParquetAccessPlan`] based on row group level metadata. +/// +/// This struct implements the various types of pruning that are applied to a +/// set of row groups within a parquet file, progressively narrowing down the +/// set of row groups (and ranges/selections within those row groups) that +/// should be scanned, based on the available metadata. +#[derive(Debug, Clone, PartialEq)] +pub struct RowGroupAccessPlanFilter { + /// which row groups should be accessed + access_plan: ParquetAccessPlan, +} + +impl RowGroupAccessPlanFilter { + /// Create a new `RowGroupPlanBuilder` for pruning out the groups to scan + /// based on metadata and statistics + pub fn new(access_plan: ParquetAccessPlan) -> Self { + Self { access_plan } + } + + /// Return true if there are no row groups + pub fn is_empty(&self) -> bool { + self.access_plan.is_empty() + } + + /// Returns the inner access plan + pub fn build(self) -> ParquetAccessPlan { + self.access_plan + } + + /// Prune remaining row groups to only those within the specified range. + /// + /// Updates this set to mark row groups that should not be scanned + /// + /// # Panics + /// if `groups.len() != self.len()` + pub fn prune_by_range(&mut self, groups: &[RowGroupMetaData], range: &FileRange) { + assert_eq!(groups.len(), self.access_plan.len()); + for (idx, metadata) in groups.iter().enumerate() { + if !self.access_plan.should_scan(idx) { + continue; + } + + // Skip the row group if the first dictionary/data page are not + // within the range. + // + // note don't use the location of metadata + // + let col = metadata.column(0); + let offset = col + .dictionary_page_offset() + .unwrap_or_else(|| col.data_page_offset()); + if !range.contains(offset) { + self.access_plan.skip(idx); + } + } + } + /// Prune remaining row groups using min/max/null_count statistics and + /// the [`PruningPredicate`] to determine if the predicate can not be true. + /// + /// Updates this set to mark row groups that should not be scanned + /// + /// Note: This method currently ignores ColumnOrder + /// + /// + /// # Panics + /// if `groups.len() != self.len()` + pub fn prune_by_statistics( + &mut self, + arrow_schema: &Schema, + parquet_schema: &SchemaDescriptor, + groups: &[RowGroupMetaData], + predicate: &PruningPredicate, + metrics: &ParquetFileMetrics, + ) { + // scoped timer updates on drop + let _timer_guard = metrics.statistics_eval_time.timer(); + + assert_eq!(groups.len(), self.access_plan.len()); + // Indexes of row groups still to scan + let row_group_indexes = self.access_plan.row_group_indexes(); + let row_group_metadatas = row_group_indexes + .iter() + .map(|&i| &groups[i]) + .collect::>(); + + let pruning_stats = RowGroupPruningStatistics { + parquet_schema, + row_group_metadatas, + arrow_schema, + }; + + // try to prune the row groups in a single call + match predicate.prune(&pruning_stats) { + Ok(values) => { + // values[i] is false means the predicate could not be true for row group i + for (idx, &value) in row_group_indexes.iter().zip(values.iter()) { + if !value { + self.access_plan.skip(*idx); + metrics.row_groups_pruned_statistics.add_pruned(1); + } else { + metrics.row_groups_pruned_statistics.add_matched(1); + } + } + } + // stats filter array could not be built, so we can't prune + Err(e) => { + log::debug!("Error evaluating row group predicate values {e}"); + metrics.predicate_evaluation_errors.add(1); + } + } + } + + /// Prune remaining row groups using available bloom filters and the + /// [`PruningPredicate`]. + /// + /// Updates this set with row groups that should not be scanned + /// + /// # Panics + /// if the builder does not have the same number of row groups as this set + pub async fn prune_by_bloom_filters( + &mut self, + arrow_schema: &Schema, + builder: &mut ParquetRecordBatchStreamBuilder, + predicate: &PruningPredicate, + metrics: &ParquetFileMetrics, + ) { + // scoped timer updates on drop + let _timer_guard = metrics.bloom_filter_eval_time.timer(); + + assert_eq!(builder.metadata().num_row_groups(), self.access_plan.len()); + for idx in 0..self.access_plan.len() { + if !self.access_plan.should_scan(idx) { + continue; + } + + // Attempt to find bloom filters for filtering this row group + let literal_columns = predicate.literal_columns(); + let mut column_sbbf = HashMap::with_capacity(literal_columns.len()); + + for column_name in literal_columns { + let Some((column_idx, _field)) = + parquet_column(builder.parquet_schema(), arrow_schema, &column_name) + else { + continue; + }; + + let bf = match builder + .get_row_group_column_bloom_filter(idx, column_idx) + .await + { + Ok(Some(bf)) => bf, + Ok(None) => continue, // no bloom filter for this column + Err(e) => { + log::debug!("Ignoring error reading bloom filter: {e}"); + metrics.predicate_evaluation_errors.add(1); + continue; + } + }; + let physical_type = builder.parquet_schema().column(column_idx).physical_type(); + + column_sbbf.insert(column_name.to_string(), (bf, physical_type)); + } + + let stats = BloomFilterStatistics { column_sbbf }; + + // Can this group be pruned? + let prune_group = match predicate.prune(&stats) { + Ok(values) => !values[0], + Err(e) => { + log::debug!("Error evaluating row group predicate on bloom filter: {e}"); + metrics.predicate_evaluation_errors.add(1); + false + } + }; + + if prune_group { + metrics.row_groups_pruned_bloom_filter.add_pruned(1); + self.access_plan.skip(idx) + } else if !stats.column_sbbf.is_empty() { + metrics.row_groups_pruned_bloom_filter.add_matched(1); + } + } + } +} +/// Implements [`PruningStatistics`] for Parquet Split Block Bloom Filters (SBBF) +struct BloomFilterStatistics { + /// Maps column name to the parquet bloom filter and parquet physical type + column_sbbf: HashMap, +} + +impl BloomFilterStatistics { + /// Helper function for checking if [`Sbbf`] filter contains [`ScalarValue`]. + /// + /// In case the type of scalar is not supported, returns `true`, assuming that the + /// value may be present. + fn check_scalar(sbbf: &Sbbf, value: &ScalarValue, parquet_type: &Type) -> bool { + match value { + ScalarValue::Utf8(Some(v)) + | ScalarValue::Utf8View(Some(v)) + | ScalarValue::LargeUtf8(Some(v)) => sbbf.check(&v.as_str()), + ScalarValue::Binary(Some(v)) + | ScalarValue::BinaryView(Some(v)) + | ScalarValue::LargeBinary(Some(v)) => sbbf.check(v), + ScalarValue::FixedSizeBinary(_size, Some(v)) => sbbf.check(v), + ScalarValue::Boolean(Some(v)) => sbbf.check(v), + ScalarValue::Float64(Some(v)) => sbbf.check(v), + ScalarValue::Float32(Some(v)) => sbbf.check(v), + ScalarValue::Int64(Some(v)) => sbbf.check(v), + ScalarValue::Int32(Some(v)) => sbbf.check(v), + ScalarValue::UInt64(Some(v)) => sbbf.check(v), + ScalarValue::UInt32(Some(v)) => sbbf.check(v), + ScalarValue::Decimal128(Some(v), p, s) => match parquet_type { + Type::INT32 => { + //https://github.com/apache/parquet-format/blob/eb4b31c1d64a01088d02a2f9aefc6c17c54cc6fc/Encodings.md?plain=1#L35-L42 + // All physical type are little-endian + if *p > 9 { + //DECIMAL can be used to annotate the following types: + // + // int32: for 1 <= precision <= 9 + // int64: for 1 <= precision <= 18 + return true; + } + let b = (*v as i32).to_le_bytes(); + // Use Decimal constructor after https://github.com/apache/arrow-rs/issues/5325 + let decimal = Decimal::Int32 { + value: b, + precision: *p as i32, + scale: *s as i32, + }; + sbbf.check(&decimal) + } + Type::INT64 => { + if *p > 18 { + return true; + } + let b = (*v as i64).to_le_bytes(); + let decimal = Decimal::Int64 { + value: b, + precision: *p as i32, + scale: *s as i32, + }; + sbbf.check(&decimal) + } + Type::FIXED_LEN_BYTE_ARRAY => { + // keep with from_bytes_to_i128 + let b = v.to_be_bytes().to_vec(); + // Use Decimal constructor after https://github.com/apache/arrow-rs/issues/5325 + let decimal = Decimal::Bytes { + value: b.into(), + precision: *p as i32, + scale: *s as i32, + }; + sbbf.check(&decimal) + } + _ => true, + }, + // One more pattern matching since not all data types are supported + // inside of a Dictionary + ScalarValue::Dictionary(_, inner) => match inner.as_ref() { + ScalarValue::Int32(_) + | ScalarValue::Int64(_) + | ScalarValue::UInt32(_) + | ScalarValue::UInt64(_) + | ScalarValue::Float32(_) + | ScalarValue::Float64(_) + | ScalarValue::Utf8(_) + | ScalarValue::LargeUtf8(_) + | ScalarValue::Binary(_) + | ScalarValue::LargeBinary(_) => { + BloomFilterStatistics::check_scalar(sbbf, inner, parquet_type) + } + _ => true, + }, + _ => true, + } + } +} + +impl PruningStatistics for BloomFilterStatistics { + fn min_values(&self, _column: &Column) -> Option { + None + } + + fn max_values(&self, _column: &Column) -> Option { + None + } + + fn num_containers(&self) -> usize { + 1 + } + + fn null_counts(&self, _column: &Column) -> Option { + None + } + + fn row_counts(&self) -> Option { + None + } + + /// Use bloom filters to determine if we are sure this column can not + /// possibly contain `values` + /// + /// The `contained` API returns false if the bloom filters knows that *ALL* + /// of the values in a column are not present. + fn contained(&self, column: &Column, values: &HashSet) -> Option { + let (sbbf, parquet_type) = self.column_sbbf.get(column.name.as_str())?; + + // Bloom filters are probabilistic data structures that can return false + // positives (i.e. it might return true even if the value is not + // present) however, the bloom filter will return `false` if the value is + // definitely not present. + + let known_not_present = values + .iter() + .map(|value| BloomFilterStatistics::check_scalar(sbbf, value, parquet_type)) + // The row group doesn't contain any of the values if + // all the checks are false + .all(|v| !v); + + let contains = if known_not_present { + Some(false) + } else { + // Given the bloom filter is probabilistic, we can't be sure that + // the row group actually contains the values. Return `None` to + // indicate this uncertainty + None + }; + + Some(BooleanArray::from(vec![contains])) + } +} + +/// Wraps a slice of [`RowGroupMetaData`] in a way that implements [`PruningStatistics`] +struct RowGroupPruningStatistics<'a> { + parquet_schema: &'a SchemaDescriptor, + row_group_metadatas: Vec<&'a RowGroupMetaData>, + arrow_schema: &'a Schema, +} + +impl<'a> RowGroupPruningStatistics<'a> { + /// Return an iterator over the row group metadata + fn metadata_iter(&'a self) -> impl Iterator + 'a { + self.row_group_metadatas.iter().copied() + } + + fn statistics_converter<'b>(&'a self, column: &'b Column) -> Result> { + Ok(StatisticsConverter::try_new( + &column.name, + self.arrow_schema, + self.parquet_schema, + )?) + } +} + +impl PruningStatistics for RowGroupPruningStatistics<'_> { + fn min_values(&self, column: &Column) -> Option { + self.statistics_converter(column) + .and_then(|c| Ok(c.row_group_mins(self.metadata_iter())?)) + .ok() + } + + fn max_values(&self, column: &Column) -> Option { + self.statistics_converter(column) + .and_then(|c| Ok(c.row_group_maxes(self.metadata_iter())?)) + .ok() + } + + fn num_containers(&self) -> usize { + self.row_group_metadatas.len() + } + + fn null_counts(&self, column: &Column) -> Option { + self.statistics_converter(column) + .and_then(|c| Ok(c.row_group_null_counts(self.metadata_iter())?)) + .ok() + .map(|counts| Arc::new(counts) as ArrayRef) + } + + fn row_counts(&self) -> Option { + // Row counts are container-level — read directly from row group metadata. + let counts: UInt64Array = self + .metadata_iter() + .map(|rg| Some(rg.num_rows() as u64)) + .collect(); + Some(Arc::new(counts) as ArrayRef) + } + + fn contained(&self, _column: &Column, _values: &HashSet) -> Option { + None + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/plantime/source.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/plantime/source.rs new file mode 100644 index 0000000000000..fa9e3c7b3590a --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/plantime/source.rs @@ -0,0 +1,399 @@ +use super::opener::LiquidParquetOpener; +use crate::cache::LiquidCacheParquetRef; +use crate::reader::plantime::engagement_policy::{ + CacheEngagementPolicy, default_engagement_policy, +}; +use ahash::{HashMap, HashMapExt}; +use arrow_schema::Schema; +use bytes::Bytes; +use datafusion::{ + config::TableParquetOptions, + datasource::{ + listing::PartitionedFile, + physical_plan::{ + FileScanConfig, FileSource, ParquetFileMetrics, ParquetFileReaderFactory, + ParquetSource, parquet::PagePruningAccessPlanFilter, + }, + table_schema::TableSchema, + }, + error::Result, + physical_expr::projection::ProjectionExprs, + physical_expr_adapter::DefaultPhysicalExprAdapterFactory, + physical_optimizer::pruning::PruningPredicate, + physical_plan::{ + PhysicalExpr, + metrics::{ExecutionPlanMetricsSet, MetricBuilder}, + }, +}; +use futures::{FutureExt, future::BoxFuture}; +use object_store::{ObjectStore, path::Path}; +use parquet::{ + arrow::{ + arrow_reader::ArrowReaderOptions, + async_reader::{AsyncFileReader, ParquetObjectReader}, + }, + file::metadata::{PageIndexPolicy, ParquetMetaData, ParquetMetaDataReader}, +}; +use std::{ + ops::Range, + sync::{Arc, LazyLock}, +}; +use tokio::sync::RwLock; + +static META_CACHE: LazyLock = LazyLock::new(MetadataCache::new); + +/// Pre-seed the metadata cache with already-loaded metadata. +/// Callers that have pre-loaded parquet metadata (e.g., from a custom +/// ParquetFileReaderFactory) can inject it here so that LC's opener +/// skips the expensive `ArrowReaderMetadata::load_async()` call. +pub async fn pre_seed_metadata_cache(path: &Path, metadata: Arc) { + let mut cache = META_CACHE.val.write().await; + cache.entry(path.clone()).or_insert(metadata); +} + +#[derive(Debug)] +pub(crate) struct CachedMetaReaderFactory { + store: Arc, +} + +impl CachedMetaReaderFactory { + pub(crate) fn new(store: Arc) -> Self { + Self { store } + } + + pub(crate) fn create_liquid_reader( + &self, + partition_index: usize, + partitioned_file: PartitionedFile, + metadata_size_hint: Option, + metrics: &ExecutionPlanMetricsSet, + ) -> ParquetMetadataCacheReader { + let path = partitioned_file.object_meta.location.clone(); + let store = Arc::clone(&self.store); + let mut inner = ParquetObjectReader::new(store, path.clone()) + .with_file_size(partitioned_file.object_meta.size); + + if let Some(hint) = metadata_size_hint { + inner = inner.with_footer_size_hint(hint); + } + + ParquetMetadataCacheReader { + file_metrics: ParquetFileMetrics::new(partition_index, path.as_ref(), metrics), + inner, + path, + } + } +} + +impl ParquetFileReaderFactory for CachedMetaReaderFactory { + fn create_reader( + &self, + partition_index: usize, + partitioned_file: PartitionedFile, + metadata_size_hint: Option, + metrics: &ExecutionPlanMetricsSet, + ) -> Result> { + let reader = self.create_liquid_reader( + partition_index, + partitioned_file, + metadata_size_hint, + metrics, + ); + Ok(Box::new(reader)) + } +} + +struct MetadataCache { + val: RwLock>>, +} + +impl MetadataCache { + fn new() -> Self { + Self { + val: RwLock::new(HashMap::new()), + } + } +} + +#[derive(Clone)] +pub struct ParquetMetadataCacheReader { + file_metrics: ParquetFileMetrics, + inner: ParquetObjectReader, + path: Path, +} + +impl AsyncFileReader for ParquetMetadataCacheReader { + fn get_byte_ranges( + &mut self, + ranges: Vec>, + ) -> BoxFuture<'_, parquet::errors::Result>> { + let total: u64 = ranges.iter().map(|r| r.end - r.start).sum(); + self.file_metrics.bytes_scanned.add(total as usize); + self.inner.get_byte_ranges(ranges) + } + + fn get_bytes(&mut self, range: Range) -> BoxFuture<'_, parquet::errors::Result> { + self.file_metrics + .bytes_scanned + .add((range.end - range.start) as usize); + self.inner.get_bytes(range) + } + + fn get_metadata( + &mut self, + options: Option<&ArrowReaderOptions>, + ) -> BoxFuture<'_, parquet::errors::Result>> { + let path = self.path.clone(); + let options = options.cloned(); + async move { + // First check with read lock + { + let cache = META_CACHE.val.read().await; + if let Some(meta) = cache.get(&path) { + log::debug!("[LC-Meta] HIT path={}", path); + return Ok(meta.clone()); + } + } + + // Upgrade to write lock and double-check + let mut cache = META_CACHE.val.write().await; + match cache.entry(path.clone()) { + std::collections::hash_map::Entry::Occupied(entry) => { + log::debug!("[LC-Meta] HIT (race) path={}", path); + Ok(entry.get().clone()) + } + std::collections::hash_map::Entry::Vacant(entry) => { + log::debug!("[LC-Meta] MISS (loading from store) path={}", path); + let meta = self.inner.get_metadata(options.as_ref()).await?; + let meta = Arc::try_unwrap(meta).unwrap_or_else(|e| e.as_ref().clone()); + let mut reader = ParquetMetaDataReader::new_with_metadata(meta.clone()) + .with_page_index_policy(PageIndexPolicy::Optional); + reader.load_page_index(&mut self.inner).await?; + let meta = Arc::new(reader.finish()?); + entry.insert(meta.clone()); + Ok(meta) + } + } + } + .boxed() + } +} + +/// The data source for LiquidCache +#[derive(Clone)] +pub struct LiquidParquetSource { + metrics: ExecutionPlanMetricsSet, + predicate: Option>, + pruning_predicate: Option>, + page_pruning_predicate: Option>, + table_parquet_options: TableParquetOptions, + liquid_cache: LiquidCacheParquetRef, + batch_size: Option, + projection: ProjectionExprs, + table_schema: TableSchema, + /// Optional caller-provided reader factory with pre-loaded metadata. + /// When set, LC's opener uses this to get metadata instantly instead of + /// fetching from the object store. + parquet_file_reader_factory: Option>, + /// Policy that decides per-file whether to use LC stream or delegate to parquet. + engagement_policy: Arc, +} + +impl LiquidParquetSource { + fn reorder_filters(&self) -> bool { + self.table_parquet_options.global.reorder_filters + } + + /// Set the table schema for the LiquidParquetSource + pub fn with_table_schema(&self, table_schema: TableSchema) -> Self { + Self { + table_schema, + ..self.clone() + } + } + + /// Set predicate information, also sets pruning_predicate and page_pruning_predicate attributes + pub fn with_predicate( + mut self, + file_schema: Arc, + predicate: Arc, + ) -> Self { + let metrics = ExecutionPlanMetricsSet::new(); + let predicate_creation_errors = + MetricBuilder::new(&metrics).global_counter("num_predicate_creation_errors"); + + self.metrics = metrics; + self.predicate = Some(Arc::clone(&predicate)); + + match PruningPredicate::try_new(Arc::clone(&predicate), Arc::clone(&file_schema)) { + Ok(pruning_predicate) => { + if !pruning_predicate.always_true() { + self.pruning_predicate = Some(Arc::new(pruning_predicate)); + } + } + Err(e) => { + log::debug!("Could not create pruning predicate for: {e}"); + predicate_creation_errors.add(1); + } + }; + + let page_pruning_predicate = Arc::new(PagePruningAccessPlanFilter::new( + &predicate, + Arc::clone(&file_schema), + )); + self.page_pruning_predicate = Some(page_pruning_predicate); + + self + } + + /// Set predicate for row_filter only — no page-index pruning. + /// Used by the indexed path where the BoolNode's RowSelection is authoritative + /// and page-level statistics must not override it. + pub fn with_predicate_no_page_pruning( + mut self, + file_schema: Arc, + predicate: Arc, + ) -> Self { + let metrics = ExecutionPlanMetricsSet::new(); + let predicate_creation_errors = + MetricBuilder::new(&metrics).global_counter("num_predicate_creation_errors"); + self.metrics = metrics; + self.predicate = Some(Arc::clone(&predicate)); + + match PruningPredicate::try_new(Arc::clone(&predicate), Arc::clone(&file_schema)) { + Ok(pruning_predicate) => { + if !pruning_predicate.always_true() { + self.pruning_predicate = Some(Arc::new(pruning_predicate)); + } + } + Err(e) => { + log::debug!("Could not create pruning predicate for: {e}"); + predicate_creation_errors.add(1); + } + }; + + // Deliberately skip page_pruning_predicate — page-index stats must not + // prune pages that the caller's RowSelection already validated. + self + } + + /// Create a new LiquidParquetSource from a ParquetSource + pub fn from_parquet_source(source: ParquetSource, liquid_cache: LiquidCacheParquetRef) -> Self { + let predicate = source.filter(); + let reader_factory = source.parquet_file_reader_factory().cloned(); + + let table_schema = source.table_schema().clone(); + let file_schema = table_schema.file_schema().clone(); + let projection = source.projection().cloned().unwrap_or_else(|| { + let table_schema = table_schema.table_schema(); + ProjectionExprs::from_indices( + &(0..table_schema.fields().len()).collect::>(), + table_schema, + ) + }); + let mut v = Self { + table_schema, + table_parquet_options: source.table_parquet_options().clone(), + batch_size: Some(liquid_cache.batch_size()), + liquid_cache, + projection, + metrics: source.metrics().clone(), + predicate: None, + pruning_predicate: None, + page_pruning_predicate: None, + parquet_file_reader_factory: reader_factory, + engagement_policy: default_engagement_policy(), + }; + + if let Some(predicate) = predicate { + v = v.with_predicate(file_schema, predicate); + } + + v + } + + /// Set a custom cache engagement policy. + pub fn with_engagement_policy(mut self, policy: Arc) -> Self { + self.engagement_policy = policy; + self + } + + /// Get the predicate for the LiquidParquetSource + pub fn predicate(&self) -> Option> { + self.predicate.clone() + } +} + +impl FileSource for LiquidParquetSource { + fn create_file_opener( + &self, + object_store: Arc, + base_config: &FileScanConfig, + partition: usize, + ) -> Result> { + let expr_adapter_factory = base_config + .expr_adapter_factory + .clone() + .unwrap_or_else(|| Arc::new(DefaultPhysicalExprAdapterFactory) as _); + + let reader_factory = Arc::new(CachedMetaReaderFactory::new(object_store)); + + // If the caller provided a ParquetFileReaderFactory (with pre-loaded + // metadata), pre-seed LC's metadata cache for all files in this partition. + // This avoids expensive ArrowReaderMetadata::load_async() calls in the + // opener for files whose metadata we already have. + // Pass caller's reader factory to the opener so it can use pre-loaded + // metadata (avoids re-fetching from object store). + let caller_reader_factory = self.parquet_file_reader_factory.clone(); + + let opener = LiquidParquetOpener::new( + partition, + self.projection.clone(), + self.batch_size + .expect("Batch size must be set before creating LiquidParquetOpener"), + base_config.limit, + self.predicate.clone(), + self.table_schema.clone(), + self.metrics.clone(), + self.liquid_cache.clone(), + reader_factory, + self.reorder_filters(), + expr_adapter_factory, + caller_reader_factory, + Arc::clone(&self.engagement_policy), + ); + + Ok(Arc::new(opener)) + } + + fn with_batch_size(&self, batch_size: usize) -> Arc { + let mut conf = self.clone(); + conf.batch_size = Some(batch_size); + Arc::new(conf) + } + + fn table_schema(&self) -> &TableSchema { + &self.table_schema + } + + fn try_pushdown_projection( + &self, + projection: &ProjectionExprs, + ) -> Result>> { + let mut source = self.clone(); + source.projection = self.projection.try_merge(projection)?; + Ok(Some(Arc::new(source))) + } + + fn projection(&self) -> Option<&ProjectionExprs> { + Some(&self.projection) + } + + fn metrics(&self) -> &ExecutionPlanMetricsSet { + &self.metrics + } + + fn file_type(&self) -> &str { + "liquid_parquet" + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/runtime/liquid_cache_reader.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/runtime/liquid_cache_reader.rs new file mode 100644 index 0000000000000..22dffbc7ff725 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/runtime/liquid_cache_reader.rs @@ -0,0 +1,900 @@ +use std::collections::VecDeque; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; + +use arrow::array::{Array, ArrayRef, BooleanArray, RecordBatch}; +use arrow::buffer::BooleanBuffer; +use arrow::compute::prep_null_mask_filter; +use arrow::record_batch::RecordBatchOptions; +use arrow_schema::{ArrowError, Schema, SchemaRef}; +use futures::{Stream, StreamExt, future::BoxFuture, stream::BoxStream}; +use parquet::arrow::arrow_reader::{ + ArrowPredicate, ArrowReaderMetadata, ArrowReaderOptions, RowSelection, RowSelector, +}; +use parquet::arrow::{ParquetRecordBatchStreamBuilder, ProjectionMask}; +use parquet::errors::ParquetError; +use parquet::file::metadata::ParquetMetaData; + +use crate::cache::{BatchID, CachedRowGroupRef}; +use crate::reader::plantime::{LiquidRowFilter, ParquetMetadataCacheReader}; +use crate::reader::runtime::utils::take_next_batch; +use crate::utils::{boolean_buffer_and_then, row_selector_to_boolean_buffer}; + +pub(crate) struct LiquidCacheReader { + state: ReaderState, + row_filter: Option, +} + +enum ReaderState { + Ready(Box), + Processing( + BoxFuture< + 'static, + ( + LiquidCacheReaderInner, + Option, + ProcessResult, + ), + >, + ), + Finished, +} + +enum ProcessResult { + Emit(Result), + Skip, +} + +struct LiquidCacheReaderInner { + cached_row_group: CachedRowGroupRef, + current_batch_id: BatchID, + selection: VecDeque, + schema: SchemaRef, + batch_size: usize, + projection_columns: Vec, + parquet_fallback: ParquetFallback, + last_pull: Option<(BatchID, RecordBatch)>, +} + +pub(crate) struct LiquidCacheReaderConfig { + pub(crate) batch_size: usize, + pub(crate) selection: RowSelection, + pub(crate) row_filter: Option, + pub(crate) cached_row_group: CachedRowGroupRef, + pub(crate) projection_columns: Vec, + pub(crate) schema: SchemaRef, + pub(crate) parquet_fallback: ParquetFallbackConfig, +} + +#[derive(Clone)] +pub(crate) struct ParquetFallbackConfig { + pub(crate) row_group_idx: usize, + pub(crate) metadata: Arc, + pub(crate) input: ParquetMetadataCacheReader, + pub(crate) cache_projection: ProjectionMask, + pub(crate) cache_column_ids: Vec, + pub(crate) cache_batch_size: usize, + pub(crate) row_count: usize, +} + +struct ParquetFallback { + row_group_idx: usize, + metadata: Arc, + input: ParquetMetadataCacheReader, + cache_projection: ProjectionMask, + cache_column_ids: Vec, + cache_batch_size: usize, + row_count: usize, + stream: Option>>, + next_batch_id: BatchID, +} + +impl LiquidCacheReader { + pub(crate) fn new(config: LiquidCacheReaderConfig) -> Self { + let inner = LiquidCacheReaderInner::new( + config.batch_size, + config.selection, + config.cached_row_group, + config.projection_columns, + Arc::clone(&config.schema), + ParquetFallback::new(config.parquet_fallback), + ); + Self { + state: ReaderState::Ready(Box::new(inner)), + row_filter: config.row_filter, + } + } + + pub(crate) fn into_filter(self) -> Option { + debug_assert!( + matches!(self.state, ReaderState::Finished), + "cannot extract filter before reader completes" + ); + self.row_filter + } +} + +impl Stream for LiquidCacheReader { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + loop { + let state = std::mem::replace(&mut self.state, ReaderState::Finished); + + match state { + ReaderState::Processing(mut fut) => match fut.as_mut().poll(cx) { + Poll::Pending => { + self.state = ReaderState::Processing(fut); + return Poll::Pending; + } + Poll::Ready((inner, row_filter, result)) => { + self.row_filter = row_filter; + self.state = ReaderState::Ready(Box::new(inner)); + match result { + ProcessResult::Emit(item) => return Poll::Ready(Some(item)), + ProcessResult::Skip => continue, + } + } + }, + ReaderState::Ready(mut inner) => { + match take_next_batch(&mut inner.selection, inner.batch_size) { + Some(selection) => { + let inner = *inner; + let future = inner.next_batch(self.row_filter.take(), selection); + self.state = ReaderState::Processing(future); + continue; + } + None => { + self.state = ReaderState::Finished; + return Poll::Ready(None); + } + } + } + ReaderState::Finished => { + self.state = ReaderState::Finished; + return Poll::Ready(None); + } + } + } + } +} + +impl ParquetFallback { + fn new(config: ParquetFallbackConfig) -> Self { + Self { + row_group_idx: config.row_group_idx, + metadata: config.metadata, + input: config.input, + cache_projection: config.cache_projection, + cache_column_ids: config.cache_column_ids, + cache_batch_size: config.cache_batch_size, + row_count: config.row_count, + stream: None, + next_batch_id: BatchID::from_raw(0), + } + } + + async fn fetch_batch(&mut self, batch_id: BatchID) -> Result { + if self.stream.is_none() || batch_id != self.next_batch_id { + self.rebuild_stream(batch_id)?; + } + + let stream = self.stream.as_mut().expect("fallback stream is present"); + let record_batch = stream.next().await.transpose()?.ok_or_else(|| { + ParquetError::General(format!( + "parquet fallback ended before batch {}", + *batch_id as usize + )) + })?; + + self.next_batch_id = batch_id; + self.next_batch_id.inc(); + Ok(record_batch) + } + + fn rebuild_stream(&mut self, batch_id: BatchID) -> Result<(), ParquetError> { + let reader_metadata = + ArrowReaderMetadata::try_new(Arc::clone(&self.metadata), ArrowReaderOptions::new())?; + let row_selection = + build_row_selection_from(batch_id, self.cache_batch_size, self.row_count); + + let stream = + ParquetRecordBatchStreamBuilder::new_with_metadata(self.input.clone(), reader_metadata) + .with_projection(self.cache_projection.clone()) + .with_row_groups(vec![self.row_group_idx]) + .with_batch_size(self.cache_batch_size) + .with_row_selection(row_selection) + .build()? + .boxed(); + + self.stream = Some(stream); + self.next_batch_id = batch_id; + Ok(()) + } +} + +fn build_row_selection_from( + batch_id: BatchID, + batch_size: usize, + row_count: usize, +) -> RowSelection { + let start = usize::from(*batch_id) * batch_size; + let mut selectors = Vec::new(); + + if start > 0 { + selectors.push(RowSelector::skip(start.min(row_count))); + } + + if start >= row_count { + return RowSelection::from(selectors); + } + + let mut remaining = row_count - start; + while remaining > 0 { + let selected = remaining.min(batch_size); + selectors.push(RowSelector::select(selected)); + remaining -= selected; + } + + RowSelection::from(selectors) +} + +impl LiquidCacheReaderInner { + fn new( + batch_size: usize, + selection: RowSelection, + cached_row_group: CachedRowGroupRef, + projection_columns: Vec, + schema: SchemaRef, + parquet_fallback: ParquetFallback, + ) -> Self { + Self { + cached_row_group, + current_batch_id: BatchID::from_raw(0), + selection: selection.into(), + schema, + batch_size, + projection_columns, + parquet_fallback, + last_pull: None, + } + } + + fn next_batch( + self, + row_filter: Option, + selection: Vec, + ) -> BoxFuture<'static, (Self, Option, ProcessResult)> { + Box::pin(async move { + let mut inner = self; + let mut row_filter = row_filter; + inner.last_pull = None; + + let result = match inner + .build_predicate_filter(&mut row_filter, selection) + .await + { + Ok(selection) => match inner.read_from_cache(&selection).await { + // read_from_cache is async because a cache miss falls back + // to reading the batch from parquet. + Ok(Some(batch)) => { + inner.current_batch_id.inc(); + ProcessResult::Emit(Ok(batch)) + } + Ok(None) => { + inner.current_batch_id.inc(); + ProcessResult::Skip + } + Err(e) => ProcessResult::Emit(Err(e)), + }, + Err(e) => ProcessResult::Emit(Err(e)), + }; + + (inner, row_filter, result) + }) + } + + async fn build_predicate_filter( + &mut self, + row_filter: &mut Option, + selection: Vec, + ) -> Result { + let mut input_selection = row_selector_to_boolean_buffer(&selection); + + let Some(filter) = row_filter.as_mut() else { + return Ok(input_selection); + }; + + for predicate in filter.predicates_mut() { + if input_selection.count_set_bits() == 0 { + break; + } + + let boolean_array = match self.cached_row_group.evaluate_selection_with_predicate( + self.current_batch_id, + &input_selection, + predicate, + ) { + Some(result) => result?, + None => { + self.evaluate_predicate_after_materialize(&input_selection, predicate) + .await? + } + }; + + let boolean_mask = if boolean_array.null_count() == 0 { + boolean_array.into_parts().0 + } else { + prep_null_mask_filter(&boolean_array).into_parts().0 + }; + + input_selection = boolean_buffer_and_then(&input_selection, &boolean_mask); + } + + Ok(input_selection) + } + + async fn read_from_cache( + &mut self, + selection: &BooleanBuffer, + ) -> Result, ArrowError> { + let selected_rows = selection.count_set_bits(); + if selected_rows == 0 { + return Ok(None); + } + + if self.projection_columns.is_empty() { + let options = RecordBatchOptions::new().with_row_count(Some(selected_rows)); + let batch = + RecordBatch::try_new_with_options(self.schema.clone(), Vec::new(), &options) + .unwrap(); + return Ok(Some(batch)); + } + + // Phase 1: Try cache for all columns, collect hits and track misses. + let mut arrays: Vec> = vec![None; self.projection_columns.len()]; + let mut has_miss = false; + let mut hit_count = 0usize; + let mut miss_count = 0usize; + + for (i, &column_idx) in self.projection_columns.iter().enumerate() { + let column = self + .cached_row_group + .get_column(column_idx as u64) + .ok_or_else(|| { + ArrowError::ComputeError(format!( + "column {column_idx} not present in liquid cache" + )) + })?; + + let array = column.get_arrow_array_with_filter(self.current_batch_id, selection); + + if let Some(arr) = array { + arrays[i] = Some(arr); + hit_count += 1; + } else { + has_miss = true; + miss_count += 1; + } + } + + if *self.current_batch_id == 0 { + log::debug!( + "[LC-Reader] batch_id={}, selected_rows={}, cols={}, hits={}, misses={}, fallback={}", + *self.current_batch_id, + selected_rows, + self.projection_columns.len(), + hit_count, + miss_count, + has_miss, + ); + } + + // Phase 2: If any columns missed, read from parquet ONCE for all misses. + if has_miss { + let record_batch = self + .read_parquet_batch_and_fill_cache(self.current_batch_id) + .await?; + + for (i, &column_idx) in self.projection_columns.iter().enumerate() { + if arrays[i].is_none() { + let array = self.parquet_array(&record_batch, column_idx)?; + arrays[i] = Some(filter_array(array, selection)?); + } + } + } + + let final_arrays: Vec = arrays.into_iter().map(|a| a.unwrap()).collect(); + Ok(Some( + RecordBatch::try_new(self.schema.clone(), final_arrays).unwrap(), + )) + } + + async fn read_parquet_batch_and_fill_cache( + &mut self, + batch_id: BatchID, + ) -> Result { + if let Some((pulled_batch_id, record_batch)) = &self.last_pull + && *pulled_batch_id == batch_id + { + return Ok(record_batch.clone()); + } + + let record_batch = self + .parquet_fallback + .fetch_batch(batch_id) + .await + .map_err(|e| ArrowError::ComputeError(format!("parquet fallback read failed: {e}")))?; + + // Spawn cache fill asynchronously — transcoding Arrow→Liquid should not + // block the query hot path. The batch is returned immediately; cache + // population happens in the background. + let cached_row_group = self.cached_row_group.clone(); + let cache_column_ids = self.parquet_fallback.cache_column_ids.clone(); + let batch_for_cache = record_batch.clone(); + tokio::spawn(async move { + for (col_idx, file_column_id) in cache_column_ids.iter().copied().enumerate() { + let Some(column) = cached_row_group.get_column(file_column_id as u64) else { + continue; + }; + let array = Arc::clone(batch_for_cache.column(col_idx)); + let _ = column.insert(batch_id, array); + } + }); + + self.last_pull = Some((batch_id, record_batch.clone())); + Ok(record_batch) + } + + async fn evaluate_predicate_after_materialize( + &mut self, + selection: &BooleanBuffer, + predicate: &mut crate::reader::LiquidPredicate, + ) -> Result { + let record_batch = self + .read_parquet_batch_and_fill_cache(self.current_batch_id) + .await?; + + if let Some(result) = self.cached_row_group.evaluate_selection_with_predicate( + self.current_batch_id, + selection, + predicate, + ) { + return result; + } + + let column_ids = predicate.predicate_column_ids(); + let mut arrays = Vec::with_capacity(column_ids.len()); + let mut fields = Vec::with_capacity(column_ids.len()); + + for column_id in column_ids { + let array = self.parquet_array(&record_batch, column_id)?; + arrays.push(filter_array(array, selection)?); + + let field = self + .cached_row_group + .get_column(column_id as u64) + .ok_or_else(|| { + ArrowError::ComputeError(format!( + "column {column_id} not present in liquid cache" + )) + })? + .field() + .as_ref() + .clone(); + fields.push(field); + } + + let schema = Arc::new(Schema::new(fields)); + let predicate_batch = if arrays.is_empty() { + let options = + RecordBatchOptions::new().with_row_count(Some(selection.count_set_bits())); + RecordBatch::try_new_with_options(schema, arrays, &options)? + } else { + RecordBatch::try_new(schema, arrays)? + }; + + predicate.evaluate(predicate_batch) + } + + fn parquet_array( + &self, + record_batch: &RecordBatch, + file_column_id: usize, + ) -> Result { + let position = self + .parquet_fallback + .cache_column_ids + .iter() + .position(|column_id| *column_id == file_column_id) + .ok_or_else(|| { + ArrowError::ComputeError(format!( + "column {file_column_id} not present in parquet fallback projection" + )) + })?; + + Ok(Arc::clone(record_batch.column(position))) + } +} + +fn filter_array(array: ArrayRef, selection: &BooleanBuffer) -> Result { + let selection_array = BooleanArray::new(selection.clone(), None); + arrow::compute::filter(array.as_ref(), &selection_array) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + cache::LiquidCacheParquet, + reader::plantime::CachedMetaReaderFactory, + reader::{FilterCandidateBuilder, LiquidPredicate, LiquidRowFilter}, + }; + use arrow::array::{ArrayRef, Int32Array}; + use arrow::record_batch::RecordBatch; + use arrow_schema::{DataType, Field, Schema, SchemaRef}; + use datafusion::datasource::listing::PartitionedFile; + use datafusion::physical_plan::metrics::ExecutionPlanMetricsSet; + use datafusion::{ + logical_expr::Operator, + physical_expr::PhysicalExpr, + physical_expr::expressions::{BinaryExpr, Column, Literal}, + scalar::ScalarValue, + }; + use futures::{StreamExt, pin_mut}; + use liquid_cache::cache::TranscodeEvict; + use liquid_cache::cache_policies::LiquidPolicy; + use object_store::local::LocalFileSystem; + use parquet::arrow::{ + ArrowWriter, ProjectionMask, + arrow_reader::{ArrowReaderMetadata, ArrowReaderOptions, RowSelection, RowSelector}, + }; + use std::fs::File; + use std::sync::Arc; + + struct TestRowGroup { + batch_size: usize, + row_group: CachedRowGroupRef, + schema: SchemaRef, + fallback: ParquetFallbackConfig, + _tmp_dir: tempfile::TempDir, + } + + struct ReaderRequest { + selection: RowSelection, + row_filter: Option, + projection_columns: Vec, + schema: SchemaRef, + } + + impl TestRowGroup { + fn reader(&self, request: ReaderRequest) -> LiquidCacheReader { + LiquidCacheReader::new(LiquidCacheReaderConfig { + batch_size: self.batch_size, + selection: request.selection, + row_filter: request.row_filter, + cached_row_group: Arc::clone(&self.row_group), + projection_columns: request.projection_columns, + schema: request.schema, + parquet_fallback: self.fallback.clone(), + }) + } + } + + async fn make_row_group(batch_size: usize, batches: &[Vec]) -> TestRowGroup { + let tmp_dir = tempfile::tempdir().unwrap(); + let field = Arc::new(Field::new("col0", DataType::Int32, false)); + let schema = Arc::new(Schema::new(vec![field.clone()])); + let parquet_path = tmp_dir.path().join("data.parquet"); + let file = File::create(&parquet_path).unwrap(); + let mut writer = ArrowWriter::try_new(file, Arc::clone(&schema), None).unwrap(); + for values in batches { + let array: ArrayRef = Arc::new(Int32Array::from(values.clone())); + let batch = RecordBatch::try_new(Arc::clone(&schema), vec![array]).unwrap(); + writer.write(&batch).unwrap(); + } + writer.close().unwrap(); + + let metadata_file = File::open(&parquet_path).unwrap(); + let reader_metadata = + ArrowReaderMetadata::load(&metadata_file, ArrowReaderOptions::new()).unwrap(); + let object_store = Arc::new(LocalFileSystem::new_with_prefix(tmp_dir.path()).unwrap()); + let partitioned_file = PartitionedFile::new( + "data.parquet", + std::fs::metadata(&parquet_path).unwrap().len(), + ); + let metrics = ExecutionPlanMetricsSet::new(); + let input = CachedMetaReaderFactory::new(object_store).create_liquid_reader( + 0, + partitioned_file, + None, + &metrics, + ); + let projection = ProjectionMask::roots( + reader_metadata.metadata().file_metadata().schema_descr(), + [0], + ); + + let cache = LiquidCacheParquet::new( + batch_size, + usize::MAX, + Box::new(LiquidPolicy::new()), + Box::new(TranscodeEvict), + ); + let file = cache.register_or_get_file("test".to_string(), schema.clone()); + // Mark col0 as a predicate column so it is cacheable in tests. + let row_group = file.create_row_group(0, vec![0]); + let column = row_group.get_column(0).unwrap(); + + for (idx, values) in batches.iter().enumerate() { + let array: ArrayRef = Arc::new(Int32Array::from(values.clone())); + column + .insert(BatchID::from_raw(idx as u16), array) + .expect("cache insert"); + } + + TestRowGroup { + batch_size, + row_group, + schema, + fallback: ParquetFallbackConfig { + row_group_idx: 0, + metadata: Arc::clone(reader_metadata.metadata()), + input, + cache_projection: projection, + cache_column_ids: vec![0], + cache_batch_size: batch_size, + row_count: flatten_batches(batches).len(), + }, + _tmp_dir: tmp_dir, + } + } + + fn flatten_batches(batches: &[Vec]) -> Vec { + batches.iter().flat_map(|b| b.iter().copied()).collect() + } + + fn collect_batches(reader: LiquidCacheReader) -> Vec { + futures::executor::block_on( + reader + .map(|batch| batch.expect("valid record batch")) + .collect::>(), + ) + } + + fn as_i32_values(batch: &RecordBatch) -> Vec { + let array = batch + .column(0) + .as_any() + .downcast_ref::() + .expect("int column"); + array.iter().map(|v| v.expect("non-null")).collect() + } + + fn build_filter( + schema: SchemaRef, + values: &[i32], + expr: Arc, + ) -> LiquidRowFilter { + let tmp_meta = tempfile::NamedTempFile::new().unwrap(); + let array: ArrayRef = Arc::new(Int32Array::from(values.to_vec())); + let batch = RecordBatch::try_new(Arc::clone(&schema), vec![array.clone()]).unwrap(); + let mut writer = + ArrowWriter::try_new(tmp_meta.reopen().unwrap(), Arc::clone(&schema), None).unwrap(); + writer.write(&batch).unwrap(); + writer.close().unwrap(); + + let file = std::fs::File::open(tmp_meta.path()).unwrap(); + let metadata = ArrowReaderMetadata::load(&file, ArrowReaderOptions::new()).unwrap(); + + let builder = FilterCandidateBuilder::new(expr, Arc::clone(&schema)); + let candidate = builder.build(metadata.metadata()).unwrap().unwrap(); + let projection = candidate.projection(metadata.metadata()); + let predicate = LiquidPredicate::try_new(candidate, projection).unwrap(); + + LiquidRowFilter::new(vec![predicate]) + } + + fn make_gt_filter(schema: SchemaRef, values: &[i32], literal: i32) -> LiquidRowFilter { + let expr: Arc = Arc::new(BinaryExpr::new( + Arc::new(Column::new("col0", 0)), + Operator::Gt, + Arc::new(Literal::new(ScalarValue::Int32(Some(literal)))), + )); + build_filter(schema, values, expr) + } + + #[tokio::test] + async fn reads_batches_in_order() { + let batch_size = 2; + let test = make_row_group(batch_size, &[vec![1, 2], vec![3, 4]]).await; + let selection = RowSelection::from(vec![RowSelector::select(4)]); + + let reader = test.reader(ReaderRequest { + selection, + row_filter: None, + projection_columns: vec![0], + schema: Arc::clone(&test.schema), + }); + + let batches = collect_batches(reader); + assert_eq!(batches.len(), 2); + assert_eq!(as_i32_values(&batches[0]), vec![1, 2]); + assert_eq!(as_i32_values(&batches[1]), vec![3, 4]); + } + + #[tokio::test] + async fn skips_unselected_batches() { + let batch_size = 2; + let test = make_row_group(batch_size, &[vec![1, 2], vec![3, 4]]).await; + let selection = RowSelection::from(vec![RowSelector::skip(2), RowSelector::select(2)]); + + let reader = test.reader(ReaderRequest { + selection, + row_filter: None, + projection_columns: vec![0], + schema: Arc::clone(&test.schema), + }); + + let batches = collect_batches(reader); + assert_eq!(batches.len(), 1); + assert_eq!(as_i32_values(&batches[0]), vec![3, 4]); + } + + #[tokio::test] + async fn empty_projection_emits_schema_only_batches() { + let batch_size = 2; + let test = make_row_group(batch_size, &[vec![10, 11]]).await; + let selection = RowSelection::from(vec![RowSelector::select(2)]); + + let reader = test.reader(ReaderRequest { + selection, + row_filter: None, + projection_columns: Vec::new(), + schema: Arc::new(Schema::new(Vec::::new())), + }); + + let batches = collect_batches(reader); + assert_eq!(batches.len(), 1); + let batch = &batches[0]; + assert_eq!(batch.num_columns(), 0); + assert_eq!(batch.num_rows(), 2); + } + + #[tokio::test] + async fn into_filter_returns_stored_filter_after_completion() { + let batch_size = 2; + let test = make_row_group(batch_size, &[vec![1, 2]]).await; + let selection = RowSelection::from(Vec::::new()); + let filter = LiquidRowFilter::new(Vec::new()); + + let mut reader = test.reader(ReaderRequest { + selection, + row_filter: Some(filter), + projection_columns: vec![0], + schema: Arc::clone(&test.schema), + }); + + let waker = futures::task::noop_waker(); + let mut cx = Context::from_waker(&waker); + assert!(matches!( + Pin::new(&mut reader).poll_next(&mut cx), + Poll::Ready(None) + )); + + assert!(reader.into_filter().is_some()); + } + + #[tokio::test] + async fn predicate_filters_rows_across_batches() { + let batches = vec![vec![1, 2], vec![3, 4]]; + let batch_size = 2; + let all_values = flatten_batches(&batches); + let test = make_row_group(batch_size, &batches).await; + let filter = make_gt_filter(Arc::clone(&test.schema), &all_values, 2); + let selection = RowSelection::from(vec![RowSelector::select(4)]); + + let reader = test.reader(ReaderRequest { + selection, + row_filter: Some(filter), + projection_columns: vec![0], + schema: Arc::clone(&test.schema), + }); + + let batches = collect_batches(reader); + assert_eq!(batches.len(), 1); + assert_eq!(as_i32_values(&batches[0]), vec![3, 4]); + } + + fn make_or_filter( + schema: SchemaRef, + values: &[i32], + gt_literal: i32, + lt_literal: i32, + ) -> LiquidRowFilter { + let cond1: Arc = Arc::new(BinaryExpr::new( + Arc::new(Column::new("col0", 0)), + Operator::Gt, + Arc::new(Literal::new(ScalarValue::Int32(Some(gt_literal)))), + )); + let cond2: Arc = Arc::new(BinaryExpr::new( + Arc::new(Column::new("col0", 0)), + Operator::Lt, + Arc::new(Literal::new(ScalarValue::Int32(Some(lt_literal)))), + )); + let expr: Arc = Arc::new(BinaryExpr::new(cond1, Operator::Or, cond2)); + build_filter(schema, values, expr) + } + + #[tokio::test] + async fn predicate_filters_or_rows() { + let batches = vec![vec![1, 2], vec![3, 4], vec![5, 6]]; + let batch_size = 2; + let all_values = flatten_batches(&batches); + let test = make_row_group(batch_size, &batches).await; + let filter = make_or_filter(Arc::clone(&test.schema), &all_values, 4, 2); + let selection = RowSelection::from(vec![RowSelector::select(6)]); + + let reader = test.reader(ReaderRequest { + selection, + row_filter: Some(filter), + projection_columns: vec![0], + schema: Arc::clone(&test.schema), + }); + + let batches = collect_batches(reader); + assert_eq!(batches.len(), 2); + assert_eq!(as_i32_values(&batches[0]), vec![1]); + assert_eq!(as_i32_values(&batches[1]), vec![5, 6]); + } + + #[tokio::test] + async fn predicate_combines_with_selection() { + let batches = vec![vec![1, 2, 3, 4]]; + let batch_size = 4; + let all_values = flatten_batches(&batches); + let test = make_row_group(batch_size, &batches).await; + let filter = make_gt_filter(Arc::clone(&test.schema), &all_values, 2); + let selection = RowSelection::from(vec![ + RowSelector::skip(1), + RowSelector::select(2), + RowSelector::skip(1), + ]); + + let reader = test.reader(ReaderRequest { + selection, + row_filter: Some(filter), + projection_columns: vec![0], + schema: Arc::clone(&test.schema), + }); + + let mut batches = collect_batches(reader); + assert_eq!(batches.len(), 1); + assert_eq!(as_i32_values(&batches.pop().unwrap()), vec![3]); + } + + #[tokio::test] + async fn predicate_can_filter_all_rows() { + let batches = vec![vec![1, 2]]; + let batch_size = 2; + let all_values = flatten_batches(&batches); + let test = make_row_group(batch_size, &batches).await; + let filter = make_gt_filter(Arc::clone(&test.schema), &all_values, 10); + let selection = RowSelection::from(vec![RowSelector::select(2)]); + + let reader = test.reader(ReaderRequest { + selection, + row_filter: Some(filter), + projection_columns: vec![0], + schema: Arc::clone(&test.schema), + }); + + let next_batch = futures::executor::block_on(async { + pin_mut!(reader); + reader.next().await + }); + + assert!(next_batch.is_none()); + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/runtime/liquid_predicate.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/runtime/liquid_predicate.rs new file mode 100644 index 0000000000000..2cae21e29421d --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/runtime/liquid_predicate.rs @@ -0,0 +1,163 @@ +use std::sync::Arc; + +use datafusion::logical_expr::Operator; +use datafusion::physical_expr::PhysicalExpr; +use datafusion::physical_expr::expressions::{ + BinaryExpr, CastExpr, Column, LikeExpr, Literal, TryCastExpr, +}; + +/// Extract multiple column-literal expressions from a nested OR structure. +/// Returns a vector of (column_name, expression) pairs if all leaf expressions +/// are column-literal expressions connected by OR operators. +pub(crate) fn extract_multi_column_or( + expr: &Arc, +) -> Option)>> { + let mut result = Vec::new(); + + fn collect_or_expressions<'a>( + expr: &'a Arc, + result: &mut Vec<(&'a str, Arc)>, + ) -> bool { + if let Some(binary) = expr.downcast_ref::() + && binary.op() == &Operator::Or + { + // Recursively collect from left and right + return collect_or_expressions(binary.left(), result) + && collect_or_expressions(binary.right(), result); + } + + // Try to extract column-literal from this expression + if let Some(column_literal) = extract_column_literal(expr) { + result.push(column_literal); + true + } else { + false + } + } + + if collect_or_expressions(expr, &mut result) && result.len() >= 2 { + Some(result) + } else { + None + } +} + +fn extract_column_literal(expr: &Arc) -> Option<(&str, Arc)> { + if let Some(binary) = expr.downcast_ref::() + && binary.right().is::() + { + return extract_column_literal(binary.left()); + } else if let Some(like_expr) = expr.downcast_ref::() + && like_expr.pattern().is::() + { + return extract_column_literal(like_expr.expr()); + } else if let Some(cast_expr) = expr.downcast_ref::() { + return extract_column_literal(cast_expr.expr()); + } else if let Some(try_cast_expr) = expr.downcast_ref::() { + return extract_column_literal(try_cast_expr.expr()); + } else if let Some(column) = expr.downcast_ref::() { + return Some((column.name(), Arc::clone(expr))); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use datafusion::common::ScalarValue; + use datafusion::logical_expr::Operator; + use datafusion::physical_expr::expressions::{BinaryExpr, Literal}; + use datafusion::physical_plan::expressions::Column; + + #[test] + fn test_extract_multi_column_or_valid_three_columns() { + // Test case: a = 1 OR b = 2 OR c = 3 + // This should extract 3 column-literal pairs + + let expr_a: Arc = Arc::new(BinaryExpr::new( + Arc::new(Column::new("a", 0)), + Operator::Eq, + Arc::new(Literal::new(ScalarValue::Int32(Some(1)))), + )); + + let expr_b: Arc = Arc::new(BinaryExpr::new( + Arc::new(Column::new("b", 1)), + Operator::Eq, + Arc::new(Literal::new(ScalarValue::Int32(Some(2)))), + )); + + let expr_c: Arc = Arc::new(BinaryExpr::new( + Arc::new(Column::new("c", 2)), + Operator::Eq, + Arc::new(Literal::new(ScalarValue::Int32(Some(3)))), + )); + + // Build nested OR: (a = 1 OR b = 2) OR c = 3 + let expr_ab = Arc::new(BinaryExpr::new(expr_a, Operator::Or, expr_b)); + let expr_final: Arc = + Arc::new(BinaryExpr::new(expr_ab, Operator::Or, expr_c)); + + let result = extract_multi_column_or(&expr_final); + + assert!(result.is_some()); + let column_exprs = result.unwrap(); + assert_eq!(column_exprs.len(), 3); + + // Verify we got the correct column names + let mut column_names: Vec<&str> = column_exprs.iter().map(|(name, _)| *name).collect(); + column_names.sort(); + assert_eq!(column_names, vec!["a", "b", "c"]); + } + + #[test] + fn test_extract_multi_column_or_invalid_expression() { + // Test case: a + b = 5 (not a column-literal OR expression) + // This should return None + + let add_expr: Arc = Arc::new(BinaryExpr::new( + Arc::new(Column::new("a", 0)), + Operator::Plus, + Arc::new(Column::new("b", 1)), + )); + + let expr: Arc = Arc::new(BinaryExpr::new( + add_expr, + Operator::Eq, + Arc::new(Literal::new(ScalarValue::Int32(Some(5)))), + )); + + let result = extract_multi_column_or(&expr); + assert!(result.is_none()); + + // Test case: Single column expression (a = 1) + // This should return None because we need >= 2 columns + let single_expr: Arc = Arc::new(BinaryExpr::new( + Arc::new(Column::new("a", 0)), + Operator::Eq, + Arc::new(Literal::new(ScalarValue::Int32(Some(1)))), + )); + + let result = extract_multi_column_or(&single_expr); + assert!(result.is_none()); + + // Test case: Mixed valid and invalid OR (a = 1 OR (b + c)) + // This should return None because one branch is not column-literal + let valid_expr: Arc = Arc::new(BinaryExpr::new( + Arc::new(Column::new("a", 0)), + Operator::Eq, + Arc::new(Literal::new(ScalarValue::Int32(Some(1)))), + )); + + let invalid_expr: Arc = Arc::new(BinaryExpr::new( + Arc::new(Column::new("b", 1)), + Operator::Plus, + Arc::new(Column::new("c", 2)), + )); + + let mixed_expr: Arc = + Arc::new(BinaryExpr::new(valid_expr, Operator::Or, invalid_expr)); + + let result = extract_multi_column_or(&mixed_expr); + assert!(result.is_none()); + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/runtime/liquid_stream.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/runtime/liquid_stream.rs new file mode 100644 index 0000000000000..30669ae6fac76 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/runtime/liquid_stream.rs @@ -0,0 +1,712 @@ +use crate::cache::{CachedFileRef, CachedRowGroupRef}; +use crate::reader::plantime::{LiquidRowFilter, ParquetMetadataCacheReader}; +use arrow::array::RecordBatch; +use arrow_schema::{Schema, SchemaRef}; +use futures::Stream; +use parquet::{ + arrow::{ + ProjectionMask, + arrow_reader::{ArrowPredicate, RowSelection, RowSelector}, + }, + errors::ParquetError, + file::metadata::ParquetMetaData, +}; +use std::{ + collections::VecDeque, + fmt::Formatter, + pin::Pin, + sync::Arc, + task::{Context, Poll}, +}; + +use super::liquid_cache_reader::{ + LiquidCacheReader, LiquidCacheReaderConfig, ParquetFallbackConfig, +}; +use super::utils::{get_root_column_ids, limit_row_selection, offset_row_selection}; + +type PlanResult = Option; + +struct ReaderFactory { + metadata: Arc, + + input: ParquetMetadataCacheReader, + + filter: Option, + + limit: Option, + + offset: Option, + + cached_file: CachedFileRef, +} + +impl ReaderFactory { + /// Plans what to read from cache vs parquet for the next row group + fn plan_row_group( + &mut self, + row_group_idx: usize, + selection: Option, + projection: ProjectionMask, + batch_size: usize, + ) -> PlanResult { + let meta = self.metadata.row_group(row_group_idx); + + let mut predicate_projection: Option = None; + if let Some(filter) = self.filter.as_mut() { + for predicate in filter.predicates_mut() { + let p_projection = predicate.projection(); + if let Some(ref mut p) = predicate_projection { + p.union(p_projection); + } else { + predicate_projection = Some(p_projection.clone()); + } + } + } + + let mut selection = + selection.unwrap_or_else(|| vec![RowSelector::select(meta.num_rows() as usize)].into()); + + let rows_before = selection.row_count(); + + if rows_before == 0 { + return None; + } + + if let Some(offset) = self.offset { + selection = offset_row_selection(selection, offset); + } + + if let Some(limit) = self.limit { + selection = limit_row_selection(selection, limit); + } + + let rows_after = selection.row_count(); + + // Update offset if necessary + if let Some(offset) = &mut self.offset { + // Reduction is either because of offset or limit, as limit is applied + // after offset has been "exhausted" can just use saturating sub here + *offset = offset.saturating_sub(rows_before - rows_after) + } + + if rows_after == 0 { + return None; + } + + if let Some(limit) = &mut self.limit { + *limit -= rows_after; + } + + let mut cache_projection = projection.clone(); + if let Some(ref predicate_projection) = predicate_projection { + cache_projection.union(predicate_projection); + } + + let schema_descr = self.metadata.file_metadata().schema_descr(); + let cache_column_ids = get_root_column_ids(schema_descr, &cache_projection); + // When no predicate is present, all projected columns are cacheable. + // Without this, `is_predicate_column` is false for every column and + // the cache is effectively disabled (get returns None, insert rejects). + let predicate_column_ids = if let Some(ref predicate_projection) = predicate_projection { + get_root_column_ids(schema_descr, predicate_projection) + } else { + cache_column_ids.clone() + }; + + if row_group_idx == 0 { + log::debug!( + "[LC-Stream] plan_row_group: rg={}, cache_cols={:?}, predicate_cols={:?}, \ + has_filter={}, rows={}", + row_group_idx, + &cache_column_ids, + &predicate_column_ids, + self.filter.is_some(), + self.metadata.row_group(row_group_idx).num_rows(), + ); + } + + let cached_row_group = self + .cached_file + .create_row_group(row_group_idx as u64, predicate_column_ids.clone()); + + let projection_column_ids = get_root_column_ids(schema_descr, &projection); + + let context = PlanningContext { + row_group_idx, + selection, + batch_size, + cached_row_group, + cache_projection, + projection_column_ids, + cache_column_ids, + }; + + Some(context) + } +} + +fn build_projection_schema(file_schema: &SchemaRef, projection_column_ids: &[usize]) -> SchemaRef { + let fields: Vec<_> = projection_column_ids + .iter() + .filter_map(|column_id| file_schema.fields().get(*column_id)) + .map(|field_ref| field_ref.as_ref().clone()) + .collect(); + Arc::new(Schema::new(fields)) +} + +/// Context for planning what to read from cache vs parquet +struct PlanningContext { + row_group_idx: usize, + selection: RowSelection, + batch_size: usize, + cached_row_group: CachedRowGroupRef, + cache_projection: ProjectionMask, + projection_column_ids: Vec, + cache_column_ids: Vec, +} + +fn build_liquid_cache_reader( + reader_factory: &mut ReaderFactory, + context: PlanningContext, + schema: SchemaRef, +) -> LiquidCacheReader { + let row_count = reader_factory + .metadata + .row_group(context.row_group_idx) + .num_rows() as usize; + let cache_batch_size = context.cached_row_group.batch_size(); + LiquidCacheReader::new(LiquidCacheReaderConfig { + batch_size: context.batch_size, + selection: context.selection, + row_filter: reader_factory.filter.take(), + cached_row_group: context.cached_row_group, + projection_columns: context.projection_column_ids, + schema, + parquet_fallback: ParquetFallbackConfig { + row_group_idx: context.row_group_idx, + metadata: Arc::clone(&reader_factory.metadata), + input: reader_factory.input.clone(), + cache_projection: context.cache_projection, + cache_column_ids: context.cache_column_ids, + cache_batch_size, + row_count, + }, + }) +} + +enum StreamState { + /// At the start of a new row group, or the end of the parquet stream + Init, + /// Decoding a batch from cache + ReadFromCache(Box), +} + +impl std::fmt::Debug for StreamState { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + StreamState::Init => write!(f, "StreamState::Init"), + StreamState::ReadFromCache(_) => write!(f, "StreamState::Decoding"), + } + } +} + +pub struct LiquidStreamBuilder { + pub(crate) input: ParquetMetadataCacheReader, + + pub(crate) metadata: Arc, + + pub(crate) batch_size: usize, + + pub(crate) row_groups: Option>, + + pub(crate) projection: ProjectionMask, + + pub(crate) filter: Option, + + pub(crate) selection: Option, + + pub(crate) limit: Option, + + pub(crate) offset: Option, +} + +impl LiquidStreamBuilder { + pub fn new(input: ParquetMetadataCacheReader, metadata: Arc) -> Self { + Self { + input, + metadata, + batch_size: 1024, + row_groups: None, + projection: ProjectionMask::all(), + filter: None, + selection: None, + limit: None, + offset: None, + } + } + + pub fn with_batch_size(mut self, batch_size: usize) -> Self { + self.batch_size = batch_size; + self + } + + pub fn with_row_groups(mut self, row_groups: Vec) -> Self { + self.row_groups = Some(row_groups); + self + } + + pub fn with_projection(mut self, projection: ProjectionMask) -> Self { + self.projection = projection; + self + } + + pub fn with_selection(mut self, selection: Option) -> Self { + self.selection = selection; + self + } + + pub fn with_limit(mut self, limit: Option) -> Self { + self.limit = limit; + self + } + + pub fn with_row_filter(mut self, filter: LiquidRowFilter) -> Self { + self.filter = Some(filter); + self + } + + pub fn build(self, liquid_cache: CachedFileRef) -> Result { + let num_row_groups = self.metadata.row_groups().len(); + + let row_groups: VecDeque = match self.row_groups { + Some(row_groups) => { + if let Some(col) = row_groups.iter().find(|x| **x >= num_row_groups) { + return Err(ParquetError::ArrowError(format!( + "row group {col} out of bounds 0..{num_row_groups}" + ))); + } + row_groups.into() + } + None => (0..self.metadata.row_groups().len()).collect(), + }; + + let batch_size = self + .batch_size + .min(self.metadata.file_metadata().num_rows() as usize); + + let schema_descr = self.metadata.file_metadata().schema_descr(); + let projection_column_ids = get_root_column_ids(schema_descr, &self.projection); + let file_schema = liquid_cache.schema(); + let schema = build_projection_schema(&file_schema, &projection_column_ids); + + let reader = ReaderFactory { + metadata: Arc::clone(&self.metadata), + input: self.input, + filter: self.filter, + limit: self.limit, + offset: self.offset, + cached_file: liquid_cache, + }; + + Ok(LiquidStream { + metadata: self.metadata, + schema, + row_groups, + projection: self.projection, + batch_size, + selection: self.selection, + reader: Some(reader), + state: StreamState::Init, + }) + } +} + +pub struct LiquidStream { + metadata: Arc, + + schema: SchemaRef, + + row_groups: VecDeque, + + projection: ProjectionMask, + + batch_size: usize, + + selection: Option, + + /// This is an option so it can be moved into a future + reader: Option, + + state: StreamState, +} + +impl std::fmt::Debug for LiquidStream { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ParquetRecordBatchStream") + .field("metadata", &self.metadata) + .field("schema", &self.schema) + .field("batch_size", &self.batch_size) + .field("projection", &self.projection) + .field("state", &self.state) + .finish() + } +} + +impl LiquidStream { + pub fn schema(&self) -> &SchemaRef { + &self.schema + } +} + +impl Stream for LiquidStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + loop { + let state = std::mem::replace(&mut self.state, StreamState::Init); + + match state { + StreamState::ReadFromCache(mut batch_reader) => { + match Pin::new(&mut *batch_reader).poll_next(cx) { + Poll::Ready(Some(Ok(batch))) => { + self.state = StreamState::ReadFromCache(batch_reader); + return Poll::Ready(Some(Ok(batch))); + } + Poll::Ready(Some(Err(e))) => { + panic!("Decoding next batch error: {e:?}"); + } + Poll::Ready(None) => { + let batch_reader = *batch_reader; + let filter = batch_reader.into_filter(); + self.reader.as_mut().unwrap().filter = filter; + // state left as Init, continue loop to plan next row group + } + Poll::Pending => { + self.state = StreamState::ReadFromCache(batch_reader); + return Poll::Pending; + } + } + } + StreamState::Init => { + let row_group_idx = match self.row_groups.pop_front() { + Some(idx) => idx, + None => return Poll::Ready(None), + }; + + let row_count = self.metadata.row_group(row_group_idx).num_rows() as usize; + + let selection = self.selection.as_mut().map(|s| s.split_off(row_count)); + + let projection = self.projection.clone(); + let batch_size = self.batch_size; + let maybe_context = self.reader.as_mut().expect("lost reader").plan_row_group( + row_group_idx, + selection, + projection, + batch_size, + ); + match maybe_context { + Some(context) => { + let schema = Arc::clone(&self.schema); + let reader_factory = self.reader.as_mut().unwrap(); + let batch_reader = + build_liquid_cache_reader(reader_factory, context, schema); + self.state = StreamState::ReadFromCache(Box::new(batch_reader)); + } + None => { + self.state = StreamState::Init; + } + } + } + } + } + } +} +#[cfg(test)] +mod tests { + use super::*; + use crate::cache::{BatchID, CachedFileRef, LiquidCacheParquet}; + use crate::reader::plantime::{ + CachedMetaReaderFactory, FilterCandidateBuilder, LiquidPredicate, LiquidRowFilter, + }; + use arrow::array::{ArrayRef, Int32Array}; + use arrow_schema::{DataType, Field, Schema}; + use datafusion::common::ScalarValue; + use datafusion::datasource::listing::PartitionedFile; + use datafusion::logical_expr::Operator; + use datafusion::physical_expr::PhysicalExpr; + use datafusion::physical_expr::expressions::{BinaryExpr, Column, Literal}; + use datafusion::physical_plan::metrics::ExecutionPlanMetricsSet; + use futures::StreamExt; + use liquid_cache::cache::TranscodeEvict; + use liquid_cache::cache_policies::LiquidPolicy; + use object_store::local::LocalFileSystem; + use parquet::arrow::ArrowWriter; + use parquet::arrow::arrow_reader::{ArrowReaderMetadata, ArrowReaderOptions}; + use std::fs::File; + use std::sync::Arc; + + fn write_two_row_group_file(path: &std::path::Path, schema: SchemaRef) { + let file = File::create(path).unwrap(); + let mut writer = ArrowWriter::try_new(file, schema.clone(), None).unwrap(); + let batch0 = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from(vec![0, 1, 2, 3])), + Arc::new(Int32Array::from(vec![10, 11, 12, 13])), + ], + ) + .unwrap(); + let batch1 = RecordBatch::try_new( + schema, + vec![ + Arc::new(Int32Array::from(vec![4, 5, 6, 7])), + Arc::new(Int32Array::from(vec![14, 15, 16, 17])), + ], + ) + .unwrap(); + writer.write(&batch0).unwrap(); + writer.flush().unwrap(); + writer.write(&batch1).unwrap(); + writer.close().unwrap(); + } + + fn make_liquid_stream( + max_memory_bytes: usize, + row_filter: Option, + projection_columns: Vec, + ) -> ( + LiquidStream, + Arc, + CachedFileRef, + tempfile::TempDir, + ) { + let schema = Arc::new(Schema::new(vec![ + Field::new("a", DataType::Int32, false), + Field::new("b", DataType::Int32, false), + ])); + let tmp_dir = tempfile::tempdir().unwrap(); + let parquet_path = tmp_dir.path().join("data.parquet"); + write_two_row_group_file(&parquet_path, schema.clone()); + let metadata_file = File::open(&parquet_path).unwrap(); + let reader_metadata = + ArrowReaderMetadata::load(&metadata_file, ArrowReaderOptions::new()).unwrap(); + let object_store = Arc::new(LocalFileSystem::new_with_prefix(tmp_dir.path()).unwrap()); + let partitioned_file = PartitionedFile::new( + "data.parquet", + std::fs::metadata(&parquet_path).unwrap().len(), + ); + let metrics = ExecutionPlanMetricsSet::new(); + let input = CachedMetaReaderFactory::new(object_store).create_liquid_reader( + 0, + partitioned_file, + None, + &metrics, + ); + + let cache = Arc::new(LiquidCacheParquet::new( + 4, + max_memory_bytes, + Box::new(LiquidPolicy::new()), + Box::new(TranscodeEvict), + )); + let cached_file = cache.register_or_get_file("data.parquet".to_string(), schema); + let projection = ProjectionMask::roots( + reader_metadata.metadata().file_metadata().schema_descr(), + projection_columns, + ); + let mut builder = LiquidStreamBuilder::new(input, Arc::clone(reader_metadata.metadata())) + .with_batch_size(4) + .with_row_groups(vec![0, 1]) + .with_projection(projection); + if let Some(row_filter) = row_filter { + builder = builder.with_row_filter(row_filter); + } + let stream = builder.build(cached_file.clone()).unwrap(); + (stream, cache, cached_file, tmp_dir) + } + + async fn collect_liquid_values(stream: LiquidStream) -> (Vec, Vec) { + let batches = stream + .map(|batch| batch.expect("valid liquid stream batch")) + .collect::>() + .await; + let mut a = Vec::new(); + let mut b = Vec::new(); + for batch in batches { + let a_array = batch + .column(0) + .as_any() + .downcast_ref::() + .unwrap(); + let b_array = batch + .column(1) + .as_any() + .downcast_ref::() + .unwrap(); + a.extend(a_array.iter().map(|value| value.unwrap())); + b.extend(b_array.iter().map(|value| value.unwrap())); + } + (a, b) + } + + async fn collect_projected_a(stream: LiquidStream) -> Vec { + let batches = stream + .map(|batch| batch.expect("valid liquid stream batch")) + .collect::>() + .await; + let mut a = Vec::new(); + for batch in batches { + let a_array = batch + .column(0) + .as_any() + .downcast_ref::() + .unwrap(); + a.extend(a_array.iter().map(|value| value.unwrap())); + } + a + } + + fn gt_filter_on( + schema: SchemaRef, + col_name: &str, + col_idx: usize, + literal: i32, + ) -> LiquidRowFilter { + let expr: Arc = Arc::new(BinaryExpr::new( + Arc::new(Column::new(col_name, col_idx)), + Operator::Gt, + Arc::new(Literal::new(ScalarValue::Int32(Some(literal)))), + )); + let tmp_meta = tempfile::NamedTempFile::new().unwrap(); + write_two_row_group_file(tmp_meta.path(), schema.clone()); + let file = File::open(tmp_meta.path()).unwrap(); + let metadata = ArrowReaderMetadata::load(&file, ArrowReaderOptions::new()).unwrap(); + let builder = FilterCandidateBuilder::new(expr, schema); + let candidate = builder.build(metadata.metadata()).unwrap().unwrap(); + let projection = candidate.projection(metadata.metadata()); + let predicate = LiquidPredicate::try_new(candidate, projection).unwrap(); + LiquidRowFilter::new(vec![predicate]) + } + + /// Let the background cache-fill tasks (spawned via tokio::spawn) run. + async fn drain_background_tasks() { + for _ in 0..64 { + tokio::task::yield_now().await; + } + } + + fn is_cached(row_group: &CachedRowGroupRef, column_id: usize, batch_idx: u16) -> bool { + row_group + .get_column(column_id as u64) + .unwrap() + .get_arrow_array_test_only(BatchID::from_raw(batch_idx)) + .is_some() + } + + #[tokio::test] + async fn reads_two_row_groups_without_filter() { + let (stream, _cache, cached_file, _tmp_dir) = + make_liquid_stream(usize::MAX, None, vec![0, 1]); + + let (a, b) = collect_liquid_values(stream).await; + + assert_eq!(a, vec![0, 1, 2, 3, 4, 5, 6, 7]); + assert_eq!(b, vec![10, 11, 12, 13, 14, 15, 16, 17]); + + drain_background_tasks().await; + // Without a predicate, all projected columns are cacheable. + let row_group0 = cached_file.create_row_group(0, vec![0, 1]); + let row_group1 = cached_file.create_row_group(1, vec![0, 1]); + assert!(is_cached(&row_group0, 0, 0)); + assert!(is_cached(&row_group0, 1, 0)); + assert!(is_cached(&row_group1, 0, 0)); + assert!(is_cached(&row_group1, 1, 0)); + } + + #[tokio::test] + async fn row_filter_filters_rows() { + let schema = Arc::new(Schema::new(vec![ + Field::new("a", DataType::Int32, false), + Field::new("b", DataType::Int32, false), + ])); + let filter = gt_filter_on(schema, "a", 0, 2); + let (stream, _cache, _cached_file, _tmp_dir) = + make_liquid_stream(usize::MAX, Some(filter), vec![0, 1]); + + let (a, b) = collect_liquid_values(stream).await; + + assert_eq!(a, vec![3, 4, 5, 6, 7]); + assert_eq!(b, vec![13, 14, 15, 16, 17]); + } + + #[tokio::test] + async fn predicate_fallback_uses_predicate_projection() { + let schema = Arc::new(Schema::new(vec![ + Field::new("a", DataType::Int32, false), + Field::new("b", DataType::Int32, false), + ])); + let filter = gt_filter_on(schema, "b", 1, 13); + let (stream, _cache, cached_file, _tmp_dir) = + make_liquid_stream(usize::MAX, Some(filter), vec![0]); + + let a_values = collect_projected_a(stream).await; + + assert_eq!(a_values, vec![4, 5, 6, 7]); + + drain_background_tasks().await; + // With a predicate on `b`, only `b` is a predicate column and cacheable. + let row_group0 = cached_file.create_row_group(0, vec![1]); + let row_group1 = cached_file.create_row_group(1, vec![1]); + assert!(is_cached(&row_group0, 1, 0)); + assert!(is_cached(&row_group1, 1, 0)); + } + + #[tokio::test] + async fn zero_memory_budget_recovers() { + let (stream, _cache, cached_file, _tmp_dir) = make_liquid_stream(0, None, vec![0, 1]); + + let (a, b) = collect_liquid_values(stream).await; + + assert_eq!(a, vec![0, 1, 2, 3, 4, 5, 6, 7]); + assert_eq!(b, vec![10, 11, 12, 13, 14, 15, 16, 17]); + + drain_background_tasks().await; + let row_group0 = cached_file.create_row_group(0, vec![0, 1]); + let row_group1 = cached_file.create_row_group(1, vec![0, 1]); + assert!(!is_cached(&row_group0, 0, 0)); + assert!(!is_cached(&row_group0, 1, 0)); + assert!(!is_cached(&row_group1, 0, 0)); + assert!(!is_cached(&row_group1, 1, 0)); + } + + #[tokio::test] + async fn pre_populated_cache_serves_reads() { + let (stream, _cache, cached_file, _tmp_dir) = + make_liquid_stream(usize::MAX, None, vec![0, 1]); + let row_group0 = cached_file.create_row_group(0, vec![0, 1]); + let row_group1 = cached_file.create_row_group(1, vec![0, 1]); + let a0: ArrayRef = Arc::new(Int32Array::from(vec![0, 1, 2, 3])); + let a1: ArrayRef = Arc::new(Int32Array::from(vec![4, 5, 6, 7])); + row_group0 + .get_column(0) + .unwrap() + .insert(BatchID::from_raw(0), a0) + .unwrap(); + row_group1 + .get_column(0) + .unwrap() + .insert(BatchID::from_raw(0), a1) + .unwrap(); + + let (a, b) = collect_liquid_values(stream).await; + + assert_eq!(a, vec![0, 1, 2, 3, 4, 5, 6, 7]); + assert_eq!(b, vec![10, 11, 12, 13, 14, 15, 16, 17]); + + drain_background_tasks().await; + // Missing column `b` was pulled from parquet and back-filled. + assert!(is_cached(&row_group0, 1, 0)); + assert!(is_cached(&row_group1, 1, 0)); + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/runtime/mod.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/runtime/mod.rs new file mode 100644 index 0000000000000..bfde28dcc9382 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/runtime/mod.rs @@ -0,0 +1,7 @@ +pub(crate) use liquid_predicate::extract_multi_column_or; +pub(crate) use liquid_stream::LiquidStreamBuilder; + +mod liquid_cache_reader; +mod liquid_predicate; +mod liquid_stream; +mod utils; diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/runtime/utils.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/runtime/utils.rs new file mode 100644 index 0000000000000..a05b79942b1c2 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/runtime/utils.rs @@ -0,0 +1,212 @@ +use std::collections::VecDeque; + +use parquet::{ + arrow::{ + ProjectionMask, + arrow_reader::{RowSelection, RowSelector}, + }, + schema::types::SchemaDescriptor, +}; + +pub(crate) fn get_root_column_ids( + schema: &SchemaDescriptor, + projection: &ProjectionMask, +) -> Vec { + let mut root_mask = vec![false; schema.root_schema().get_fields().len()]; + + for leaf_idx in 0..schema.num_columns() { + if projection.leaf_included(leaf_idx) { + let root_idx = schema.get_column_root_idx(leaf_idx); + root_mask[root_idx] = true; + } + } + + root_mask + .into_iter() + .enumerate() + .filter_map(|(idx, included)| included.then_some(idx)) + .collect() +} + +pub(crate) fn offset_row_selection(selection: RowSelection, offset: usize) -> RowSelection { + if offset == 0 { + return selection; + } + + let mut selected_count = 0; + let mut skipped_count = 0; + + let mut selectors: Vec = selection.into(); + + let find = selectors.iter().position(|selector| match selector.skip { + true => { + skipped_count += selector.row_count; + false + } + false => { + selected_count += selector.row_count; + selected_count > offset + } + }); + + let split_idx = match find { + Some(idx) => idx, + None => { + selectors.clear(); + return RowSelection::from(selectors); + } + }; + + let mut new_selectors = Vec::with_capacity(selectors.len() - split_idx + 1); + new_selectors.push(RowSelector::skip(skipped_count + offset)); + new_selectors.push(RowSelector::select(selected_count - offset)); + new_selectors.extend_from_slice(&selectors[split_idx + 1..]); + + RowSelection::from(new_selectors) +} + +pub(crate) fn limit_row_selection(selection: RowSelection, mut limit: usize) -> RowSelection { + let mut selectors: Vec = selection.into(); + + if limit == 0 { + selectors.clear(); + } + + for (idx, selection) in selectors.iter_mut().enumerate() { + if !selection.skip { + if selection.row_count >= limit { + selection.row_count = limit; + selectors.truncate(idx + 1); + break; + } else { + limit -= selection.row_count; + } + } + } + RowSelection::from(selectors) +} + +/// Take the next batch from the selection queue. +/// The returning selection will have exactly the batch size, or less if the selection is exhausted. +pub(super) fn take_next_batch( + selection: &mut VecDeque, + batch_size: usize, +) -> Option> { + let mut current_selected = 0; + let mut rt = Vec::new(); + while let Some(mut front) = selection.pop_front() { + if front.row_count + current_selected > batch_size { + let to_select = batch_size - current_selected; + if to_select > 0 { + let mut sub_front = front; + sub_front.row_count = to_select; + rt.push(sub_front); + } + let remaining = front.row_count - to_select; + front.row_count = remaining; + selection.push_front(front); + current_selected += to_select; + break; + } else { + rt.push(front); + current_selected += front.row_count; + } + } + if current_selected == 0 { + return None; + } + Some(rt) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_take_next_batch() { + { + let mut queue = VecDeque::new(); + let selection = take_next_batch(&mut queue, 8); + assert!(selection.is_none()); + assert!(queue.is_empty()); + } + + { + let mut queue = VecDeque::from(vec![RowSelector::select(8)]); + let selection = take_next_batch(&mut queue, 8).unwrap(); + assert_eq!(selection, vec![RowSelector::select(8)]); + assert!(queue.is_empty()); + } + + { + let mut queue = VecDeque::from(vec![RowSelector::select(10)]); + let selection = take_next_batch(&mut queue, 8).unwrap(); + assert_eq!(selection, vec![RowSelector::select(8)]); + assert_eq!(queue, vec![RowSelector::select(2)]); + } + + { + let mut queue = VecDeque::from(vec![ + RowSelector::select(2), + RowSelector::skip(2), + RowSelector::select(2), + RowSelector::skip(2), + RowSelector::select(2), + ]); + let selection = take_next_batch(&mut queue, 8).unwrap(); + assert_eq!( + selection, + vec![ + RowSelector::select(2), + RowSelector::skip(2), + RowSelector::select(2), + RowSelector::skip(2), + ] + ); + assert_eq!(queue, vec![RowSelector::select(2)]); + } + + { + let mut queue = VecDeque::from(vec![RowSelector::select(3), RowSelector::skip(2)]); + let selection = take_next_batch(&mut queue, 8).unwrap(); + assert_eq!( + selection, + vec![RowSelector::select(3), RowSelector::skip(2)] + ); + assert!(queue.is_empty()); + } + + { + let mut queue = VecDeque::from(vec![ + RowSelector::select(2), + RowSelector::skip(4), + RowSelector::select(6), + ]); + let selection = take_next_batch(&mut queue, 8).unwrap(); + assert_eq!( + selection, + vec![ + RowSelector::select(2), + RowSelector::skip(4), + RowSelector::select(2), + ] + ); + assert_eq!(queue, vec![RowSelector::select(4)]); + } + + { + let mut queue = VecDeque::from(vec![ + RowSelector::skip(5), + RowSelector::select(3), + RowSelector::skip(2), + RowSelector::select(7), + ]); + let selection = take_next_batch(&mut queue, 8).unwrap(); + assert_eq!( + selection, + vec![RowSelector::skip(5), RowSelector::select(3),] + ); + assert_eq!(queue, vec![RowSelector::skip(2), RowSelector::select(7)]); + } + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/utils/boolean_selection.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/utils/boolean_selection.rs new file mode 100644 index 0000000000000..c031d45e4c1f6 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/utils/boolean_selection.rs @@ -0,0 +1,273 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use std::ops::Range; + +use arrow::array::{Array, BooleanArray, BooleanBufferBuilder}; +use arrow::buffer::{BooleanBuffer, MutableBuffer}; +use arrow::util::bit_iterator::BitIndexIterator; +use parquet::arrow::arrow_reader::{RowSelection, RowSelector}; + +/// A selection of rows using a boolean array. +#[derive(Debug, Clone, PartialEq)] +pub struct BooleanSelection { + selectors: BooleanBuffer, +} + +impl BooleanSelection { + /// Create a new BooleanSelection from a list of BooleanArray. + pub fn from_filters(filters: &[BooleanArray]) -> Self { + let arrays: Vec<&dyn Array> = filters.iter().map(|x| x as &dyn Array).collect(); + let result = arrow::compute::concat(&arrays).unwrap().into_data(); + let (boolean_array, _null) = BooleanArray::from(result).into_parts(); + BooleanSelection { + selectors: boolean_array, + } + } + + /// Create a new BooleanSelection with all rows unselected + pub fn new_unselected(row_count: usize) -> Self { + let buffer = BooleanBuffer::new_unset(row_count); + + BooleanSelection { selectors: buffer } + } + + /// Create a new BooleanSelection with all rows selected + pub fn new_selected(row_count: usize) -> Self { + let buffer = BooleanBuffer::new_set(row_count); + + BooleanSelection { selectors: buffer } + } + + /// Returns a new BooleanSelection that selects the inverse of this BooleanSelection. + pub fn as_inverted(&self) -> Self { + let buffer = !&self.selectors; + BooleanSelection { selectors: buffer } + } + + /// Returns the number of rows in this BooleanSelection. + pub fn len(&self) -> usize { + self.selectors.len() + } + + /// Check if the BooleanSelection is empty. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Returns the number of rows selected by this BooleanSelection. + pub fn row_count(&self) -> usize { + self.selectors.count_set_bits() + } + + /// Create a new BooleanSelection from a list of consecutive ranges. + pub fn from_consecutive_ranges( + ranges: impl Iterator>, + total_rows: usize, + ) -> Self { + let mut buffer = BooleanBufferBuilder::new(total_rows); + let mut last_end = 0; + + for range in ranges { + let len = range.end - range.start; + if len == 0 { + continue; + } + + if range.start > last_end { + buffer.append_n(range.start - last_end, false); + } + buffer.append_n(len, true); + last_end = range.end; + } + + if last_end != total_rows { + buffer.append_n(total_rows - last_end, false); + } + + BooleanSelection { + selectors: buffer.finish(), + } + } + + /// Compute the union of two [`RowSelection`] + /// For example: + /// self: NNYYYYNNYYNYN + /// other: NYNNNNNNN + /// + /// returned: NYYYYYNNYYNYN + #[must_use] + pub fn union(&self, other: &Self) -> Self { + // use arrow::compute::kernels::boolean::or; + + let union_selectors = &self.selectors | &other.selectors; + + BooleanSelection { + selectors: union_selectors, + } + } + + /// Compute the intersection of two [`RowSelection`] + /// For example: + /// self: NNYYYYNNYYNYN + /// other: NYNNNNNNY + /// + /// returned: NNNNNNNNYYNYN + #[must_use] + pub fn intersection(&self, other: &Self) -> Self { + let intersection_selectors = &self.selectors & &other.selectors; + + BooleanSelection { + selectors: intersection_selectors, + } + } + + /// Combines this `BooleanSelection` with another using logical AND on the selected bits. + /// + /// The `other` `BooleanSelection` must have exactly as many set bits as `self`. + /// This method will keep only the bits in `self` that are also set in `other` + /// at the positions corresponding to `self`'s set bits. + pub fn and_then(&self, other: &Self) -> Self { + // Ensure that 'other' has exactly as many set bits as 'self' + debug_assert_eq!( + self.row_count(), + other.len(), + "The 'other' selection must have exactly as many set bits as 'self'." + ); + + if self.len() == other.len() { + // fast path if the two selections are the same length + // common if this is the first predicate + debug_assert_eq!(self.row_count(), self.len()); + return self.intersection(other); + } + + let mut buffer = MutableBuffer::from_len_zeroed(self.selectors.inner().len()); + buffer.copy_from_slice(self.selectors.values()); + let mut builder = BooleanBufferBuilder::new_from_buffer(buffer, self.len()); + + // Create iterators for 'self' and 'other' bits + let mut other_bits = other.selectors.iter(); + + for bit_idx in self.positive_iter() { + let predicate = other_bits + .next() + .expect("Mismatch in set bits between self and other"); + if !predicate { + builder.set_bit(bit_idx, false); + } + } + + BooleanSelection { + selectors: builder.finish(), + } + } + + /// Returns an iterator over the indices of the set bits in this [`BooleanSelection`] + pub fn positive_iter(&self) -> BitIndexIterator<'_> { + self.selectors.set_indices() + } + + /// Returns `true` if this [BooleanSelection] selects any rows + pub fn selects_any(&self) -> bool { + self.row_count() > 0 + } + + /// Returns a new BooleanSelection that selects the rows in this BooleanSelection from `offset` to `offset + len` + pub fn slice(&self, offset: usize, len: usize) -> BooleanArray { + BooleanArray::new(self.selectors.slice(offset, len), None) + } +} + +impl From> for BooleanSelection { + fn from(selection: Vec) -> Self { + let selection = RowSelection::from(selection); + RowSelection::into(selection) + } +} + +impl From for BooleanSelection { + fn from(selection: RowSelection) -> Self { + let total_rows = selection.row_count(); + let mut builder = BooleanBufferBuilder::new(total_rows); + + for selector in selection.iter() { + if selector.skip { + builder.append_n(selector.row_count, false); + } else { + builder.append_n(selector.row_count, true); + } + } + + BooleanSelection { + selectors: builder.finish(), + } + } +} + +impl From<&BooleanSelection> for RowSelection { + fn from(selection: &BooleanSelection) -> Self { + let array = BooleanArray::new(selection.selectors.clone(), None); + RowSelection::from_filters(&[array]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_boolean_selection_and_then() { + // Initial mask: 001011010101 + let self_filters = vec![BooleanArray::from(vec![ + false, false, true, false, true, true, false, true, false, true, false, true, + ])]; + let self_selection = BooleanSelection::from_filters(&self_filters); + + // Predicate mask (only for selected bits): 001101 + let other_filters = vec![BooleanArray::from(vec![ + false, false, true, true, false, true, + ])]; + let other_selection = BooleanSelection::from_filters(&other_filters); + + let result = self_selection.and_then(&other_selection); + + // Expected result: 000001010001 + let expected_filters = vec![BooleanArray::from(vec![ + false, false, false, false, false, true, false, true, false, false, false, true, + ])]; + let expected_selection = BooleanSelection::from_filters(&expected_filters); + + assert_eq!(result, expected_selection); + } + + #[test] + #[should_panic( + expected = "The 'other' selection must have exactly as many set bits as 'self'." + )] + fn test_and_then_mismatched_set_bits() { + let self_filters = vec![BooleanArray::from(vec![true, true, false])]; + let self_selection = BooleanSelection::from_filters(&self_filters); + + // 'other' has only one set bit, but 'self' has two + let other_filters = vec![BooleanArray::from(vec![true, false, false])]; + let other_selection = BooleanSelection::from_filters(&other_filters); + + // This should panic + let _ = self_selection.and_then(&other_selection); + } +} diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/utils/mod.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/utils/mod.rs new file mode 100644 index 0000000000000..237e831a8d5d0 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/reader/utils/mod.rs @@ -0,0 +1 @@ +pub(crate) mod boolean_selection; diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/sync.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/sync.rs new file mode 100644 index 0000000000000..4e2a741253538 --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/sync.rs @@ -0,0 +1,2 @@ +#[allow(unused_imports)] +pub use std::{sync::*, thread}; diff --git a/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/utils.rs b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/utils.rs new file mode 100644 index 0000000000000..076cc2b91035f --- /dev/null +++ b/sandbox/libs/dataformat-native/rust/liquid-cache/datafusion/src/utils.rs @@ -0,0 +1,335 @@ +use arrow::{ + array::BooleanBufferBuilder, + buffer::{BooleanBuffer, MutableBuffer}, +}; +use parquet::arrow::arrow_reader::RowSelector; + +fn boolean_buffer_and_then_fallback(left: &BooleanBuffer, right: &BooleanBuffer) -> BooleanBuffer { + debug_assert_eq!( + left.count_set_bits(), + right.len(), + "the right selection must have the same number of set bits as the left selection" + ); + + if left.len() == right.len() { + debug_assert_eq!(left.count_set_bits(), left.len()); + return right.clone(); + } + + let mut buffer = MutableBuffer::from_len_zeroed(left.values().len()); + buffer.copy_from_slice(left.values()); + let mut builder = BooleanBufferBuilder::new_from_buffer(buffer, left.len()); + + let mut other_bits = right.iter(); + + for bit_idx in left.set_indices() { + let predicate = other_bits + .next() + .expect("Mismatch in set bits between self and other"); + if !predicate { + builder.set_bit(bit_idx, false); + } + } + + builder.finish() +} + +/// Combines this [`BooleanBuffer`] with another using logical AND on the selected bits. +/// +/// Unlike intersection, the `other` [`BooleanBuffer`] must have exactly as many **set bits** as `self`, +/// i.e., self.count_set_bits() == other.len(). +/// +/// This method will keep only the bits in `self` that are also set in `other` +/// at the positions corresponding to `self`'s set bits. +/// For example: +/// left: NNYYYNNYYNYN +/// right: YNY NY N +/// result: NNYNYNNNYNNN +/// +/// Optimized version of `boolean_buffer_and_then` using BMI2 PDEP instructions. +/// This function performs the same operation but uses bit manipulation instructions +/// for better performance on supported x86_64 CPUs. +pub fn boolean_buffer_and_then(left: &BooleanBuffer, right: &BooleanBuffer) -> BooleanBuffer { + debug_assert_eq!( + left.count_set_bits(), + right.len(), + "the right selection must have the same number of set bits as the left selection" + ); + + if left.len() == right.len() { + debug_assert_eq!(left.count_set_bits(), left.len()); + return right.clone(); + } + + // Fast path for BMI2 support on x86_64 + #[cfg(target_arch = "x86_64")] + { + if is_x86_feature_detected!("bmi2") { + return unsafe { boolean_buffer_and_then_bmi2(left, right) }; + } + } + + boolean_buffer_and_then_fallback(left, right) +} + +#[cfg(target_arch = "x86_64")] +#[inline(always)] +fn load_u64_zero_padded(base_ptr: *const u8, total_bytes: usize, offset: usize) -> u64 { + let remaining = total_bytes.saturating_sub(offset); + if remaining >= 8 { + unsafe { core::ptr::read_unaligned(base_ptr.add(offset) as *const u64) } + } else if remaining > 0 { + let mut tmp = [0u8; 8]; + unsafe { + core::ptr::copy_nonoverlapping(base_ptr.add(offset), tmp.as_mut_ptr(), remaining); + } + u64::from_le_bytes(tmp) + } else { + 0 + } +} + +#[cfg(target_arch = "x86_64")] +#[target_feature(enable = "bmi2")] +unsafe fn boolean_buffer_and_then_bmi2( + left: &BooleanBuffer, + right: &BooleanBuffer, +) -> BooleanBuffer { + use core::arch::x86_64::_pdep_u64; + + debug_assert_eq!(left.count_set_bits(), right.len()); + + let bit_len = left.len(); + let byte_len = bit_len.div_ceil(8); + let left_ptr = left.values().as_ptr(); + let right_bytes = right.len().div_ceil(8); + let right_ptr = right.values().as_ptr(); + + let mut out = MutableBuffer::from_len_zeroed(byte_len); + let out_ptr = out.as_mut_ptr(); + + let full_words = bit_len / 64; + let mut right_bit_idx = 0; // how many bits we have processed from right + + for word_idx in 0..full_words { + let left_word = + unsafe { core::ptr::read_unaligned(left_ptr.add(word_idx * 8) as *const u64) }; + + if left_word == 0 { + continue; + } + + let need = left_word.count_ones(); + + // Absolute byte & bit offset of the first needed bit inside `right`. + let rb_byte = right_bit_idx / 8; + let rb_bit = (right_bit_idx & 7) as u32; + + let need_high = rb_bit != 0; + let safe16 = right_bytes.saturating_sub(16); + let mut r_bits; + if rb_byte <= safe16 { + let low = unsafe { core::ptr::read_unaligned(right_ptr.add(rb_byte) as *const u64) }; + if need_high { + let high = + unsafe { core::ptr::read_unaligned(right_ptr.add(rb_byte + 8) as *const u64) }; + r_bits = (low >> rb_bit) | (high << (64 - rb_bit)); + } else { + r_bits = low >> rb_bit; + } + } else { + let low = load_u64_zero_padded(right_ptr, right_bytes, rb_byte); + r_bits = low >> rb_bit; + if need_high { + let high = load_u64_zero_padded(right_ptr, right_bytes, rb_byte + 8); + r_bits |= high << (64 - rb_bit); + } + } + + // Mask off the high garbage. + r_bits &= 1u64.unbounded_shl(need).wrapping_sub(1); + + // The PDEP instruction: https://www.felixcloutier.com/x86/pdep + // It takes left_word as the mask, and deposit the packed bits into the sparse positions of `left_word`. + let result = _pdep_u64(r_bits, left_word); + + unsafe { + core::ptr::write_unaligned(out_ptr.add(word_idx * 8) as *mut u64, result); + } + + right_bit_idx += need as usize; + } + + // Handle remaining bits that are less than 64 bits + let tail_bits = bit_len & 63; + if tail_bits != 0 { + // Build the mask from the remaining bytes in one bounded copy + let tail_bytes = tail_bits.div_ceil(8); + let base = unsafe { left_ptr.add(full_words * 8) }; + let mut mask: u64; + if tail_bytes == 8 { + mask = unsafe { core::ptr::read_unaligned(base as *const u64) }; + } else { + let mut buf = [0u8; 8]; + unsafe { + core::ptr::copy_nonoverlapping(base, buf.as_mut_ptr(), tail_bytes); + } + mask = u64::from_le_bytes(buf); + } + // Clear any high bits beyond the actual tail length + mask &= (1u64 << tail_bits) - 1; + + if mask != 0 { + let need = mask.count_ones(); + + let rb_byte = right_bit_idx / 8; + let rb_bit = (right_bit_idx & 7) as u32; + + let need_high = rb_bit != 0; + let safe16 = right_bytes.saturating_sub(16); + let mut r_bits; + if rb_byte <= safe16 { + let low = + unsafe { core::ptr::read_unaligned(right_ptr.add(rb_byte) as *const u64) }; + if need_high { + let high = unsafe { + core::ptr::read_unaligned(right_ptr.add(rb_byte + 8) as *const u64) + }; + r_bits = (low >> rb_bit) | (high << (64 - rb_bit)); + } else { + r_bits = low >> rb_bit; + } + } else { + let low = load_u64_zero_padded(right_ptr, right_bytes, rb_byte); + r_bits = low >> rb_bit; + if need_high { + let high = load_u64_zero_padded(right_ptr, right_bytes, rb_byte + 8); + r_bits |= high << (64 - rb_bit); + } + } + + r_bits &= 1u64.unbounded_shl(need).wrapping_sub(1); + + let result = _pdep_u64(r_bits, mask); + + let tail_bytes = tail_bits.div_ceil(8); + let result_bytes = result.to_le_bytes(); + let dst_off = full_words * 8; + unsafe { + let dst = core::slice::from_raw_parts_mut(out_ptr, byte_len); + dst[dst_off..dst_off + tail_bytes].copy_from_slice(&result_bytes[..tail_bytes]); + } + } + } + + BooleanBuffer::new(out.into(), 0, bit_len) +} + +pub(super) fn row_selector_to_boolean_buffer(selection: &[RowSelector]) -> BooleanBuffer { + let mut buffer = BooleanBufferBuilder::new(8192); + for selector in selection.iter() { + if selector.skip { + buffer.append_n(selector.row_count, false); + } else { + buffer.append_n(selector.row_count, true); + } + } + buffer.finish() +} + +#[cfg(all(test, target_arch = "x86_64"))] +mod tests { + use super::*; + + #[test] + fn test_boolean_buffer_and_then_bmi2_large() { + use super::boolean_buffer_and_then_bmi2; + + // Test with larger buffer (more than 64 bits) + let size = 128; + let mut left_builder = BooleanBufferBuilder::new(size); + let mut right_bits = Vec::new(); + + // Create a pattern where every 3rd bit is set in left + for i in 0..size { + let is_set = i.is_multiple_of(3); + left_builder.append(is_set); + if is_set { + // For right buffer, alternate between true/false + right_bits.push(right_bits.len().is_multiple_of(2)); + } + } + let left = left_builder.finish(); + + let mut right_builder = BooleanBufferBuilder::new(right_bits.len()); + for bit in right_bits { + right_builder.append(bit); + } + let right = right_builder.finish(); + + let result_bmi2 = unsafe { boolean_buffer_and_then_bmi2(&left, &right) }; + let result_orig = boolean_buffer_and_then_fallback(&left, &right); + + assert_eq!(result_bmi2.len(), result_orig.len()); + assert_eq!(result_bmi2.len(), size); + + // Verify they produce the same result + for i in 0..size { + assert_eq!( + result_bmi2.value(i), + result_orig.value(i), + "Mismatch at position {i}" + ); + } + } + + #[test] + fn test_boolean_buffer_and_then_bmi2_edge_cases() { + use super::boolean_buffer_and_then_bmi2; + + // Test case: all bits set in left, alternating pattern in right + let mut left_builder = BooleanBufferBuilder::new(16); + for _ in 0..16 { + left_builder.append(true); + } + let left = left_builder.finish(); + + let mut right_builder = BooleanBufferBuilder::new(16); + for i in 0..16 { + right_builder.append(i % 2 == 0); + } + let right = right_builder.finish(); + + let result_bmi2 = unsafe { boolean_buffer_and_then_bmi2(&left, &right) }; + let result_orig = boolean_buffer_and_then_fallback(&left, &right); + + assert_eq!(result_bmi2.len(), result_orig.len()); + for i in 0..16 { + assert_eq!( + result_bmi2.value(i), + result_orig.value(i), + "Mismatch at position {i}" + ); + // Should be true for even indices, false for odd + assert_eq!(result_bmi2.value(i), i.is_multiple_of(2)); + } + + // Test case: no bits set in left + let mut left_empty_builder = BooleanBufferBuilder::new(8); + for _ in 0..8 { + left_empty_builder.append(false); + } + let left_empty = left_empty_builder.finish(); + let right_empty = BooleanBufferBuilder::new(0).finish(); + + let result_bmi2_empty = unsafe { boolean_buffer_and_then_bmi2(&left_empty, &right_empty) }; + let result_orig_empty = boolean_buffer_and_then_fallback(&left_empty, &right_empty); + + assert_eq!(result_bmi2_empty.len(), result_orig_empty.len()); + assert_eq!(result_bmi2_empty.len(), 8); + for i in 0..8 { + assert!(!result_bmi2_empty.value(i)); + assert!(!result_orig_empty.value(i)); + } + } +} diff --git a/sandbox/libs/tiered-storage/src/main/rust/Cargo.toml b/sandbox/libs/tiered-storage/src/main/rust/Cargo.toml index c7a7d6ec9e471..29f4011f8e340 100644 --- a/sandbox/libs/tiered-storage/src/main/rust/Cargo.toml +++ b/sandbox/libs/tiered-storage/src/main/rust/Cargo.toml @@ -21,5 +21,4 @@ native-bridge-common = { workspace = true } opensearch-block-cache = { path = "../../../../../plugins/block-cache-foyer/src/main/rust" } [dev-dependencies] -tempfile = { workspace = true } -tokio = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } diff --git a/sandbox/libs/tiered-storage/src/main/rust/src/ffm.rs b/sandbox/libs/tiered-storage/src/main/rust/src/ffm.rs index fa044ced26137..2d65e61cdb3d2 100644 --- a/sandbox/libs/tiered-storage/src/main/rust/src/ffm.rs +++ b/sandbox/libs/tiered-storage/src/main/rust/src/ffm.rs @@ -20,9 +20,9 @@ use object_store::ObjectStore; use opensearch_block_cache::traits::BlockCache; -use crate::registry::TieredStorageRegistry; use crate::registry::FileRegistry; -use crate::tiered_object_store::TieredObjectStore; +use crate::registry::TieredStorageRegistry; +use crate::tiered_object_store::{MetadataCachingStore, TieredObjectStore}; use crate::types::FileLocation; const NULL_PTR: i64 = 0; @@ -116,8 +116,12 @@ pub extern "C" fn ts_create_tiered_object_store( Ok(ptr) } -/// Returns a `Box>` pointer from an existing TieredObjectStore Arc pointer. -/// This is the format that `df_create_reader` expects — a boxed fat pointer to the trait object. +/// Returns a `Box>` pointer from an existing TieredObjectStore +/// Arc pointer. This is the format that `df_create_reader` and warmup paths expect — a boxed +/// fat pointer to the trait object. `MetadataCachingStore: ObjectStore`, so all `ObjectStore` +/// methods remain available; in addition, callers can invoke `put_metadata` to promote bytes +/// into the never-evict metadata tier. +/// /// Each call creates a new Box with its own Arc clone — caller must free with /// `ts_destroy_object_store_box_ptr`. #[ffm_safe] @@ -128,23 +132,24 @@ pub extern "C" fn ts_get_object_store_box_ptr(tiered_store_ptr: i64) -> i64 { } // Increment strong count so we don't consume the original Arc unsafe { Arc::increment_strong_count(tiered_store_ptr as *const TieredObjectStore) }; - let arc: Arc = unsafe { Arc::from_raw(tiered_store_ptr as *const TieredObjectStore) }; - // Coerce to trait object and box it - let boxed: Box> = Box::new(arc as Arc); + let arc: Arc = + unsafe { Arc::from_raw(tiered_store_ptr as *const TieredObjectStore) }; + // Coerce to MetadataCachingStore trait object and box it + let boxed: Box> = Box::new(arc as Arc); let ptr = Box::into_raw(boxed) as i64; native_bridge_common::log_info!("ffm: ts_get_object_store_box_ptr: ok"); Ok(ptr) } -/// Destroy a `Box>` pointer returned by `ts_get_object_store_box_ptr`. -/// Drops the Box and decrements the Arc strong count. +/// Destroy a `Box>` pointer returned by +/// `ts_get_object_store_box_ptr`. Drops the Box and decrements the Arc strong count. #[ffm_safe] #[no_mangle] pub extern "C" fn ts_destroy_object_store_box_ptr(ptr: i64) -> i64 { if ptr == NULL_PTR { return Err("ts_destroy_object_store_box_ptr: null pointer (0)".to_string()); } - let _boxed = unsafe { Box::from_raw(ptr as *mut Arc) }; + let _boxed = unsafe { Box::from_raw(ptr as *mut Arc) }; native_bridge_common::log_info!("ffm: ts_destroy_object_store_box_ptr: ok"); Ok(0) } @@ -198,7 +203,9 @@ pub extern "C" fn ts_register_files( if lines.len() < expected { return Err(format!( "ts_register_files: expected {} lines ({}*3) but got {}", - expected, count, lines.len() + expected, + count, + lines.len() )); } @@ -219,18 +226,18 @@ pub extern "C" fn ts_register_files( registry.register(path, entry); } - native_bridge_common::log_debug!("ffm: ts_register_files count={}, location={}", count, file_location); + native_bridge_common::log_debug!( + "ffm: ts_register_files count={}, location={}", + count, + file_location + ); Ok(0) } /// Remove a file from the registry. #[ffm_safe] #[no_mangle] -pub extern "C" fn ts_remove_file( - store_ptr: i64, - path_ptr: *const u8, - path_len: i64, -) -> i64 { +pub extern "C" fn ts_remove_file(store_ptr: i64, path_ptr: *const u8, path_len: i64) -> i64 { let store = unsafe { arc_from_ptr(store_ptr) }?; let path = unsafe { str_from_raw(path_ptr, path_len) } .map_err(|e| format!("ts_remove_file path: {}", e))?; diff --git a/sandbox/libs/tiered-storage/src/main/rust/src/ffm_tests.rs b/sandbox/libs/tiered-storage/src/main/rust/src/ffm_tests.rs index b9726074fda1a..766945736d510 100644 --- a/sandbox/libs/tiered-storage/src/main/rust/src/ffm_tests.rs +++ b/sandbox/libs/tiered-storage/src/main/rust/src/ffm_tests.rs @@ -143,16 +143,20 @@ fn test_create_with_zero_cache_ptr_creates_uncached_store() { /// without consuming the Box — two stores can share the same cache pointer. #[test] fn test_create_with_cache_does_not_consume_pointer() { + use bytes::Bytes; use opensearch_block_cache::range_cache::CacheKey; use opensearch_block_cache::traits::BlockCache; - use bytes::Bytes; // Minimal no-op cache used only to construct a valid Box> pointer. struct NoopCache; impl BlockCache for NoopCache { - fn as_any(&self) -> &dyn std::any::Any { self } - fn get<'a>(&'a self, _key: &'a CacheKey) - -> std::pin::Pin> + Send + 'a>> + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn get<'a>( + &'a self, + _key: &'a CacheKey, + ) -> std::pin::Pin> + Send + 'a>> { Box::pin(std::future::ready(None)) } diff --git a/sandbox/libs/tiered-storage/src/main/rust/src/registry/tiered_registry.rs b/sandbox/libs/tiered-storage/src/main/rust/src/registry/tiered_registry.rs index 991763555942c..9300ff49d7e7d 100644 --- a/sandbox/libs/tiered-storage/src/main/rust/src/registry/tiered_registry.rs +++ b/sandbox/libs/tiered-storage/src/main/rust/src/registry/tiered_registry.rs @@ -164,8 +164,7 @@ impl FileRegistry for TieredStorageRegistry { let before = self.files.len(); self.files.retain(|key, _| { // Check both with and without leading "/" since callers may pass either form - valid_keys.contains(key.as_str()) - || valid_keys.contains(&format!("/{}", key)) + valid_keys.contains(key.as_str()) || valid_keys.contains(&format!("/{}", key)) }); let removed = before.saturating_sub(self.files.len()); if removed > 0 { @@ -202,17 +201,11 @@ mod tests { } fn remote_entry() -> TieredFileEntry { - TieredFileEntry::new( - FileLocation::Remote, - Some(Arc::from("remote/a.parquet")), - ) + TieredFileEntry::new(FileLocation::Remote, Some(Arc::from("remote/a.parquet"))) } fn both_entry() -> TieredFileEntry { - TieredFileEntry::new( - FileLocation::Remote, - Some(Arc::from("remote/a.parquet")), - ) + TieredFileEntry::new(FileLocation::Remote, Some(Arc::from("remote/a.parquet"))) } // -- Register ----------------------------------------------------------- diff --git a/sandbox/libs/tiered-storage/src/main/rust/src/tiered_object_store.rs b/sandbox/libs/tiered-storage/src/main/rust/src/tiered_object_store.rs index d1140f574f34f..e6e88d78322aa 100644 --- a/sandbox/libs/tiered-storage/src/main/rust/src/tiered_object_store.rs +++ b/sandbox/libs/tiered-storage/src/main/rust/src/tiered_object_store.rs @@ -27,8 +27,9 @@ use bytes::Bytes; use futures::stream::BoxStream; use futures::StreamExt; use object_store::{ - path::Path, CopyOptions, GetOptions, GetResult, ListResult, MultipartUpload, ObjectMeta, - ObjectStore, PutMultipartOptions, PutOptions, PutPayload, PutResult, Result as OsResult, + path::Path, CopyOptions, GetOptions, GetRange, GetResult, ListResult, MultipartUpload, + ObjectMeta, ObjectStore, PutMultipartOptions, PutOptions, PutPayload, PutResult, + Result as OsResult, }; use opensearch_block_cache::range_cache::range_cache_key; @@ -38,6 +39,29 @@ use crate::registry::traits::FileRegistry; use crate::registry::TieredStorageRegistry; use crate::types::{FileLocation, TieredFileEntry}; +// --------------------------------------------------------------------------- +// MetadataCachingStore — extension trait +// --------------------------------------------------------------------------- + +/// An [`ObjectStore`] that supports promoting reads into a sticky metadata tier +/// (the never-evict Foyer SSD instance in production). +/// +/// The default implementation is a no-op so callers can invoke `put_metadata` +/// uniformly through any `Arc` without checking the +/// concrete type. [`TieredObjectStore`] overrides it to actually route the +/// bytes into the metadata cache; plain stores (e.g. `LocalFileSystem` in +/// tests) just inherit the no-op. +/// +/// Used by warmup paths (e.g. `analytics-backend-datafusion`'s +/// `add_files_with_store`) that need to populate the metadata tier after +/// fetching bytes through the store. +pub trait MetadataCachingStore: ObjectStore { + /// Promote `data` ranges to the never-evict metadata tier. + /// + /// Default: no-op. + fn put_metadata(&self, _path: &str, _ranges: &[std::ops::Range], _data: &[Bytes]) {} +} + // --------------------------------------------------------------------------- // TieredObjectStore // --------------------------------------------------------------------------- @@ -76,13 +100,19 @@ impl TieredObjectStore { /// Set the remote store (once). Subsequent calls are ignored. pub fn set_remote(&self, store: Arc) { - self.remote.set(store).ok(); // ignore if already set + let was_set = self.remote.set(store).is_err(); + native_bridge_common::log_debug!( + "[warm-tier] set_remote wired={} (already_set={})", + !was_set, + was_set + ); } /// Attach a block cache. Hot nodes skip this; `None` means no caching. #[must_use] pub fn with_cache(mut self, cache: Arc) -> Self { self.cache = Some(cache); + native_bridge_common::log_debug!("[warm-tier] TieredObjectStore: block cache attached"); self } @@ -93,10 +123,40 @@ impl TieredObjectStore { /// so that stale byte-range entries are freed promptly. pub fn evict_path(&self, path: &str) { if let Some(ref cache) = self.cache { + native_bridge_common::log_debug!("[warm-tier] evict_path path='{}'", path); cache.evict_prefix(path); } } + /// Store byte ranges directly into the metadata cache (never-evict tier). + /// + /// Called by warmup after fetching metadata bytes. The warmup code reads + /// metadata (via this store or any source), then calls this to ensure the + /// bytes are in the durable metadata_cache — surviving LRU eviction from + /// data scan pressure and node restarts. + /// + /// No-op if no cache is attached. + pub fn put_metadata(&self, path: &str, ranges: &[std::ops::Range], data: &[Bytes]) { + // Normalize path by stripping leading '/' to match `object_store::Path` semantics + // (read paths through ObjectStore::get_opts/get_ranges arrive with the leading '/' + // already stripped). Without this, warmup writes under "/Volumes/..." while query + // reads probe under "Volumes/..." — silent cache miss on every read. + let path_str = path.strip_prefix('/').unwrap_or(path); + native_bridge_common::log_debug!( + "[warm-tier] put_metadata path='{}' ranges={} bytes={} cache={}", + path_str, + ranges.len(), + ranges.iter().map(|r| r.end - r.start).sum::(), + self.cache.is_some() + ); + if let Some(ref cache) = self.cache { + for (r, bytes) in ranges.iter().zip(data.iter()) { + let key = range_cache_key(path_str, r.start, r.end); + cache.put_metadata(&key, bytes.clone()); + } + } + } + /// Register a file in the registry. For Remote/Both locations, the caller /// must provide a `remote_path`. pub fn register_file( @@ -152,8 +212,57 @@ impl TieredObjectStore { Ok(()) } - // TODO: Add pin(path)/unpin(path) methods for write-path eviction protection. - // TODO: Add schedule_eviction(path) and sweep() for deferred eviction lifecycle. + /// Resolve a GetRange to absolute (start, end) byte offsets. + /// + /// - `Bounded(start..end)`: returned directly. + /// - `Suffix(n)`: resolved to `(file_size - n, file_size)` using registry. + /// - `Offset(n)`: resolved to `(n, file_size)` using registry. + /// + /// Returns `None` if file_size is needed but not available in the registry. + /// + /// Note: `GetRange::Offset` is defensive/forward-compatible code. DataFusion's + /// current parquet reading pipeline only produces `Bounded` (column data) and + /// `Suffix` (footer metadata). The `Offset` variant is never generated in + /// practice but is handled for completeness. + fn resolve_range(&self, path_str: &str, range: &GetRange) -> Option<(u64, u64)> { + match range { + GetRange::Bounded(r) => Some((r.start, r.end)), + GetRange::Suffix(n) => { + let file_size = self + .registry + .get(path_str) + .map(|g| g.size()) + .filter(|&s| s > 0); + match file_size { + Some(size) => Some((size.saturating_sub(*n), size)), + None => { + native_bridge_common::log_debug!( + "TieredObjectStore: resolve_range Suffix({}) — file_size unavailable for '{}', cache bypassed", + n, path_str + ); + None + } + } + } + GetRange::Offset(o) => { + let file_size = self + .registry + .get(path_str) + .map(|g| g.size()) + .filter(|&s| s > 0); + match file_size { + Some(size) => Some((*o, size)), + None => { + native_bridge_common::log_debug!( + "TieredObjectStore: resolve_range Offset({}) — file_size unavailable for '{}', cache bypassed", + o, path_str + ); + None + } + } + } + } + } // NOTE: The guard is intentionally dropped before I/O. The Arc // keeps the store alive independently. On writable warm, the guard must be held @@ -174,11 +283,15 @@ impl TieredObjectStore { /// Checks if a local read error is NotFound and the file has since transitioned /// to REMOTE in the registry (e.g., afterSyncToRemote deleted the local copy). /// Returns the remote path + store if retry is possible, None otherwise. - fn should_retry_remote(&self, path_str: &str, err: &object_store::Error) -> Option<(Path, Arc)> { + fn should_retry_remote( + &self, + path_str: &str, + err: &object_store::Error, + ) -> Option<(Path, Arc)> { if matches!(err, object_store::Error::NotFound { .. }) { let resolved = self.resolve_remote(path_str); if resolved.is_some() { - native_bridge_common::log_info!( + native_bridge_common::log_debug!( "TieredObjectStore: LOCAL NotFound, file transitioned to REMOTE — retrying path='{}'", path_str ); @@ -192,7 +305,11 @@ impl TieredObjectStore { /// Fast-path head response from registry or directory existence check. /// Returns `Some(GetResult)` if the head can be answered without I/O, /// `None` if the caller should fall through to the normal get_opts path. - fn try_head_from_registry(&self, location: &Path, path_str: &str) -> Option> { + fn try_head_from_registry( + &self, + location: &Path, + path_str: &str, + ) -> Option> { // Check registry for cached file size if let Some(guard) = self.registry.get(path_str) { let size = guard.size(); @@ -225,7 +342,7 @@ impl TieredObjectStore { }; let matches = self.registry.entries_matching(&prefix_with_slash); if !matches.is_empty() { - native_bridge_common::log_info!( + native_bridge_common::log_debug!( "TieredObjectStore: try_head_from_registry — path='{}' is a directory ({} files), returning NotFound", path_str, matches.len() ); @@ -238,6 +355,39 @@ impl TieredObjectStore { None } + /// Try to serve a range read from the cache. Returns `Some(Ok(GetResult))` on hit, + /// `None` on miss. Does NOT auto-populate the cache on miss. + /// + /// Used by `get_opts` to short-circuit range reads when the requested bytes were + /// previously stored via `put_metadata()` during warmup. + async fn try_serve_from_cache( + &self, + path_str: &str, + location: &Path, + range: &GetRange, + ) -> Option> { + let cache = self.cache.as_ref()?; + let (start, end) = self.resolve_range(path_str, range)?; + let key = range_cache_key(path_str, start, end); + let cached = cache.get(&key).await?; + let file_size = self.registry.get(path_str).map(|g| g.size()).unwrap_or(end); + let meta = ObjectMeta { + location: location.clone(), + last_modified: chrono::DateTime::::default(), + size: file_size, + e_tag: None, + version: None, + }; + Some(Ok(GetResult { + payload: object_store::GetResultPayload::Stream( + futures::stream::once(async { Ok(cached) }).boxed(), + ), + meta, + range: start..end, + attributes: Default::default(), + })) + } + /// Phase 1 — probe the block cache for each requested range. /// /// Returns: @@ -318,8 +468,9 @@ impl TieredObjectStore { slots: &mut Vec>, ) { if let Some(ref cache) = self.cache { - for (fetched_bytes, (&slot_i, miss_range)) in - fetched.iter().zip(miss_indices.iter().zip(miss_ranges.iter())) + for (fetched_bytes, (&slot_i, miss_range)) in fetched + .iter() + .zip(miss_indices.iter().zip(miss_ranges.iter())) { let key = range_cache_key(path_str, miss_range.start, miss_range.end); cache.put(&key, fetched_bytes.clone()); @@ -344,7 +495,22 @@ impl fmt::Debug for TieredObjectStore { impl fmt::Display for TieredObjectStore { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "TieredObjectStore(files={}, cache={})", self.registry.len(), self.cache.is_some()) + write!( + f, + "TieredObjectStore(files={}, cache={})", + self.registry.len(), + self.cache.is_some() + ) + } +} + +// --------------------------------------------------------------------------- +// MetadataCachingStore impl — delegates to the inherent put_metadata above. +// --------------------------------------------------------------------------- + +impl MetadataCachingStore for TieredObjectStore { + fn put_metadata(&self, path: &str, ranges: &[std::ops::Range], data: &[Bytes]) { + TieredObjectStore::put_metadata(self, path, ranges, data); } } @@ -392,6 +558,12 @@ impl ObjectStore for TieredObjectStore { /// Also handles head requests (options.head == true) by returning cached /// size from the registry when available — avoids I/O for the common case. /// For directory paths, returns NotFound so DataFusion uses list() instead. + /// + /// When a bounded/suffix/offset range is specified AND a cache is attached, + /// probes the cache first. On hit (entry previously stored via `put_metadata` + /// during warmup), returns cached bytes immediately — zero S3/local I/O. + /// On miss, proceeds with normal fetch (no auto-populate — metadata cache is + /// populated only by explicit `put_metadata()` calls from warmup code). async fn get_opts(&self, location: &Path, options: GetOptions) -> OsResult { let path_str = location.as_ref(); @@ -402,26 +574,86 @@ impl ObjectStore for TieredObjectStore { } } + // Cache probe for range reads. Serves entries from metadata Foyer + // (put there by warmup) or data Foyer (populated on prior miss). + // On miss: fetches from S3/local, then populates data Foyer via put() + // so repeated reads hit cache. + if let Some(ref get_range) = options.range { + if let Some(result) = self + .try_serve_from_cache(path_str, location, get_range) + .await + { + return result; + } + } - if let Some((rp, store)) = self.resolve_remote(path_str) { + // Cache miss — fetch from remote/local then populate data Foyer. + // This ensures single-range reads (CachedMetadataReader::get_bytes for + // column chunks in the IndexedExec path) are cached on first access. + // cache.put() always routes to data Foyer — metadata Foyer is only + // populated via explicit put_metadata() from warmup. + let get_result = if let Some((rp, store)) = self.resolve_remote(path_str) { native_bridge_common::log_debug!( "TieredObjectStore: get_opts REMOTE path='{}'", path_str ); - return store.get_opts(&rp, options).await; - } - - - let result = self.local.get_opts(location, options.clone()).await; - - + store.get_opts(&rp, options.clone()).await + } else { + let local_result = self.local.get_opts(location, options.clone()).await; + match local_result { + Ok(r) => Ok(r), + Err(ref e) => { + if let Some((rp, store)) = self.should_retry_remote(path_str, e) { + store.get_opts(&rp, options.clone()).await + } else { + local_result + } + } + } + }?; - if let Err(ref e) = result { - if let Some((rp, store)) = self.should_retry_remote(path_str, e) { - return store.get_opts(&rp, options).await; + // Populate data Foyer for bounded-range reads so repeated single-range + // reads (IndexedExec column chunks) hit cache on subsequent queries. + // TieredBlockCache::put() enforces max_data_entry_size — entries exceeding + // that limit are silently skipped (no buffering needed for them either). + if let Some(ref cache) = self.cache { + if let Some(ref get_range) = options.range { + if let Some((start, end)) = self.resolve_range(path_str, get_range) { + let range_size = end - start; + // Skip buffering for large ranges — TieredBlockCache::put() would + // reject them anyway (max_data_entry_size). This avoids allocating + // memory for entries that won't be cached. + let max_size = cache.as_any() + .downcast_ref::() + .map(|t| t.max_data_entry_size()) + .unwrap_or(32 * 1024 * 1024); // fallback for non-tiered cache + if range_size > max_size { + return Ok(get_result); + } + let bytes = get_result.bytes().await?; + let key = range_cache_key(path_str, start, end); + cache.put(&key, bytes.clone()); + let file_size = self.registry.get(path_str).map(|g| g.size()).unwrap_or(end); + let meta = ObjectMeta { + location: location.clone(), + last_modified: chrono::DateTime::::default(), + size: file_size, + e_tag: None, + version: None, + }; + return Ok(GetResult { + payload: object_store::GetResultPayload::Stream( + futures::stream::once(async { Ok(bytes) }).boxed(), + ), + meta, + range: start..end, + attributes: Default::default(), + }); + } } } - result + + Ok(get_result) } /// Multi-range read with cache-first routing. @@ -433,30 +665,17 @@ impl ObjectStore for TieredObjectStore { if miss_ranges.is_empty() { // Full cache hit — all ranges served from SSD. - native_bridge_common::log_debug!( - "TieredObjectStore: get_ranges FULL CACHE HIT path='{}' n={} total_bytes={}", - path_str, ranges.len(), - ranges.iter().map(|r| r.end - r.start).sum::() - ); return Ok(slots.into_iter().map(|o| o.unwrap()).collect()); } - if self.cache.is_some() { - native_bridge_common::log_debug!( - "TieredObjectStore: get_ranges CACHE MISS path='{}' misses={}/{}", - path_str, miss_ranges.len(), ranges.len() - ); - } else { - native_bridge_common::log_debug!( - "TieredObjectStore: get_ranges NO CACHE path='{}' fetching={}/{}", - path_str, miss_ranges.len(), ranges.len() - ); - } - let fetched = self.fetch_misses(location, path_str, &miss_ranges).await?; self.populate_cache_and_reassemble( - path_str, &fetched, &miss_indices, &miss_ranges, &mut slots, + path_str, + &fetched, + &miss_indices, + &miss_ranges, + &mut slots, ); Ok(slots.into_iter().map(|o| o.unwrap()).collect()) diff --git a/sandbox/libs/tiered-storage/src/main/rust/src/tiered_object_store_tests.rs b/sandbox/libs/tiered-storage/src/main/rust/src/tiered_object_store_tests.rs index b055f7da14f61..480d4cab12b33 100644 --- a/sandbox/libs/tiered-storage/src/main/rust/src/tiered_object_store_tests.rs +++ b/sandbox/libs/tiered-storage/src/main/rust/src/tiered_object_store_tests.rs @@ -639,10 +639,12 @@ impl ObjectStore for ErrorStore { &self, locations: BoxStream<'static, OsResult>, ) -> BoxStream<'static, OsResult> { - Box::pin(locations.map(|_| Err(object_store::Error::Generic { - store: "ErrorStore", - source: "simulated error".into(), - }))) + Box::pin(locations.map(|_| { + Err(object_store::Error::Generic { + store: "ErrorStore", + source: "simulated error".into(), + }) + })) } fn list(&self, _prefix: Option<&Path>) -> BoxStream<'static, OsResult> { @@ -696,11 +698,7 @@ fn test_register_file_remote_without_remote_path_returns_err() { let registry = Arc::new(TieredStorageRegistry::new()); let local = Arc::new(InMemory::new()); let tiered = TieredObjectStore::new(registry, local as _); - let result = tiered.register_file( - "/a.parquet", - FileLocation::Remote, - None, - ); + let result = tiered.register_file("/a.parquet", FileLocation::Remote, None); assert!(result.is_err()); } @@ -904,7 +902,11 @@ async fn test_head_directory_path_returns_synthetic_when_registry_has_entries() let (registry, _local, _remote, tiered) = setup(); // Register a file so registry is non-empty - let entry = TieredFileEntry::with_size(FileLocation::Remote, Some(Arc::from("remote/a.parquet")), 1024); + let entry = TieredFileEntry::with_size( + FileLocation::Remote, + Some(Arc::from("remote/a.parquet")), + 1024, + ); registry.register("data/parquet/a.parquet", entry); // head() on a directory path should return NotFound — DataFusion uses list() @@ -912,20 +914,30 @@ async fn test_head_directory_path_returns_synthetic_when_registry_has_entries() // DataFusion "this is not a file" and it proceeds to list(). let result = tiered.head(&Path::from("data/parquet")).await; assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), object_store::Error::NotFound { .. })); + assert!(matches!( + result.unwrap_err(), + object_store::Error::NotFound { .. } + )); } #[tokio::test] async fn test_head_directory_path_with_trailing_slash() { let (registry, _local, _remote, tiered) = setup(); - let entry = TieredFileEntry::with_size(FileLocation::Remote, Some(Arc::from("remote/b.parquet")), 2048); + let entry = TieredFileEntry::with_size( + FileLocation::Remote, + Some(Arc::from("remote/b.parquet")), + 2048, + ); registry.register("data/parquet/b.parquet", entry); // Trailing slash also treated as directory — returns NotFound let result = tiered.head(&Path::from("data/parquet/")).await; assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), object_store::Error::NotFound { .. })); + assert!(matches!( + result.unwrap_err(), + object_store::Error::NotFound { .. } + )); } #[tokio::test] @@ -942,12 +954,356 @@ async fn test_head_file_path_not_treated_as_directory() { let (registry, _local, _remote, tiered) = setup(); // Register a file so registry is non-empty - let entry = TieredFileEntry::with_size(FileLocation::Remote, Some(Arc::from("remote/c.parquet")), 512); + let entry = TieredFileEntry::with_size( + FileLocation::Remote, + Some(Arc::from("remote/c.parquet")), + 512, + ); registry.register("data/parquet/c.parquet", entry); // head() on a file path (has extension) should NOT use directory check // — it should try registry lookup, then remote, then local - let result = tiered.head(&Path::from("data/parquet/nonexistent.parquet")).await; + let result = tiered + .head(&Path::from("data/parquet/nonexistent.parquet")) + .await; // Not in registry, not local → NotFound assert!(result.is_err()); } + +// -- resolve_range tests ---------------------------------------------------- + +#[test] +fn test_resolve_range_bounded_returns_bounds_directly() { + let (_registry, _local, _remote, tiered) = setup(); + // Bounded needs no registry/size — returned verbatim. + assert_eq!( + tiered.resolve_range("any.parquet", &GetRange::Bounded(10..20)), + Some((10, 20)) + ); +} + +#[test] +fn test_resolve_range_suffix_uses_registry_size() { + let (registry, _local, _remote, tiered) = setup(); + registry.register( + "a.parquet", + TieredFileEntry::with_size(FileLocation::Local, None, 1000), + ); + // Suffix(64) → (size - 64, size). + assert_eq!( + tiered.resolve_range("a.parquet", &GetRange::Suffix(64)), + Some((936, 1000)) + ); +} + +#[test] +fn test_resolve_range_offset_uses_registry_size() { + let (registry, _local, _remote, tiered) = setup(); + registry.register( + "a.parquet", + TieredFileEntry::with_size(FileLocation::Local, None, 1000), + ); + // Offset(100) → (100, size). + assert_eq!( + tiered.resolve_range("a.parquet", &GetRange::Offset(100)), + Some((100, 1000)) + ); +} + +#[test] +fn test_resolve_range_suffix_none_when_unregistered() { + let (_registry, _local, _remote, tiered) = setup(); + // No registry entry → size unavailable → cache bypassed (None). + assert_eq!( + tiered.resolve_range("missing.parquet", &GetRange::Suffix(64)), + None + ); +} + +#[test] +fn test_resolve_range_offset_none_when_size_zero() { + let (registry, _local, _remote, tiered) = setup(); + // size 0 (default) is filtered out → None. + registry.register("z.parquet", TieredFileEntry::new(FileLocation::Local, None)); + assert_eq!( + tiered.resolve_range("z.parquet", &GetRange::Offset(10)), + None + ); +} + +// -- Cache routing tests (MockBlockCache) ----------------------------------- + +use bytes::Bytes; +use opensearch_block_cache::range_cache::{range_cache_key, CacheKey}; +use opensearch_block_cache::traits::BlockCache; +use std::collections::HashMap; +use std::sync::Mutex; + +/// Minimal in-memory [`BlockCache`] for unit-testing TieredObjectStore's cache +/// routing without pulling in real Foyer. Mirrors TieredBlockCache semantics: +/// `get` probes the metadata tier first, then the data tier. +#[derive(Default)] +struct MockBlockCache { + data: Mutex>, + meta: Mutex>, + evicted: Mutex>, +} + +impl BlockCache for MockBlockCache { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn get<'a>( + &'a self, + key: &'a CacheKey, + ) -> std::pin::Pin> + Send + 'a>> { + let k = key.as_str().to_string(); + let hit = self + .meta + .lock() + .unwrap() + .get(&k) + .cloned() + .or_else(|| self.data.lock().unwrap().get(&k).cloned()); + Box::pin(async move { hit }) + } + + fn put(&self, key: &CacheKey, data: Bytes) { + self.data + .lock() + .unwrap() + .insert(key.as_str().to_string(), data); + } + + fn put_metadata(&self, key: &CacheKey, data: Bytes) { + self.meta + .lock() + .unwrap() + .insert(key.as_str().to_string(), data); + } + + fn evict_prefix(&self, prefix: &str) { + self.evicted.lock().unwrap().push(prefix.to_string()); + self.data + .lock() + .unwrap() + .retain(|k, _| !k.starts_with(prefix)); + self.meta + .lock() + .unwrap() + .retain(|k, _| !k.starts_with(prefix)); + } + + fn clear(&self) -> std::pin::Pin + Send + '_>> { + self.data.lock().unwrap().clear(); + self.meta.lock().unwrap().clear(); + Box::pin(async {}) + } +} + +fn setup_with_cache() -> ( + Arc, + Arc, + Arc, + TieredObjectStore, +) { + let registry = Arc::new(TieredStorageRegistry::new()); + let local = Arc::new(InMemory::new()); + let cache = Arc::new(MockBlockCache::default()); + let tiered = TieredObjectStore::new(Arc::clone(®istry), Arc::clone(&local) as _) + .with_cache(Arc::clone(&cache) as Arc); + (registry, local, cache, tiered) +} + +#[tokio::test] +async fn test_put_metadata_served_from_cache_on_get_opts() { + let (registry, _local, _cache, tiered) = setup_with_cache(); + registry.register( + "a.parquet", + TieredFileEntry::with_size(FileLocation::Local, None, 1000), + ); + + // Warmup promotes the footer range into the metadata tier. No local file exists, + // so a successful read proves the bytes were served from the cache. + tiered.put_metadata("a.parquet", &[936..1000], &[Bytes::from_static(b"FOOTER")]); + + let opts = GetOptions { + range: Some(GetRange::Bounded(936..1000)), + ..Default::default() + }; + let bytes = tiered + .get_opts(&Path::from("a.parquet"), opts) + .await + .unwrap() + .bytes() + .await + .unwrap(); + assert_eq!(bytes.as_ref(), b"FOOTER"); +} + +#[tokio::test] +async fn test_put_metadata_routes_to_metadata_tier_only() { + let (registry, _local, cache, tiered) = setup_with_cache(); + registry.register( + "a.parquet", + TieredFileEntry::with_size(FileLocation::Local, None, 1000), + ); + + tiered.put_metadata("a.parquet", &[936..1000], &[Bytes::from_static(b"FOOTER")]); + + let key = range_cache_key("a.parquet", 936, 1000); + assert!( + cache.meta.lock().unwrap().contains_key(key.as_str()), + "metadata tier populated" + ); + assert!( + !cache.data.lock().unwrap().contains_key(key.as_str()), + "data tier must NOT be populated by put_metadata" + ); +} + +#[tokio::test] +async fn test_put_metadata_noop_without_cache() { + // No cache attached — must be a no-op, not a panic. + let (_registry, _local, _remote, tiered) = setup(); + tiered.put_metadata("a.parquet", &[0..10], &[Bytes::from_static(b"0123456789")]); +} + +#[tokio::test] +async fn test_get_opts_miss_populates_data_tier() { + let (registry, local, cache, tiered) = setup_with_cache(); + local + .put( + &Path::from("a.parquet"), + PutPayload::from_static(b"0123456789"), + ) + .await + .unwrap(); + registry.register( + "a.parquet", + TieredFileEntry::with_size(FileLocation::Local, None, 10), + ); + + let opts = GetOptions { + range: Some(GetRange::Bounded(0..4)), + ..Default::default() + }; + let bytes = tiered + .get_opts(&Path::from("a.parquet"), opts) + .await + .unwrap() + .bytes() + .await + .unwrap(); + assert_eq!(bytes.as_ref(), b"0123"); + + // A get_opts miss populates the DATA tier (never the metadata tier). + let key = range_cache_key("a.parquet", 0, 4); + assert!( + cache.data.lock().unwrap().contains_key(key.as_str()), + "data tier populated on miss" + ); + assert!( + !cache.meta.lock().unwrap().contains_key(key.as_str()), + "get_opts must not populate metadata tier" + ); +} + +#[tokio::test] +async fn test_get_ranges_full_hit_served_from_cache() { + // Empty local store proves the cache served the read. + let (registry, _local, cache, tiered) = setup_with_cache(); + registry.register( + "a.parquet", + TieredFileEntry::with_size(FileLocation::Local, None, 100), + ); + + cache.put( + &range_cache_key("a.parquet", 0, 4), + Bytes::from_static(b"AAAA"), + ); + cache.put( + &range_cache_key("a.parquet", 4, 8), + Bytes::from_static(b"BBBB"), + ); + + let results = tiered + .get_ranges(&Path::from("a.parquet"), &[0..4, 4..8]) + .await + .unwrap(); + assert_eq!(results[0].as_ref(), b"AAAA"); + assert_eq!(results[1].as_ref(), b"BBBB"); +} + +#[tokio::test] +async fn test_get_ranges_partial_miss_fetches_and_populates() { + let (registry, local, cache, tiered) = setup_with_cache(); + local + .put( + &Path::from("a.parquet"), + PutPayload::from_static(b"0123456789"), + ) + .await + .unwrap(); + registry.register( + "a.parquet", + TieredFileEntry::with_size(FileLocation::Local, None, 10), + ); + + // Pre-seed only [0,4); [5,8) misses and is fetched from local. + cache.put( + &range_cache_key("a.parquet", 0, 4), + Bytes::from_static(b"AAAA"), + ); + + let results = tiered + .get_ranges(&Path::from("a.parquet"), &[0..4, 5..8]) + .await + .unwrap(); + assert_eq!(results[0].as_ref(), b"AAAA", "hit served from cache"); + assert_eq!(results[1].as_ref(), b"567", "miss fetched from local store"); + + // The previously-missing range is now cached. + let miss_key = range_cache_key("a.parquet", 5, 8); + assert!( + cache.data.lock().unwrap().contains_key(miss_key.as_str()), + "miss range populated into data tier" + ); +} + +#[tokio::test] +async fn test_evict_path_evicts_cache_prefix() { + let (_registry, _local, cache, tiered) = setup_with_cache(); + + cache.put_metadata( + &range_cache_key("seg/a.parquet", 0, 4), + Bytes::from_static(b"m"), + ); + cache.put( + &range_cache_key("seg/a.parquet", 4, 8), + Bytes::from_static(b"d"), + ); + + tiered.evict_path("seg/a.parquet"); + + assert_eq!( + cache.evicted.lock().unwrap().as_slice(), + &["seg/a.parquet".to_string()] + ); + assert!( + cache.data.lock().unwrap().is_empty(), + "data tier cleared for the evicted prefix" + ); + assert!( + cache.meta.lock().unwrap().is_empty(), + "metadata tier cleared for the evicted prefix" + ); +} + +#[test] +fn test_evict_path_noop_without_cache() { + // No cache attached — must be a no-op, not a panic. + let (_registry, _local, _remote, tiered) = setup(); + tiered.evict_path("x"); +} diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/Cargo.toml b/sandbox/plugins/analytics-backend-datafusion/rust/Cargo.toml index 83ce662d149f1..25df4167b9ef9 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/Cargo.toml +++ b/sandbox/plugins/analytics-backend-datafusion/rust/Cargo.toml @@ -25,6 +25,7 @@ arrow-schema = { workspace = true } parquet = { workspace = true } object_store = { workspace = true } +bytes = { workspace = true } url = { workspace = true } prost = { workspace = true } @@ -40,6 +41,7 @@ dashmap = { workspace = true } log = { workspace = true } num_cpus = { workspace = true } native-bridge-common = { workspace = true } +opensearch-tiered-storage = { workspace = true } async-trait = { workspace = true } chrono = { workspace = true } roaring = "=0.10.12" @@ -93,11 +95,19 @@ crc32fast = { workspace = true } # rewrite. STANDARD alphabet matches the OpenSearch `binary` field wire contract. base64 = "0.22" +# Liquid Cache for decoded-batch Parquet caching (vendored in-memory subset, +# see sandbox/libs/dataformat-native/rust/liquid-cache/README.md). +opensearch-liquid-cache-core = { workspace = true } +opensearch-liquid-cache-datafusion = { workspace = true } + [dev-dependencies] criterion = { workspace = true } tempfile = { workspace = true } rand = "=0.8.6" proptest = { workspace = true } +# Used only by tiered_storage_integration_tests.rs, which exercise FoyerCache / +# TieredBlockCache directly through the TieredObjectStore. +opensearch-block-cache = { path = "../../block-cache-foyer/src/main/rust" } [[bench]] name = "query_bench" diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/benches/cross_rt_throughput_bench.rs b/sandbox/plugins/analytics-backend-datafusion/rust/benches/cross_rt_throughput_bench.rs index 4a5dd6f66edf2..52d494b11bada 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/benches/cross_rt_throughput_bench.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/benches/cross_rt_throughput_bench.rs @@ -86,8 +86,7 @@ fn bench_cross_rt_throughput(c: &mut Criterion) { stream::iter(batches), )); - let cross = - CrossRtStream::new_with_df_error_stream(inner, exec.clone()); + let cross = CrossRtStream::new_with_df_error_stream(inner, exec.clone()); let wrapped = RecordBatchStreamAdapter::new(cross.schema(), cross); tokio::pin!(wrapped); diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/benches/memory_budget_bench.rs b/sandbox/plugins/analytics-backend-datafusion/rust/benches/memory_budget_bench.rs index 5ba301b1986b6..fb051753b8881 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/benches/memory_budget_bench.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/benches/memory_budget_bench.rs @@ -128,8 +128,7 @@ fn bench_acquire_budget_concurrent(c: &mut Criterion) { thread::spawn(move || { barrier.wait(); for _ in 0..100 { - let budget = - acquire_budget(&pool, &schema, 4, 8192).unwrap(); + let budget = acquire_budget(&pool, &schema, 4, 8192).unwrap(); drop(budget); } }) diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/benches/memory_pressure_bench.rs b/sandbox/plugins/analytics-backend-datafusion/rust/benches/memory_pressure_bench.rs index 7e6fe40a7aae1..56eeb51f11c6d 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/benches/memory_pressure_bench.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/benches/memory_pressure_bench.rs @@ -272,8 +272,18 @@ fn print_result(label: &str, r: &SimulationResult) { r.budgeted_effective_partitions.iter().sum::() as f64 / r.budgeted_effective_partitions.len() as f64 }; - let min_partitions = r.budgeted_effective_partitions.iter().min().copied().unwrap_or(0); - let max_partitions = r.budgeted_effective_partitions.iter().max().copied().unwrap_or(0); + let min_partitions = r + .budgeted_effective_partitions + .iter() + .min() + .copied() + .unwrap_or(0); + let max_partitions = r + .budgeted_effective_partitions + .iter() + .max() + .copied() + .unwrap_or(0); let memory_saved = r .unbudgeted_total_memory @@ -288,26 +298,14 @@ fn print_result(label: &str, r: &SimulationResult) { println!("┌─────────────────────────────────────────────────────────────────┐"); println!("│ {:<63} │", label); println!("├─────────────────────────────────────────────────────────────────┤"); - println!( - "│ Pool limit: {:<40} │", - fmt_mb(r.pool_limit) - ); - println!( - "│ Concurrent queries: {:<40} │", - r.num_queries - ); + println!("│ Pool limit: {:<40} │", fmt_mb(r.pool_limit)); + println!("│ Concurrent queries: {:<40} │", r.num_queries); println!( "│ Configured partitions: {:<40} │", r.configured_target_partitions ); - println!( - "│ Batch size: {:<40} │", - r.batch_size - ); - println!( - "│ Avg row bytes: {:<40} │", - r.avg_row_bytes - ); + println!("│ Batch size: {:<40} │", r.batch_size); + println!("│ Avg row bytes: {:<40} │", r.avg_row_bytes); println!("├────────────────── WITH BUDGET ───────────────────────────────────┤"); println!( "│ Peak pool reserved: {:<40} │", @@ -321,14 +319,8 @@ fn print_result(label: &str, r: &SimulationResult) { "│ Effective partitions: avg={:.1}, min={}, max={:<21} │", avg_partitions, min_partitions, max_partitions ); - println!( - "│ Operator spills: {:<40} │", - r.budgeted_spills - ); - println!( - "│ Queries rejected: {:<40} │", - r.budgeted_rejections - ); + println!("│ Operator spills: {:<40} │", r.budgeted_spills); + println!("│ Queries rejected: {:<40} │", r.budgeted_rejections); println!( "│ Within pool limit: {:<40} │", if within_pool { "YES ✓" } else { "NO ✗" } @@ -349,7 +341,10 @@ fn print_result(label: &str, r: &SimulationResult) { println!( "│ Would exceed pool: {:<40} │", if r.unbudgeted_total_memory > r.pool_limit { - format!("YES by {}", fmt_mb(r.unbudgeted_total_memory - r.pool_limit)) + format!( + "YES by {}", + fmt_mb(r.unbudgeted_total_memory - r.pool_limit) + ) } else { "NO".to_string() } @@ -359,11 +354,15 @@ fn print_result(label: &str, r: &SimulationResult) { let (u_avg, u_min, u_p50, u_p99) = latency_stats(&r.unbudgeted_latencies_ns); println!( "│ With budget: avg={}, p50={}, p99={:<12} │", - fmt_ns(b_avg), fmt_ns(b_p50), fmt_ns(b_p99) + fmt_ns(b_avg), + fmt_ns(b_p50), + fmt_ns(b_p99) ); println!( "│ Without budget: avg={}, p50={}, p99={:<12} │", - fmt_ns(u_avg), fmt_ns(u_p50), fmt_ns(u_p99) + fmt_ns(u_avg), + fmt_ns(u_p50), + fmt_ns(u_p99) ); let overhead_ns = b_avg - u_avg; let overhead_pct = if u_avg > 0.0 { @@ -405,7 +404,10 @@ fn main() { 8192, // batch_size=8192 &analytics, ); - print_result("Scenario 1: Low concurrency (4 queries, 256MB pool, 10-col schema)", &r); + print_result( + "Scenario 1: Low concurrency (4 queries, 256MB pool, 10-col schema)", + &r, + ); // Scenario 2: Moderate concurrency, moderate pool let r = simulate_concurrent_queries( @@ -415,7 +417,10 @@ fn main() { 8192, &analytics, ); - print_result("Scenario 2: Moderate concurrency (8 queries, 128MB pool, 10-col)", &r); + print_result( + "Scenario 2: Moderate concurrency (8 queries, 128MB pool, 10-col)", + &r, + ); // Scenario 3: High concurrency, tight pool let r = simulate_concurrent_queries( @@ -425,17 +430,23 @@ fn main() { 8192, &analytics, ); - print_result("Scenario 3: High concurrency (16 queries, 64MB pool, 10-col)", &r); + print_result( + "Scenario 3: High concurrency (16 queries, 64MB pool, 10-col)", + &r, + ); // Scenario 4: Wide schema under moderate pressure let r = simulate_concurrent_queries( 256 * 1024 * 1024, // 256MB pool 8, - 8, // higher parallelism + 8, // higher parallelism 8192, &wide, ); - print_result("Scenario 4: Wide schema (8 queries, 256MB pool, 100-col, tp=8)", &r); + print_result( + "Scenario 4: Wide schema (8 queries, 256MB pool, 100-col, tp=8)", + &r, + ); // Scenario 5: Wide schema under extreme pressure let r = simulate_concurrent_queries( @@ -445,7 +456,10 @@ fn main() { 8192, &wide, ); - print_result("Scenario 5: Wide schema extreme (16 queries, 128MB pool, 100-col, tp=8)", &r); + print_result( + "Scenario 5: Wide schema extreme (16 queries, 128MB pool, 100-col, tp=8)", + &r, + ); // Scenario 6: Very high concurrency burst let r = simulate_concurrent_queries( @@ -455,5 +469,8 @@ fn main() { 8192, &analytics, ); - print_result("Scenario 6: Burst (32 queries, 256MB pool, 10-col, tp=4)", &r); + print_result( + "Scenario 6: Burst (32 queries, 256MB pool, 10-col, tp=4)", + &r, + ); } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/benches/row_id_bench.rs b/sandbox/plugins/analytics-backend-datafusion/rust/benches/row_id_bench.rs index 105e16a6ef026..86f940895c9c3 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/benches/row_id_bench.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/benches/row_id_bench.rs @@ -56,6 +56,7 @@ fn setup() -> (RuntimeManager, DataFusionRuntime) { runtime_env, custom_cache_manager: None, dynamic_limit_handle: handle, + liquid_cache_optimizer: None, }; (mgr, df_runtime) } @@ -213,9 +214,10 @@ fn bench_clickbench(c: &mut Criterion) { .unwrap(); let mut stream = unsafe { Box::from_raw( - ptr as *mut datafusion::physical_plan::stream::RecordBatchStreamAdapter< - opensearch_datafusion::cross_rt_stream::CrossRtStream, - >, + ptr + as *mut datafusion::physical_plan::stream::RecordBatchStreamAdapter< + opensearch_datafusion::cross_rt_stream::CrossRtStream, + >, ) }; let mut rows = 0u64; diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/agg_mode.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/agg_mode.rs index d42545bd4c2ce..468d047aa889b 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/agg_mode.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/agg_mode.rs @@ -16,7 +16,7 @@ use datafusion::physical_optimizer::optimizer::{PhysicalOptimizer, PhysicalOptim use datafusion::physical_plan::aggregates::{AggregateExec, AggregateMode}; use datafusion::physical_plan::expressions::Column; use datafusion::physical_plan::projection::ProjectionExec; -use datafusion::physical_plan::ExecutionPlan; +use datafusion::physical_plan::{ExecutionPlan, ExecutionPlanProperties}; use datafusion_common::Result; #[derive(Clone, Copy, Debug, PartialEq)] @@ -38,20 +38,25 @@ pub(crate) fn physical_optimizer_rules_without_combine( } /// Applies aggregate mode stripping to a physical plan. +/// `has_topk`: when true and stripping to Partial, replaces Final/FinalPartitioned with +/// PartialReduce so CSS partitions are merged by group key before the TopK sort truncates. pub(crate) fn apply_aggregate_mode( plan: Arc, mode: Mode, + has_topk: bool, ) -> Result> { match mode { Mode::Default => Ok(plan), - Mode::Partial => force_aggregate_mode(plan, AggregateMode::Partial), - Mode::Final => force_aggregate_mode(plan, AggregateMode::Final), + Mode::Partial => force_aggregate_mode(plan, AggregateMode::Partial, has_topk), + Mode::Final => force_aggregate_mode(plan, AggregateMode::Final, false), } } /// Returns the output schema of the Partial aggregate without rebuilding the plan tree. /// Used by `derive_schema_from_partial_plan` where we only need types, not an executable plan. -pub(crate) fn partial_aggregate_schema(plan: &Arc) -> Option { +pub(crate) fn partial_aggregate_schema( + plan: &Arc, +) -> Option { find_partial_input(Arc::clone(plan)).map(|p| p.schema()) } @@ -59,6 +64,7 @@ pub(crate) fn partial_aggregate_schema(plan: &Arc) -> Option< fn force_aggregate_mode( plan: Arc, target: AggregateMode, + has_topk: bool, ) -> Result> { if let Some(agg) = plan.downcast_ref::() { // Treat `FinalPartitioned` as `Final`: DataFusion picks `FinalPartitioned` for @@ -71,39 +77,56 @@ fn force_aggregate_mode( let new_children: Vec> = agg .children() .into_iter() - .map(|c| force_aggregate_mode(Arc::clone(c), target)) + .map(|c| force_aggregate_mode(Arc::clone(c), target, has_topk)) .collect::>()?; return plan.with_new_children(new_children); } // Mode mismatch — strip this node match target { AggregateMode::Partial => { - // Current node is Final; find the Partial subtree below + // Current node is Final/FinalPartitioned. + // When TopK is active and the input has multiple partitions (CSS), replace + // with PartialReduce instead of stripping. PartialReduce keeps agg.input() + // (RepartitionExec(Hash) → Partial(×N)) so CSS partitions are merged by + // group key before TopK truncation. Skip when input_partitions=1 — PartialReduce + // over a single partition is redundant and adds unnecessary overhead. + if has_topk && agg.input().output_partitioning().partition_count() > 1 { + return Ok(Arc::new(AggregateExec::try_new( + AggregateMode::PartialReduce, + agg.group_expr().clone(), + agg.aggr_expr().to_vec(), + agg.filter_expr().to_vec(), + Arc::clone(agg.input()), + agg.input_schema(), + )?)); + } + // Normal path: strip Final, return Partial subtree if let Some(partial_subtree) = find_partial_input(Arc::clone(agg.input())) { return Ok(partial_subtree); } - // If no Partial found below, the input itself is the Partial Ok(Arc::clone(agg.input())) } AggregateMode::Final => { // Current node is Partial; skip it, return its child // (the Final above will keep itself) let child = agg.children()[0]; - force_aggregate_mode(Arc::clone(child), target) + force_aggregate_mode(Arc::clone(child), target, false) } _ => Ok(plan), } } else if plan.children().len() == 1 { // Single-input wrapper — recurse transparently. let old_child = Arc::clone(plan.children()[0]); - let new_child = force_aggregate_mode(old_child.clone(), target)?; + let new_child = force_aggregate_mode(old_child.clone(), target, has_topk)?; // DataFusion's ProjectionMapping::try_new asserts col.name() == input_schema.field(i).name(); // with_new_children triggers it. Remap columns to the post-strip schema so it passes. if let Some(proj) = plan.downcast_ref::() { if old_child.schema() != new_child.schema() { let new_schema = &new_child.schema(); - let remapped: Vec<(Arc, String)> = proj.expr().iter() + let remapped: Vec<(Arc, String)> = proj + .expr() + .iter() .map(|pe| (remap_column(pe.expr.clone(), new_schema), pe.alias.clone())) .collect(); return Ok(Arc::new(ProjectionExec::try_new(remapped, new_child)?)); @@ -136,25 +159,31 @@ fn find_partial_input(plan: Arc) -> Option, schema: &arrow::datatypes::SchemaRef) -> Arc { +fn remap_column( + expr: Arc, + schema: &arrow::datatypes::SchemaRef, +) -> Arc { if let Some(col) = expr.downcast_ref::() { return Arc::new(Column::new(schema.field(col.index()).name(), col.index())); } let children = expr.children(); - if children.is_empty() { return expr; } - let new_children: Vec<_> = children.into_iter().map(|c| remap_column(c.clone(), schema)).collect(); + if children.is_empty() { + return expr; + } + let new_children: Vec<_> = children + .into_iter() + .map(|c| remap_column(c.clone(), schema)) + .collect(); let fallback = expr.clone(); expr.with_new_children(new_children).unwrap_or(fallback) } - #[cfg(test)] mod tests { use super::*; - use datafusion::prelude::*; use datafusion::physical_plan::displayable; + use datafusion::prelude::*; /// Helper: create a SessionContext with CombinePartialFinalAggregate disabled, /// register a memtable, and produce a physical plan for `SELECT SUM(x) FROM t`. @@ -235,7 +264,7 @@ mod tests { plan_string(&plan) ); - let result = apply_aggregate_mode(plan, Mode::Partial).unwrap(); + let result = apply_aggregate_mode(plan, Mode::Partial, false).unwrap(); let result_modes = find_agg_modes(&result); assert!( result_modes.contains(&AggregateMode::Partial), @@ -253,7 +282,7 @@ mod tests { async fn test_strip_final_over_scan() { // Final(Partial(memtable)) → strip to Final only (Partial removed) let plan = make_agg_plan().await; - let result = apply_aggregate_mode(plan, Mode::Final).unwrap(); + let result = apply_aggregate_mode(plan, Mode::Final, false).unwrap(); let result_modes = find_agg_modes(&result); assert!( result_modes.contains(&AggregateMode::Final), @@ -276,13 +305,13 @@ mod tests { let modes = find_agg_modes(&plan); if modes.len() < 2 { // If optimizer collapsed it, just verify Mode::Partial works - let result = apply_aggregate_mode(plan, Mode::Partial).unwrap(); + let result = apply_aggregate_mode(plan, Mode::Partial, false).unwrap(); let result_modes = find_agg_modes(&result); assert!(!result_modes.contains(&AggregateMode::Final)); return; } - let result = apply_aggregate_mode(plan, Mode::Partial).unwrap(); + let result = apply_aggregate_mode(plan, Mode::Partial, false).unwrap(); let result_modes = find_agg_modes(&result); assert!( !result_modes.contains(&AggregateMode::Final), @@ -297,7 +326,7 @@ mod tests { // Final → CoalescePartitions → Partial → scan; strip to Final let plan = make_agg_plan().await; // The simple plan has CoalescePartitions between Final and Partial - let result = apply_aggregate_mode(plan, Mode::Final).unwrap(); + let result = apply_aggregate_mode(plan, Mode::Final, false).unwrap(); let result_modes = find_agg_modes(&result); assert!( !result_modes.contains(&AggregateMode::Partial), @@ -329,13 +358,51 @@ mod tests { async fn test_apply_partial_strips_final() { let plan = make_agg_plan().await; let display_before = plan_string(&plan); - assert!(display_before.contains("AggregateExec: mode=Final"), "expected Final in plan"); - assert!(display_before.contains("AggregateExec: mode=Partial"), "expected Partial in plan"); + assert!( + display_before.contains("AggregateExec: mode=Final"), + "expected Final in plan" + ); + assert!( + display_before.contains("AggregateExec: mode=Partial"), + "expected Partial in plan" + ); - let stripped = apply_aggregate_mode(plan, Mode::Partial).unwrap(); + let stripped = apply_aggregate_mode(plan, Mode::Partial, false).unwrap(); let display_after = plan_string(&stripped); - assert!(!display_after.contains("mode=Final"), "Final should be stripped"); - assert!(display_after.contains("mode=Partial"), "Partial should remain"); + assert!( + !display_after.contains("mode=Final"), + "Final should be stripped" + ); + assert!( + display_after.contains("mode=Partial"), + "Partial should remain" + ); } + /// When has_topk=true and the input has multiple partitions (CSS), Final/FinalPartitioned + /// must be replaced with PartialReduce rather than stripped, so the coordinator receives + /// correctly merged partial state instead of per-partition-truncated results. + #[tokio::test] + async fn test_apply_partial_with_topk_produces_partial_reduce() { + let plan = make_agg_plan_with_repartition().await; + let display_before = plan_string(&plan); + // With target_partitions=4 and GROUP BY, DF produces FinalPartitioned. + assert!( + display_before.contains("mode=FinalPartitioned") + || display_before.contains("mode=Final"), + "expected Final/FinalPartitioned in multi-partition plan, got:\n{display_before}" + ); + + let result = apply_aggregate_mode(plan, Mode::Partial, true).unwrap(); + let modes = find_agg_modes(&result); + assert!( + modes.contains(&AggregateMode::PartialReduce), + "has_topk=true with multi-partition input must produce PartialReduce, got modes: {modes:?}" + ); + assert!( + !modes.contains(&AggregateMode::Final) + && !modes.contains(&AggregateMode::FinalPartitioned), + "Final/FinalPartitioned must not remain after stripping" + ); + } } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/api.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/api.rs index 28b2717e7de76..3ba0ce7f11e19 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/api.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/api.rs @@ -46,10 +46,10 @@ use arrow_schema::ffi::FFI_ArrowSchema; use datafusion::common::DataFusionError; use datafusion::datasource::listing::ListingTableUrl; use datafusion::datasource::physical_plan::parquet::{ParquetAccessPlan, RowGroupAccess}; +use datafusion::execution::cache::cache_manager::CacheManagerConfig; use datafusion::execution::disk_manager::{DiskManagerBuilder, DiskManagerMode}; -use datafusion::execution::memory_pool::TrackConsumersPool; +use datafusion::execution::memory_pool::{MemoryPool, TrackConsumersPool}; use datafusion::execution::runtime_env::RuntimeEnvBuilder; -use datafusion::execution::cache::cache_manager::CacheManagerConfig; use datafusion::execution::RecordBatchStream; use datafusion::execution::SessionStateBuilder; use datafusion::physical_plan::execute_stream; @@ -63,10 +63,16 @@ use roaring::RoaringBitmap; use crate::cancellation; use crate::cross_rt_stream::CrossRtStream; use crate::custom_cache_manager::CustomCacheManager; +use crate::datafusion_query_config::DatafusionQueryConfig; +use crate::helper::{build_query_runtime_env_with_store, new_query_tracking_context}; +use crate::indexed_executor::execute_indexed_query; use crate::local_executor::LocalSession; use crate::memory::{DynamicLimitHandle, DynamicLimitPool}; +use crate::memory_guard::{per_query_spill_budget, SpillBudget}; use crate::partition_stream::PartitionStreamSender; -use crate::query_tracker::{self, QueryTrackingContext}; +use crate::phantom_corrector::PhantomCorrector; +use crate::query_executor; +use crate::query_tracker::{self, QueryTrackingContext, QueryType}; use crate::runtime_manager::RuntimeManager; use crate::shard_table_provider::{ShardTableConfig, ShardTableProvider}; @@ -95,9 +101,10 @@ pub struct QueryStreamHandle { impl QueryStreamHandle { fn schema_has_views(schema: &arrow_schema::SchemaRef) -> bool { - schema.fields().iter().any(|f| { - matches!(f.data_type(), DataType::Utf8View | DataType::BinaryView) - }) + schema + .fields() + .iter() + .any(|f| matches!(f.data_type(), DataType::Utf8View | DataType::BinaryView)) } pub fn new( @@ -185,41 +192,82 @@ impl QueryStreamHandle { let mut map = serde_json::Map::new(); Self::collect_metrics(plan.as_ref(), &mut map); // Include the physical plan display text - let plan_text = datafusion::physical_plan::displayable(plan.as_ref()).indent(true).to_string(); - map.insert("physical_plan".to_string(), serde_json::Value::String(plan_text)); + let plan_text = datafusion::physical_plan::displayable(plan.as_ref()) + .indent(true) + .to_string(); + map.insert( + "physical_plan".to_string(), + serde_json::Value::String(plan_text), + ); serde_json::to_vec(&map).ok() } - fn collect_metrics(plan: &dyn datafusion::physical_plan::ExecutionPlan, map: &mut serde_json::Map) { + fn collect_metrics( + plan: &dyn datafusion::physical_plan::ExecutionPlan, + map: &mut serde_json::Map, + ) { if let Some(metrics) = plan.metrics() { for m in metrics.iter() { - let add = |map: &mut serde_json::Map, key: String, delta: i64| { + let add = |map: &mut serde_json::Map, + key: String, + delta: i64| { let prev = map.get(&key).and_then(|v| v.as_i64()).unwrap_or(0); - map.insert(key, serde_json::Value::Number(serde_json::Number::from(prev + delta))); + map.insert( + key, + serde_json::Value::Number(serde_json::Number::from(prev + delta)), + ); }; match m.value() { - MetricValue::PruningMetrics { name, pruning_metrics } => { - add(map, format!("{}_pruned", name), pruning_metrics.pruned() as i64); - add(map, format!("{}_matched", name), pruning_metrics.matched() as i64); + MetricValue::PruningMetrics { + name, + pruning_metrics, + } => { + add( + map, + format!("{}_pruned", name), + pruning_metrics.pruned() as i64, + ); + add( + map, + format!("{}_matched", name), + pruning_metrics.matched() as i64, + ); } MetricValue::StartTimestamp(_) => { let v = m.value().as_usize() as i64; - let prev = map.get("start_timestamp").and_then(|v| v.as_i64()).unwrap_or(i64::MAX); + let prev = map + .get("start_timestamp") + .and_then(|v| v.as_i64()) + .unwrap_or(i64::MAX); if v > 0 && v < prev { - map.insert("start_timestamp".to_string(), serde_json::Value::Number(serde_json::Number::from(v))); + map.insert( + "start_timestamp".to_string(), + serde_json::Value::Number(serde_json::Number::from(v)), + ); } } MetricValue::EndTimestamp(_) => { let v = m.value().as_usize() as i64; - let prev = map.get("end_timestamp").and_then(|v| v.as_i64()).unwrap_or(0); + let prev = map + .get("end_timestamp") + .and_then(|v| v.as_i64()) + .unwrap_or(0); if v > prev { - map.insert("end_timestamp".to_string(), serde_json::Value::Number(serde_json::Number::from(v))); + map.insert( + "end_timestamp".to_string(), + serde_json::Value::Number(serde_json::Number::from(v)), + ); } } - MetricValue::Ratio { .. } | MetricValue::Gauge { .. } | MetricValue::CurrentMemoryUsage(_) => { + MetricValue::Ratio { .. } + | MetricValue::Gauge { .. } + | MetricValue::CurrentMemoryUsage(_) => { let name = m.value().name().to_string(); let value = m.value().as_usize() as i64; - map.insert(name, serde_json::Value::Number(serde_json::Number::from(value))); + map.insert( + name, + serde_json::Value::Number(serde_json::Number::from(value)), + ); } other => { add(map, other.name().to_string(), other.as_usize() as i64); @@ -265,6 +313,8 @@ pub struct DataFusionRuntime { pub runtime_env: datafusion::execution::runtime_env::RuntimeEnv, pub custom_cache_manager: Option, pub dynamic_limit_handle: DynamicLimitHandle, + pub liquid_cache_optimizer: + Option>, } /// Per-file metadata passed from Java at shard view creation time. @@ -369,7 +419,22 @@ impl DataFusionRuntime { runtime_env, custom_cache_manager: None, dynamic_limit_handle: handle, + liquid_cache_optimizer: None, + } + } + + pub fn apply_liquid_cache_optimizers( + &self, + mut builder: SessionStateBuilder, + ) -> SessionStateBuilder { + if let Some(ref optimizer) = self.liquid_cache_optimizer { + builder = builder.with_physical_optimizer_rule(optimizer.clone()); } + builder + } + + pub fn has_liquid_cache(&self) -> bool { + self.liquid_cache_optimizer.is_some() } } @@ -463,6 +528,9 @@ pub fn create_global_runtime( cache_manager_ptr: i64, spill_dir: &str, spill_limit: i64, + liquid_cache_enabled: bool, + liquid_cache_size: i64, + liquid_cache_eviction_policy: &str, ) -> Result { if memory_pool_limit < 0 { return Err(DataFusionError::Configuration(format!( @@ -488,11 +556,7 @@ pub fn create_global_runtime( crate::memory_guard::mark_spill_disabled(); DiskManagerBuilder::default().with_mode(DiskManagerMode::Disabled) } else { - let effective_spill_limit = if spill_limit == 0 { - resolve_dynamic_spill_limit(spill_dir) - } else { - spill_limit as u64 - }; + let effective_spill_limit = spill_limit as u64; // Wipe leaked entries from a prior non-graceful shutdown. // @@ -517,7 +581,9 @@ pub fn create_global_runtime( let msg = format!( "Failed to enumerate spill directory {} (io kind={:?}): {}. \ Verify the process owns the spill directory before restarting.", - spill_dir, e.kind(), e + spill_dir, + e.kind(), + e ); log::error!("{}", msg); DataFusionError::Configuration(msg) @@ -526,7 +592,9 @@ pub fn create_global_runtime( let entry = entry.map_err(|e| { let msg = format!( "Failed to read spill directory entry in {} (io kind={:?}): {}", - spill_dir, e.kind(), e + spill_dir, + e.kind(), + e ); log::error!("{}", msg); DataFusionError::Configuration(msg) @@ -551,7 +619,10 @@ pub fn create_global_runtime( let msg = format!( "Failed to rename leaked spill entry {} -> {} (io kind={:?}): {}. \ Verify the process owns the spill directory before restarting.", - path.display(), stale.display(), e.kind(), e + path.display(), + stale.display(), + e.kind(), + e ); log::error!("{}", msg); return Err(DataFusionError::Configuration(msg)); @@ -661,7 +732,22 @@ pub fn create_global_runtime( .with_cache_manager(cache_manager_config) .build()?; - let runtime = DataFusionRuntime { runtime_env, custom_cache_manager, dynamic_limit_handle }; + let liquid_cache_optimizer = if liquid_cache_enabled { + let liquid_runtime = crate::liquid_cache::LiquidOnlyRuntime::init( + liquid_cache_size as u64, + liquid_cache_eviction_policy, + )?; + Some(liquid_runtime.optimizer()) + } else { + None + }; + + let runtime = DataFusionRuntime { + runtime_env, + custom_cache_manager, + dynamic_limit_handle, + liquid_cache_optimizer, + }; Ok(Box::into_raw(Box::new(runtime)) as i64) } @@ -714,7 +800,10 @@ pub unsafe fn get_memory_pool_stats(ptr: i64, out_ptr: *mut i64) { /// `ptr` must be a valid pointer returned by `create_global_runtime`. pub unsafe fn set_memory_pool_limit(ptr: i64, new_limit: i64) -> Result<(), String> { if new_limit < 0 { - return Err(format!("Memory pool limit must be non-negative, got {}", new_limit)); + return Err(format!( + "Memory pool limit must be non-negative, got {}", + new_limit + )); } let runtime = &*(ptr as *const DataFusionRuntime); runtime.dynamic_limit_handle.set_limit(new_limit as usize); @@ -734,7 +823,10 @@ static REDUCE_TARGET_PARTITIONS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(4); pub fn set_reduce_target_partitions(value: i64) { - REDUCE_TARGET_PARTITIONS.store(value.max(1).min(32) as usize, std::sync::atomic::Ordering::Release); + REDUCE_TARGET_PARTITIONS.store( + value.max(1).min(32) as usize, + std::sync::atomic::Ordering::Release, + ); } pub fn get_reduce_target_partitions() -> usize { @@ -757,7 +849,8 @@ pub fn get_reduce_target_partitions() -> usize { /// `filenames` are kept in the order supplied by the caller. /// /// `store_ptr`: 0 = use default LocalFileSystem (hot path), -/// >0 = Box> pointer (routes reads through TieredObjectStore). +/// >0 = `Box>` pointer (routes reads through TieredObjectStore; +/// trait upcasts to `dyn ObjectStore` for DataFusion APIs that take `Arc`). pub fn create_reader( table_path: &str, filenames: Vec, @@ -786,15 +879,31 @@ pub fn create_reader( .map_err(|e| DataFusionError::Execution(format!("Invalid table path: {}", e)))?; // Resolve the object store: if store_ptr > 0, clone the Arc from the boxed pointer. + // Pointer type is `Arc` (since 2026-06); trait-upcast to + // `Arc` for the DataFusion APIs below. // Otherwise use default LocalFileSystem. let store: Arc = if store_ptr > 0 { - let boxed = unsafe { &*(store_ptr as *const Arc) }; - Arc::clone(boxed) + let boxed = unsafe { + &*(store_ptr + as *const Arc< + dyn opensearch_tiered_storage::tiered_object_store::MetadataCachingStore, + >) + }; + // Bind first, then trait-upcast at the let-binding boundary + // (Arc::clone alone can't infer the supertrait return type). + let mc_arc: Arc = + Arc::clone(boxed); + mc_arc } else { let default_rt = RuntimeEnvBuilder::new().build()?; default_rt.object_store(&table_url)? }; + // A Java-supplied store (store_ptr > 0) is a remote/warm store: fetch the whole + // page-index region so query range keys match eager warm-population (warm hits). + // The default LocalFileSystem has no warm tier → keep the narrow scoped fetch. + crate::cache::page_index::set_whole_region_fetch_enabled(store_ptr > 0); + let object_metas = tokio_rt_manager.io_runtime.block_on(create_object_metas( store.as_ref(), table_path, @@ -841,59 +950,31 @@ pub async unsafe fn execute_query( manager: &RuntimeManager, context_id: i64, query_config: crate::datafusion_query_config::DatafusionQueryConfig, + internal_search: crate::datafusion_query_config::InternalSearch, ) -> Result { let shard_view = &*(shard_view_ptr as *const ShardView); let runtime = &*(runtime_ptr as *const DataFusionRuntime); let cpu_executor = manager.cpu_executor(); - // Create per-query context — auto-registers in the global registry + // Create per-query context (auto-registers in the global registry) and extract + // its per-query memory pool overlaying the global pool. let global_pool = runtime.runtime_env.memory_pool.clone(); - let mut query_context = QueryTrackingContext::new(context_id, global_pool.clone(), query_tracker::QueryType::Shard); - - let query_memory_pool = query_context - .memory_pool() - .map(|p| p as Arc); - - // Check disk pressure: when spill is on and disk is dangerously low, reduce - // parallelism so each query produces less spill volume. When spill is off, disk - // health is irrelevant — there is no spill to throttle, so parallelism stays at - // the configured value. One statvfs call (~1µs) only on the enabled path. - let disk_capped_partitions = match crate::memory_guard::per_query_spill_budget() { - crate::memory_guard::SpillBudget::Critical => 1, - crate::memory_guard::SpillBudget::Disabled - | crate::memory_guard::SpillBudget::Available(_) => query_config.target_partitions, - }; - - // Acquire memory budget: reserve phantom for untracked memory. - // Best-effort from cached metadata (zero I/O). If not cached, skip budget - // — first query warms the cache, subsequent queries benefit. - let (effective_config, phantom_corrector) = { - let mut cfg = query_config.clone(); - cfg.target_partitions = disk_capped_partitions; - let corrector = if let Some(budget) = try_acquire_budget_from_cache(shard_view, runtime, &global_pool, &cfg) { - cfg.target_partitions = budget.target_partitions; - cfg.batch_size = budget.batch_size; - let batches_in_pipeline = budget.target_partitions * 3 + 2; // partitions × multiplier + output channel(2) - let estimated_batch_bytes = if budget.phantom_bytes > 0 && batches_in_pipeline > 0 { - budget.phantom_bytes / batches_in_pipeline - } else { - cfg.batch_size * 100 // fallback - }; - let corrector = Arc::new(crate::phantom_corrector::PhantomCorrector::new_from_metadata( - budget.phantom_bytes, estimated_batch_bytes, batches_in_pipeline, - )); - query_context.set_phantom_reservation(budget.phantom_reservation); - Some(query_context.set_phantom_corrector(corrector)) - } else { - None - }; - (cfg, corrector) - }; + let (mut query_context, query_memory_pool) = + new_query_tracking_context(context_id, global_pool.clone(), QueryType::Shard); + + // Apply disk-pressure capping + memory budget, attaching phantom + // reservation/corrector to the query context. + let (effective_config, phantom_corrector) = resolve_effective_config( + shard_view, + runtime, + &global_pool, + &query_config, + &mut query_context, + ); - // Peek at plan bytes for routing signals. - // - is_indexed: index_filter UDF present (indexed query path) - // - has_row_id: __row_id__ column requested (QTF query phase) - let (is_indexed, has_row_id) = inspect_plan_bytes(plan_bytes); + // Route to the indexed executor when the plan has an index_filter UDF or + // requests __row_id__ (QTF query phase); otherwise the ListingTable path. + let use_indexed = use_indexed_path(plan_bytes); // Register cancellation token. let token = query_tracker::get_cancellation_token(context_id); @@ -904,30 +985,20 @@ pub async unsafe fn execute_query( // in single-JVM test topologies where coordinator and data node share a gate. let query_future = async move { - // Routing logic: - // 1. Indexed query (has index_filter) → always indexed path - // 2. Has __row_id__ but not indexed (non-indexed + sort) → consult QueryStrategy - // - ListingTable → vanilla path with ShardTableProvider + ProjectRowIdOptimizer - // - IndexedPredicateOnly → indexed path (position-based row IDs) - // - None → vanilla path (no row ID computation) - // 3. Neither → vanilla path - let use_indexed = is_indexed - || (has_row_id && effective_config.query_strategy != crate::datafusion_query_config::QueryStrategy::ListingTable); - if use_indexed { - let qc = Arc::new(effective_config); - crate::indexed_executor::execute_indexed_query( + execute_indexed_query( plan_bytes.to_vec(), table_name.to_string(), shard_view, runtime, cpu_executor, query_memory_pool, - qc, + effective_config, context_id, - ).await + ) + .await } else { - crate::query_executor::execute_query( + query_executor::execute_query( shard_view.table_path.clone(), shard_view.object_metas.clone(), table_name.to_string(), @@ -941,7 +1012,9 @@ pub async unsafe fn execute_query( phantom_corrector, &shard_view.sort_fields, &shard_view.sort_orders, - ).await + internal_search, + ) + .await } }; @@ -980,17 +1053,17 @@ pub async unsafe fn fetch_by_row_ids( ) -> Result { use crate::indexed_table::row_selection::build_row_selection_with_min_skip_run; use crate::indexed_table::segment_info::build_segments; - use crate::query_executor::{build_query_runtime_env, store_url_from_table_path, wrap_stream_as_handle}; + use crate::query_executor::{store_url_from_table_path, wrap_stream_as_handle}; // ── 1. Build RuntimeEnv + SessionContext ── - let runtime_env = build_query_runtime_env(runtime, &shard_view.table_path, shard_view.object_metas.as_ref())?; - - // Register shard-specific object store on file:// scheme for this query. - runtime_env.register_object_store( - &url::Url::parse("file://").unwrap(), + let runtime_env = build_query_runtime_env_with_store( + runtime, + &shard_view.table_path, + shard_view.object_metas.as_ref(), Arc::clone(&shard_view.store), - ); + None, + )?; let mut config = SessionConfig::new(); config.options_mut().execution.parquet.pushdown_filters = true; @@ -1005,8 +1078,15 @@ pub async unsafe fn fetch_by_row_ids( // ── 2. Build ShardFileInfo with ParquetAccessPlan per file ── - let store = ctx.state().runtime_env().object_store(&shard_view.table_path)?; - let metadata_cache = ctx.state().runtime_env().cache_manager.get_file_metadata_cache(); + let store = ctx + .state() + .runtime_env() + .object_store(&shard_view.table_path)?; + let metadata_cache = ctx + .state() + .runtime_env() + .cache_manager + .get_file_metadata_cache(); let (segments, _schema) = build_segments( &ctx.state(), Arc::clone(&store), @@ -1015,12 +1095,15 @@ pub async unsafe fn fetch_by_row_ids( metadata_cache, &shard_view.sort_fields, ) - .await - .map_err(DataFusionError::Execution)?; + .await + .map_err(DataFusionError::Execution)?; // Distribute global row_ids to per-file local positions. // Note: Java validates non-empty + ascending row_ids before the FFM call; we don't repeat that here. - debug_assert!(!segments.is_empty(), "fetch_by_row_ids: build_segments returned empty for non-empty shard view"); + debug_assert!( + !segments.is_empty(), + "fetch_by_row_ids: build_segments returned empty for non-empty shard view" + ); let mut per_segment: HashMap = HashMap::new(); for &gid in &row_ids { debug_assert!(gid >= 0, "fetch_by_row_ids: negative row id {}", gid); @@ -1031,7 +1114,10 @@ pub async unsafe fn fetch_by_row_ids( debug_assert!( (gid as u64) >= seg.global_base && (gid as u64) < seg.global_base + seg.max_doc as u64, "fetch_by_row_ids: row id {} out of bounds for segment {} (base={}, max_doc={})", - gid, seg_idx, seg.global_base, seg.max_doc + gid, + seg_idx, + seg.global_base, + seg.max_doc ); let local_pos = (gid as u64 - seg.global_base) as u32; per_segment.entry(seg_idx).or_default().insert(local_pos); @@ -1052,9 +1138,8 @@ pub async unsafe fn fetch_by_row_ids( .map(|pos| pos - rg_start) .collect(); if !rg_bitmap.is_empty() { - let selection = build_row_selection_with_min_skip_run( - &rg_bitmap, rg.num_rows as usize, 1, - ); + let selection = + build_row_selection_with_min_skip_run(&rg_bitmap, rg.num_rows as usize, 1); plan.set(rg.index, RowGroupAccess::Selection(selection)); } } @@ -1075,10 +1160,14 @@ pub async unsafe fn fetch_by_row_ids( // ── 3. Register ShardTableProvider ── let store_url = store_url_from_table_path(&shard_view.table_path)?; - let listing_options = datafusion::datasource::listing::ListingOptions::new( - Arc::new(datafusion::datasource::file_format::parquet::ParquetFormat::new()) - ).with_file_extension(".parquet").with_collect_stat(true); - let resolved_schema = listing_options.infer_schema(&ctx.state(), &shard_view.table_path).await?; + let listing_options = datafusion::datasource::listing::ListingOptions::new(Arc::new( + datafusion::datasource::file_format::parquet::ParquetFormat::new(), + )) + .with_file_extension(".parquet") + .with_collect_stat(true); + let resolved_schema = listing_options + .infer_schema(&ctx.state(), &shard_view.table_path) + .await?; let provider = Arc::new(ShardTableProvider::new(ShardTableConfig { file_schema: resolved_schema, @@ -1093,10 +1182,15 @@ pub async unsafe fn fetch_by_row_ids( // each column verbatim except __row_id__, which we replace with the synthesized // expression. This preserves caller column order and guarantees a single __row_id__ // column in the result. - let projection = columns.iter() + let projection = columns + .iter() .map(|c| { if c == crate::ROW_ID_COLUMN_NAME { - format!("(\"{}\" + \"row_base\") AS \"{}\"", crate::ROW_ID_COLUMN_NAME, crate::ROW_ID_COLUMN_NAME) + format!( + "(\"{}\" + \"row_base\") AS \"{}\"", + crate::ROW_ID_COLUMN_NAME, + crate::ROW_ID_COLUMN_NAME + ) } else { format!("\"{}\"", c) } @@ -1110,7 +1204,10 @@ pub async unsafe fn fetch_by_row_ids( // Post-condition: returned stream schema must contain __row_id__ plus every requested column. // Catches drift if SQL synthesis or the optimizer ever drops a projection silently. - debug_assert!(assert_fetch_result_schema(df_stream.schema().as_ref(), &columns)); + debug_assert!(assert_fetch_result_schema( + df_stream.schema().as_ref(), + &columns + )); // ── 5. Wrap and return ── @@ -1118,11 +1215,14 @@ pub async unsafe fn fetch_by_row_ids( // monotonically nondecreasing across the entire stream. target_partitions=1 // means a single ordered execution, so the check is global, not per-batch only. let df_stream = ascending_row_id_check_stream(df_stream); - Ok(wrap_stream_as_handle(df_stream, manager.cpu_executor(), runtime, context_id)) + Ok(wrap_stream_as_handle( + df_stream, + manager.cpu_executor(), + runtime, + context_id, + )) } -/// it; the failure mode is documented here to keep the dispatch contract -/// explicit. /// Resolve the dynamic spill limit based on available disk space. /// Uses 80% of available space on the spill directory's filesystem. /// Falls back to 8GB if disk space cannot be determined. @@ -1137,29 +1237,39 @@ fn resolve_dynamic_spill_limit(spill_dir: &str) -> u64 { let limit = (available as f64 * FRACTION) as u64; log::info!( "Dynamic spill limit: {} bytes (80% of {} available on {})", - limit, available, spill_dir + limit, + available, + spill_dir ); limit } None => { log::warn!( "Could not determine disk space for '{}', using fallback {}GB", - spill_dir, FALLBACK / (1024 * 1024 * 1024) + spill_dir, + FALLBACK / (1024 * 1024 * 1024) ); FALLBACK } } } -/// Inspect substrait plan bytes for routing signals. -/// Returns (has_index_filter, has_row_id). -fn inspect_plan_bytes(plan_bytes: &[u8]) -> (bool, bool) { +/// Whether a shard query routes to the indexed executor (vs the ListingTable path), +/// decided by scanning the substrait plan bytes for two needles: the `index_filter` +/// UDF name and the `__row_id__` column name. Either one → indexed path. +/// +/// This is a cheap byte-substring scan, not a parse. A false positive on +/// `index_filter` takes the indexed path and then fails in `execute_indexed_query` +/// when `classify_filter` returns `None` (no automatic retry on the ListingTable +/// path) — unreachable in practice because the needle is not a valid DataFusion +/// identifier a plan would otherwise contain. +fn use_indexed_path(plan_bytes: &[u8]) -> bool { const INDEX_FILTER: &[u8] = b"index_filter"; const ROW_ID: &[u8] = crate::ROW_ID_COLUMN_NAME.as_bytes(); - ( - plan_bytes.windows(INDEX_FILTER.len()).any(|w| w == INDEX_FILTER), - plan_bytes.windows(ROW_ID.len()).any(|w| w == ROW_ID), - ) + plan_bytes + .windows(INDEX_FILTER.len()) + .any(|w| w == INDEX_FILTER) + || plan_bytes.windows(ROW_ID.len()).any(|w| w == ROW_ID) } /// Best-effort budget acquisition from cached parquet metadata. @@ -1168,17 +1278,55 @@ fn inspect_plan_bytes(plan_bytes: &[u8]) -> (bool, bool) { /// If cached: extracts the schema + measured row bytes, acquires budget. /// If not cached: returns None (first query — skip budget, warm cache). /// Zero I/O in all cases. -/// Best-effort budget acquisition from cached parquet metadata. -/// -/// Looks up the first file's ParquetMetaData from the file metadata cache. -/// If cached: extracts the schema + measured row bytes, acquires budget. -/// If not cached: returns None (first query — skip budget, warm cache). -/// Zero I/O in all cases. +fn resolve_effective_config( + shard_view: &ShardView, + runtime: &DataFusionRuntime, + global_pool: &Arc, + query_config: &DatafusionQueryConfig, + query_context: &mut QueryTrackingContext, +) -> (Arc, Option>) { + // Disk pressure: when spill is on and disk is dangerously low, cap parallelism + // to 1 so each query produces less spill volume. When spill is off, disk health + // is irrelevant — no spill to throttle. One statvfs call (~1µs) only when enabled. + let disk_capped_partitions = match per_query_spill_budget() { + SpillBudget::Critical => 1, + SpillBudget::Disabled | SpillBudget::Available(_) => query_config.target_partitions, + }; + + // Acquire memory budget: reserve phantom for untracked memory. Best-effort from + // cached metadata (zero I/O); if not cached, skip budget (first query warms the + // cache, subsequent queries benefit). + let mut cfg = query_config.clone(); + cfg.target_partitions = disk_capped_partitions; + let corrector = if let Some(budget) = + try_acquire_budget_from_cache(shard_view, runtime, global_pool, &cfg) + { + cfg.target_partitions = budget.target_partitions; + cfg.batch_size = budget.batch_size; + let batches_in_pipeline = budget.target_partitions * 3 + 2; // partitions × multiplier + output channel(2) + let estimated_batch_bytes = if budget.phantom_bytes > 0 && batches_in_pipeline > 0 { + budget.phantom_bytes / batches_in_pipeline + } else { + cfg.batch_size * 100 // fallback + }; + let corrector = Arc::new(PhantomCorrector::new_from_metadata( + budget.phantom_bytes, + estimated_batch_bytes, + batches_in_pipeline, + )); + query_context.set_phantom_reservation(budget.phantom_reservation); + Some(query_context.set_phantom_corrector(corrector)) + } else { + None + }; + (Arc::new(cfg), corrector) +} + fn try_acquire_budget_from_cache( shard_view: &ShardView, runtime: &DataFusionRuntime, - pool: &Arc, - config: &crate::datafusion_query_config::DatafusionQueryConfig, + pool: &Arc, + config: &DatafusionQueryConfig, ) -> Option { use datafusion::execution::cache::CacheAccessor; use parquet::arrow::parquet_to_arrow_schema; @@ -1193,13 +1341,18 @@ fn try_acquire_budget_from_cache( let cached = cache.get(&first_meta.location)?; // Downcast Arc to ParquetMetaData - let parquet_meta = cached.file_metadata.as_any().downcast_ref::()?; + let parquet_meta = cached + .file_metadata + .as_any() + .downcast_ref::()?; // Extract Arrow schema (zero I/O — just struct conversion) let schema = parquet_to_arrow_schema( parquet_meta.file_metadata().schema_descr(), parquet_meta.file_metadata().key_value_metadata(), - ).ok().map(Arc::new)?; + ) + .ok() + .map(Arc::new)?; // Acquire budget using measured row bytes from metadata crate::query_budget::acquire_budget_from_metadata( @@ -1208,7 +1361,8 @@ fn try_acquire_budget_from_cache( parquet_meta, config.target_partitions, config.batch_size, - ).ok() + ) + .ok() } /// Returns the Arrow schema for the given stream as a heap-allocated FFI_ArrowSchema pointer. @@ -1236,24 +1390,27 @@ pub unsafe fn stream_get_schema(stream_ptr: i64) -> Result /// # Safety /// `stream_ptr` must be a valid, non-zero pointer. Must not be called concurrently /// on the same stream. -pub async unsafe fn stream_next( - stream_ptr: i64, -) -> Result { +pub async unsafe fn stream_next(stream_ptr: i64) -> Result { let handle = &mut *(stream_ptr as *mut QueryStreamHandle); let token = query_tracker::get_cancellation_token(handle._query_tracking_context.context_id()); // Fetch the next batch (cancellation-aware) - let result = cancellation::cancellable_or( - token.as_ref(), - None, - async { handle.stream.try_next().await.map_err(|e: DataFusionError| e) }, - ).await + let result = cancellation::cancellable_or(token.as_ref(), None, async { + handle + .stream + .try_next() + .await + .map_err(|e: DataFusionError| e) + }) + .await .map_err(|e| DataFusionError::Execution(e))?; match result { Some(batch) => { // Apply pending phantom correction from the self-correcting budget. - handle._query_tracking_context.apply_pending_phantom_correction(); + handle + ._query_tracking_context + .apply_pending_phantom_correction(); let batch = if handle.has_views { compact_string_view_columns(batch) @@ -1272,23 +1429,28 @@ pub async unsafe fn stream_next( /// Prevents sliced StringView batches from carrying full backing buffers across FFI. fn compact_string_view_columns(batch: RecordBatch) -> RecordBatch { let schema = batch.schema(); - let needs_compaction = batch - .columns() - .iter() - .zip(schema.fields().iter()) - .any(|(col, field)| match field.data_type() { - DataType::Utf8View => { - let view: &arrow_array::StringViewArray = col.as_any().downcast_ref() - .expect("column must be StringViewArray when schema declares Utf8View"); - view_needs_gc(view.data_buffers(), view.total_buffer_bytes_used()) - } - DataType::BinaryView => { - let view: &arrow_array::BinaryViewArray = col.as_any().downcast_ref() - .expect("column must be BinaryViewArray when schema declares BinaryView"); - view_needs_gc(view.data_buffers(), view.total_buffer_bytes_used()) - } - _ => false, - }); + let needs_compaction = + batch + .columns() + .iter() + .zip(schema.fields().iter()) + .any(|(col, field)| match field.data_type() { + DataType::Utf8View => { + let view: &arrow_array::StringViewArray = col + .as_any() + .downcast_ref() + .expect("column must be StringViewArray when schema declares Utf8View"); + view_needs_gc(view.data_buffers(), view.total_buffer_bytes_used()) + } + DataType::BinaryView => { + let view: &arrow_array::BinaryViewArray = col + .as_any() + .downcast_ref() + .expect("column must be BinaryViewArray when schema declares BinaryView"); + view_needs_gc(view.data_buffers(), view.total_buffer_bytes_used()) + } + _ => false, + }); if !needs_compaction { return batch; } @@ -1298,12 +1460,16 @@ fn compact_string_view_columns(batch: RecordBatch) -> RecordBatch { .zip(schema.fields().iter()) .map(|(col, field)| match field.data_type() { DataType::Utf8View => { - let view: &arrow_array::StringViewArray = col.as_any().downcast_ref() + let view: &arrow_array::StringViewArray = col + .as_any() + .downcast_ref() .expect("column must be StringViewArray when schema declares Utf8View"); Arc::new(view.gc()) as Arc } DataType::BinaryView => { - let view: &arrow_array::BinaryViewArray = col.as_any().downcast_ref() + let view: &arrow_array::BinaryViewArray = col + .as_any() + .downcast_ref() .expect("column must be BinaryViewArray when schema declares BinaryView"); Arc::new(view.gc()) as Arc } @@ -1333,6 +1499,10 @@ pub unsafe fn stream_close(stream_ptr: i64) { return; } let mut handle = Box::from_raw(stream_ptr as *mut QueryStreamHandle); + let context_id = handle._query_tracking_context.context_id(); + // Grab the CPU runtime handle BEFORE drop — drop removes the tracker from + // the registry, making it unreachable for flush_cpu_runtime. + let cpu_rt_handle = query_tracker::take_cpu_runtime_handle(context_id); // Dropping the handle aborts the CPU task but does not wait for it; on the coordinator-reduce // path that task still holds Java-borrowed input batches. Wait for it to fully unwind (signal // fires once its batches drop) before returning, so the caller's allocator close is safe. @@ -1345,7 +1515,9 @@ pub unsafe fn stream_close(stream_ptr: i64) { // may leak the borrow — log it so a recurring timeout is visible rather than silent. let timed_out = mgr .io_runtime - .block_on(async { tokio::time::timeout(std::time::Duration::from_secs(30), rx).await }) + .block_on(async { + tokio::time::timeout(std::time::Duration::from_secs(30), rx).await + }) .is_err(); if timed_out { native_bridge_common::log_error!( @@ -1355,6 +1527,13 @@ pub unsafe fn stream_close(stream_ptr: i64) { } } } + // After dropping the QueryStreamHandle (which drops CrossRtStream → JoinSet → + // aborts the CPU task), flush the runtime so the cascading abort of + // pull_from_input tasks (holding GroupValues buffers) is processed now rather + // than lingering in tokio's deferred drop queue on an idle runtime. + if let Some(rt) = cpu_rt_handle { + query_tracker::flush_cpu_runtime_with_handle(&rt, context_id); + } } /// Fires the cancellation token for the given context_id. @@ -1363,6 +1542,23 @@ pub fn cancel_query(context_id: i64) { query_tracker::cancel_query(context_id); } +/// Clears all caching layers: Liquid Cache (in-memory) and DataFusion +/// metadata caches (parquet footers + column statistics). +/// +/// # Safety +/// `runtime_ptr` must be 0 or a valid pointer from `create_global_runtime`. +pub unsafe fn clear_liquid_cache(runtime_ptr: i64) { + crate::liquid_cache::LiquidOnlyRuntime::reset_cache_if_initialized(); + + if runtime_ptr == 0 { + return; + } + let runtime = &*(runtime_ptr as *const DataFusionRuntime); + if let Some(ref cache_manager) = runtime.custom_cache_manager { + cache_manager.clear_all(); + } +} + /// Converts SQL to Substrait plan bytes (test only). /// /// # Safety @@ -1396,7 +1592,8 @@ pub unsafe fn sql_to_substrait( }, CachedFileList::new(object_metas.as_ref().clone()), ); - let runtime_env = crate::query_executor::query_runtime_env_builder(runtime, list_file_cache).build()?; + let runtime_env = + crate::query_executor::query_runtime_env_builder(runtime, list_file_cache).build()?; let state = SessionStateBuilder::new() .with_config(SessionConfig::new()) @@ -1458,7 +1655,10 @@ fn derive_schema_from_partial_plan( use substrait::proto::{read_rel::ReadType, Plan}; let plan = Plan::decode(substrait_bytes).map_err(|e| { - DataFusionError::Execution(format!("derive_schema_from_partial_plan: decode failed: {}", e)) + DataFusionError::Execution(format!( + "derive_schema_from_partial_plan: decode failed: {}", + e + )) })?; let state = SessionStateBuilder::new() @@ -1485,9 +1685,10 @@ fn derive_schema_from_partial_plan( continue; }; let table_name = nt.names.last().cloned().unwrap_or_default(); - let base_schema = read.base_schema.as_ref().ok_or_else(|| { - DataFusionError::Execution("ReadRel missing base_schema".to_string()) - })?; + let base_schema = read + .base_schema + .as_ref() + .ok_or_else(|| DataFusionError::Execution("ReadRel missing base_schema".to_string()))?; let df_schema = from_substrait_named_struct(&consumer, base_schema)?; let arrow_schema = df_schema.as_arrow().clone(); @@ -1524,33 +1725,56 @@ fn derive_schema_from_partial_plan( // Extract the substrait-declared output names from Plan.Root.names — these are the // user-facing aliases Java wrote (e.g. "RegionID", "u") and must match what the // FINAL substrait's Read.base_schema declares on the coordinator side. - let declared_names: Vec = plan.relations.iter().find_map(|pr| { - if let Some(substrait::proto::plan_rel::RelType::Root(rr)) = pr.rel_type.as_ref() { - Some(rr.names.clone()) - } else { - None - } - }).unwrap_or_default(); + let declared_names: Vec = plan + .relations + .iter() + .find_map(|pr| { + if let Some(substrait::proto::plan_rel::RelType::Root(rr)) = pr.rel_type.as_ref() { + Some(rr.names.clone()) + } else { + None + } + }) + .unwrap_or_default(); let logical_plan = futures::executor::block_on(from_substrait_plan(&session_state, &plan))?; - let physical_plan = futures::executor::block_on(session_state.create_physical_plan(&logical_plan))?; + let physical_plan = + futures::executor::block_on(session_state.create_physical_plan(&logical_plan))?; - // Engine-native-merge (HLL): Partial has Binary fields that differ from the top (Int64). - // Use Partial schema + Root.names so coordinator sees the correct Binary wire type. - // All other plans: use top schema directly (matches main behavior). + // Engine-native-merge: Partial state types differ from Final output (Binary HLL sketches, + // or List state for sub-32-bit bitmap accumulators). Use Partial schema + Root.names so + // the coordinator sees the correct wire type. if let Some(partial_schema) = crate::agg_mode::partial_aggregate_schema(&physical_plan) { - let has_binary = partial_schema.fields().iter().any(|f| matches!(f.data_type(), arrow::datatypes::DataType::Binary)); - if has_binary && !declared_names.is_empty() && declared_names.len() == partial_schema.fields().len() { + let has_nontrivial_state = partial_schema.fields().iter().any(|f| { + matches!( + f.data_type(), + arrow::datatypes::DataType::Binary | arrow::datatypes::DataType::List(_) + ) + }); + if has_nontrivial_state + && !declared_names.is_empty() + && declared_names.len() == partial_schema.fields().len() + { use arrow::datatypes::{Field, Schema}; let coerced = crate::schema_coerce::coerce_inferred_schema(partial_schema); - let fields: Vec = coerced.fields().iter().zip(declared_names.iter()) - .map(|(f, name)| Field::new(name.as_str(), f.data_type().clone(), f.is_nullable()) - .with_metadata(f.metadata().clone())) + let fields: Vec = coerced + .fields() + .iter() + .zip(declared_names.iter()) + .map(|(f, name)| { + Field::new(name.as_str(), f.data_type().clone(), f.is_nullable()) + .with_metadata(f.metadata().clone()) + }) .collect(); - return Ok(Arc::new(Schema::new_with_metadata(fields, coerced.metadata().clone()))); + return Ok(Arc::new(Schema::new_with_metadata( + fields, + coerced.metadata().clone(), + ))); } } - Ok(crate::schema_coerce::coerce_inferred_schema(physical_plan.schema())) + Ok(crate::schema_coerce::coerce_inferred_schema( + physical_plan.schema(), + )) } /// Encodes a Schema as Arrow IPC stream-format bytes (a schema-only message @@ -1672,11 +1896,18 @@ pub(crate) fn first_named_table_name(plan_bytes: &[u8]) -> Option { } /// Extracts the `base_schema` NamedStruct from the plan's first ReadRel matching `table_name`. -pub(crate) fn base_schema_for_table(plan: &substrait::proto::Plan, table_name: &str) -> Option { +pub(crate) fn base_schema_for_table( + plan: &substrait::proto::Plan, + table_name: &str, +) -> Option { use substrait::proto::read_rel::ReadType; for read in collect_plan_reads(plan) { - let Some(ReadType::NamedTable(nt)) = read.read_type.as_ref() else { continue }; - if nt.names.last().map(String::as_str) != Some(table_name) { continue } + let Some(ReadType::NamedTable(nt)) = read.read_type.as_ref() else { + continue; + }; + if nt.names.last().map(String::as_str) != Some(table_name) { + continue; + } return read.base_schema.clone(); } None @@ -1753,7 +1984,11 @@ pub unsafe fn register_partition_stream( let current_partitions = session.target_partitions(); let current_phantom = session.phantom_size(); if let Some(budget) = crate::query_budget::try_grow_reduce_budget( - pool, &schema, batch_size, current_partitions, current_phantom, + pool, + &schema, + batch_size, + current_partitions, + current_phantom, )? { session.reduce_target_partitions(budget.target_partitions); session.set_phantom(budget.phantom_reservation); @@ -1791,7 +2026,11 @@ pub async unsafe fn execute_local_plan( // Per-query memory tracking — wraps the session's global pool. A // `context_id` of 0 disables tracking (pool is not consulted) and no // cancellation token is registered in the global QUERY_REGISTRY. - let query_context = QueryTrackingContext::new(context_id, session.memory_pool(), query_tracker::QueryType::Coordinator); + let query_context = QueryTrackingContext::new( + context_id, + session.memory_pool(), + query_tracker::QueryType::Coordinator, + ); let token = query_tracker::get_cancellation_token(context_id); // Race substrait planning + execution against the cancellation token so @@ -1810,15 +2049,23 @@ pub async unsafe fn execute_local_plan( // shape as `execute_query`, so existing `stream_next` / `stream_close` // drain this handle unchanged. Use the cancellable variant so the CPU // task can be aborted mid-execution when cancel_query fires. - let (cross_rt_stream, abort_handle, task_done) = - CrossRtStream::new_with_df_error_stream_cancellable(df_stream, manager.cpu_executor()); - if let Some(h) = abort_handle { - query_tracker::set_abort_handle(context_id, h); + let cpu_exec = manager.cpu_executor(); + let (cross_rt_stream, _abort_handle, task_done) = + CrossRtStream::new_with_df_error_stream_cancellable( + df_stream, + cpu_exec.clone(), + token.clone(), + ); + // Reduce path: cancel via the token only, do NOT register the abort handle — an abort() mid-send + // would skip the cross_rt drop+drain cleanup and leak the aggregate's in-flight GroupValues. + if let Some(rt) = cpu_exec.handle() { + query_tracker::set_cpu_runtime_handle(context_id, rt); } let wrapped = RecordBatchStreamAdapter::new(cross_rt_stream.schema(), cross_rt_stream); // Attach the teardown signal so stream_close releases borrowed input batches before allocator close. - let handle = QueryStreamHandle::new_with_plan(wrapped, query_context, permit, physical_plan).with_task_done(task_done); + let handle = QueryStreamHandle::new_with_plan(wrapped, query_context, permit, physical_plan) + .with_task_done(task_done); Ok(Box::into_raw(Box::new(handle)) as i64) } @@ -1846,7 +2093,12 @@ pub unsafe fn execute_local_prepared_plan( // so a `cancel_query(context_id)` call fires the token here too. // The token is held via the QueryStreamHandle's context and consulted by // stream_next on each batch pull. - let query_context = QueryTrackingContext::new(context_id, session.memory_pool(), query_tracker::QueryType::Coordinator); + let query_context = QueryTrackingContext::new( + context_id, + session.memory_pool(), + query_tracker::QueryType::Coordinator, + ); + let token = query_tracker::get_cancellation_token(context_id); // DataFusion's execute_stream is sync, but kicks off RepartitionExec / // stream channels that require a Tokio reactor. Enter the IO runtime's @@ -1854,10 +2106,16 @@ pub unsafe fn execute_local_prepared_plan( let _guard = manager.io_runtime.enter(); let df_stream = session.execute_prepared()?; - let (cross_rt_stream, abort_handle, task_done) = - CrossRtStream::new_with_df_error_stream_cancellable(df_stream, manager.cpu_executor()); - if let Some(h) = abort_handle { - query_tracker::set_abort_handle(context_id, h); + let cpu_exec = manager.cpu_executor(); + let (cross_rt_stream, _abort_handle, task_done) = + CrossRtStream::new_with_df_error_stream_cancellable( + df_stream, + cpu_exec.clone(), + token.clone(), + ); + // Prepared-reduce path: same as execute_local_plan — token-only cancel, no abort handle. + if let Some(rt) = cpu_exec.handle() { + query_tracker::set_cpu_runtime_handle(context_id, rt); } let wrapped = RecordBatchStreamAdapter::new(cross_rt_stream.schema(), cross_rt_stream); @@ -1964,7 +2222,8 @@ mod tests { // memory_guard SPILL_ENABLED flag off so per_query_spill_budget returns // Disabled (not Critical) — preventing the 1-partition clamp. let _guard = SPILL_GLOBALS_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let ptr = create_global_runtime(64 * 1024 * 1024, 0, "", 0).expect("runtime build"); + let ptr = create_global_runtime(64 * 1024 * 1024, 0, "", 0, false, 0, "lru") + .expect("runtime build"); assert!(ptr > 0); let runtime = unsafe { &*(ptr as *const DataFusionRuntime) }; assert!( @@ -1982,11 +2241,10 @@ mod tests { #[test] fn create_global_runtime_with_spill_dir_enables_disk_manager() { // Non-empty spill_dir takes the Directories(...) path. tmp_files_enabled() must - // be true so spill attempts succeed. Passing spill_limit=0 also exercises the - // dynamic-limit resolver (resolve_dynamic_spill_limit + set_spill_dir). The - // budget must NOT be Disabled — set_spill_dir flips SPILL_ENABLED on. Whether - // it's Available or Critical depends on the test host's free disk; both prove - // the enabled-path branch is taken. + // be true so spill attempts succeed. The budget must NOT be Disabled — + // set_spill_dir flips SPILL_ENABLED on. Whether it's Available or Critical + // depends on the test host's free disk; both prove the enabled-path branch + // is taken. // // Also doubles as a startup-cleanup regression check: drop a "leaked" sentinel // file in the directory before the call and assert it's gone after. @@ -1997,24 +2255,47 @@ mod tests { // Simulate a leaked spill file from a prior non-graceful shutdown. let sentinel = tmp.path().join("leaked_from_prior_run.tmp"); fs::write(&sentinel, b"stale spill data").expect("seed sentinel"); - assert!(sentinel.exists(), "sentinel must exist before runtime build"); + assert!( + sentinel.exists(), + "sentinel must exist before runtime build" + ); - let ptr = create_global_runtime(64 * 1024 * 1024, 0, spill_path, 0).expect("runtime build"); + let ptr = create_global_runtime( + 64 * 1024 * 1024, + 0, + spill_path, + 1024 * 1024 * 1024, + false, + 0, + "lru", + ) + .expect("runtime build"); assert!(ptr > 0); // Phase 1 renames the sentinel file to leaked_from_prior_run.tmp.stale // synchronously; phase 2 unlinks it asynchronously. The original name // is gone immediately; wait for the .stale name to disappear too. - assert!(!sentinel.exists(), "sentinel original name must be gone (renamed)"); + assert!( + !sentinel.exists(), + "sentinel original name must be gone (renamed)" + ); let stale = tmp.path().join("leaked_from_prior_run.tmp.stale"); let cleaned = wait_until(2000, || !stale.exists()); - assert!(cleaned, "background cleanup must remove the .stale sentinel within 2s"); + assert!( + cleaned, + "background cleanup must remove the .stale sentinel within 2s" + ); let runtime = unsafe { &*(ptr as *const DataFusionRuntime) }; assert!( runtime.runtime_env.disk_manager.tmp_files_enabled(), "expected DiskManagerMode::Directories when spill_dir is set" ); + assert_eq!( + runtime.runtime_env.disk_manager.max_temp_directory_size(), + 1024 * 1024 * 1024, + "DiskManager cap must equal the positive spill_limit passed in" + ); assert_ne!( crate::memory_guard::per_query_spill_budget(), crate::memory_guard::SpillBudget::Disabled, @@ -2051,24 +2332,48 @@ mod tests { assert!(top_file.exists()); assert!(nested_file.exists()); - let ptr = create_global_runtime(64 * 1024 * 1024, 0, spill_path, 0).expect("runtime build"); + let ptr = create_global_runtime( + 64 * 1024 * 1024, + 0, + spill_path, + 1024 * 1024 * 1024, + false, + 0, + "lru", + ) + .expect("runtime build"); assert!(ptr > 0); // Phase 1: original names gone (renamed to *.stale). - assert!(!top_file.exists(), "top-level file original name must be gone (renamed)"); - assert!(!outer_subdir.exists(), "original subdir name must be gone (renamed)"); + assert!( + !top_file.exists(), + "top-level file original name must be gone (renamed)" + ); + assert!( + !outer_subdir.exists(), + "original subdir name must be gone (renamed)" + ); // Phase 2: *.stale entries cleaned by the background thread. let stale_top = tmp.path().join("top.tmp.stale"); let stale_dir = tmp.path().join("subdir.stale"); let cleaned = wait_until(2000, || !stale_top.exists() && !stale_dir.exists()); - assert!(cleaned, "background cleanup must remove both .stale entries within 2s"); + assert!( + cleaned, + "background cleanup must remove both .stale entries within 2s" + ); assert!(!nested_file.exists(), "nested leaked file must be removed"); assert!(!nested_dir.exists(), "nested leaked subdir must be removed"); // Spill root itself must remain (cleanup wipes children only). - assert!(tmp.path().exists(), "spill root must be preserved across cleanup"); - assert!(tmp.path().is_dir(), "spill root must remain a directory after cleanup"); + assert!( + tmp.path().exists(), + "spill root must be preserved across cleanup" + ); + assert!( + tmp.path().is_dir(), + "spill root must remain a directory after cleanup" + ); unsafe { close_global_runtime(ptr) }; } @@ -2080,7 +2385,8 @@ mod tests { // accidental fs::remove_dir_all("") would error and break boot. This test // guards against future refactors that hoist the cleanup out of the else-branch. let _guard = SPILL_GLOBALS_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let ptr = create_global_runtime(64 * 1024 * 1024, 0, "", 0).expect("runtime build"); + let ptr = create_global_runtime(64 * 1024 * 1024, 0, "", 0, false, 0, "lru") + .expect("runtime build"); assert!(ptr > 0); unsafe { close_global_runtime(ptr) }; } @@ -2098,14 +2404,22 @@ mod tests { fs::write(&bad_path, b"not a directory").expect("seed regular file"); let bad_path_str = bad_path.to_str().expect("utf-8 path"); - let err = create_global_runtime(64 * 1024 * 1024, 0, bad_path_str, 0) + let err = create_global_runtime(64 * 1024 * 1024, 0, bad_path_str, 0, false, 0, "lru") .expect_err("create_global_runtime must fail when cleanup fails"); // Operator-facing message must include the offending path + io kind so the // logs are diagnostic without needing further investigation. let msg = err.to_string(); - assert!(msg.contains(bad_path_str), "error must reference the offending path; got: {}", msg); - assert!(msg.contains("io kind="), "error must include the io::ErrorKind for diagnosis; got: {}", msg); + assert!( + msg.contains(bad_path_str), + "error must reference the offending path; got: {}", + msg + ); + assert!( + msg.contains("io kind="), + "error must include the io::ErrorKind for diagnosis; got: {}", + msg + ); assert!( msg.contains("Failed to enumerate spill directory") || msg.contains("Failed to clear leaked spill entry"), @@ -2153,13 +2467,18 @@ mod tests { fs::write(&loose_file, b"loose top-level file").expect("seed loose file"); // Lock parent to r+x only — simulates the mount-point parent the JVM doesn't own. - let original_parent_mode = fs::metadata(parent.path()).expect("stat parent").permissions().mode(); - let mut locked = fs::metadata(parent.path()).expect("stat parent").permissions(); + let original_parent_mode = fs::metadata(parent.path()) + .expect("stat parent") + .permissions() + .mode(); + let mut locked = fs::metadata(parent.path()) + .expect("stat parent") + .permissions(); locked.set_mode(0o555); fs::set_permissions(parent.path(), locked).expect("chmod parent 555"); let spill_str = spill_path.to_str().expect("utf-8 path"); - let result = create_global_runtime(64 * 1024 * 1024, 0, spill_str, 0); + let result = create_global_runtime(64 * 1024 * 1024, 0, spill_str, 0, false, 0, "lru"); // Restore parent perms via RAII so tempdir cleanup runs even on assertion failure. struct RestorePerms<'a> { @@ -2175,25 +2494,46 @@ mod tests { } } } - let _restore = RestorePerms { path: parent.path(), mode: original_parent_mode }; + let _restore = RestorePerms { + path: parent.path(), + mode: original_parent_mode, + }; let ptr = result.expect("runtime build must succeed when only the parent is read-only"); assert!(ptr > 0); // Phase 1: both originals are renamed inline — gone immediately by the // original name. Phase 2 unlinks the .stale entries asynchronously. - assert!(!loose_file.exists(), "loose top-level file original name must be gone (renamed)"); - assert!(!leaked_dir.exists(), "leaked datafusion-* original name must be gone (renamed)"); + assert!( + !loose_file.exists(), + "loose top-level file original name must be gone (renamed)" + ); + assert!( + !leaked_dir.exists(), + "leaked datafusion-* original name must be gone (renamed)" + ); let stale_loose = spill_path.join("stray.txt.stale"); let stale_dir = spill_path.join("datafusion-aB3kF7.stale"); let cleaned = wait_until(2000, || !stale_loose.exists() && !stale_dir.exists()); - assert!(cleaned, "background cleanup must remove both .stale entries within 2s"); - assert!(!leaked_file.exists(), "leaked file under leaked subdir must be removed"); + assert!( + cleaned, + "background cleanup must remove both .stale entries within 2s" + ); + assert!( + !leaked_file.exists(), + "leaked file under leaked subdir must be removed" + ); // Spill root itself preserved. - assert!(spill_path.exists(), "spill mount-point dir must be preserved"); - assert!(spill_path.is_dir(), "spill mount-point must remain a directory"); + assert!( + spill_path.exists(), + "spill mount-point dir must be preserved" + ); + assert!( + spill_path.is_dir(), + "spill mount-point must remain a directory" + ); unsafe { close_global_runtime(ptr) }; } @@ -2217,16 +2557,23 @@ mod tests { assert!(link.is_symlink(), "precondition: link is a symlink"); let spill_str = spill_path.to_str().expect("utf-8 path"); - let ptr = create_global_runtime(64 * 1024 * 1024, 0, spill_str, 0).expect("runtime build"); + let ptr = create_global_runtime(64 * 1024 * 1024, 0, spill_str, 0, false, 0, "lru") + .expect("runtime build"); assert!(ptr > 0); // Phase 1: symlink is renamed inline (fs::rename does not follow symlinks). - assert!(!link.exists(), "symlink original name must be gone (renamed)"); + assert!( + !link.exists(), + "symlink original name must be gone (renamed)" + ); let stale_link = spill_path.join("escape_link.stale"); // Phase 2: remove_file on a symlink unlinks the link itself, never the target. let cleaned = wait_until(2000, || !stale_link.exists()); - assert!(cleaned, "background cleanup must remove escape_link.stale within 2s"); + assert!( + cleaned, + "background cleanup must remove escape_link.stale within 2s" + ); // Critical: the target outside spill must be intact across both phases. // If phase 1 had followed the symlink during rename, or phase 2 followed @@ -2261,7 +2608,8 @@ mod tests { fs::create_dir(&leaked_b).expect("create leaked b"); fs::write(leaked_b.join("tmp_002.arrow"), b"data b").expect("write b"); - let ptr = create_global_runtime(64 * 1024 * 1024, 0, spill_path, 0).expect("runtime build"); + let ptr = create_global_runtime(64 * 1024 * 1024, 0, spill_path, 0, false, 0, "lru") + .expect("runtime build"); assert!(ptr > 0); // Originals were renamed inline — gone immediately by the original name. @@ -2272,7 +2620,10 @@ mod tests { let stale_a = tmp.path().join("datafusion-aB3kF7.stale"); let stale_b = tmp.path().join("datafusion-Xy9pQ2.stale"); let cleaned = wait_until(2000, || !stale_a.exists() && !stale_b.exists()); - assert!(cleaned, "background cleanup must remove both *.stale entries within 2s"); + assert!( + cleaned, + "background cleanup must remove both *.stale entries within 2s" + ); unsafe { close_global_runtime(ptr) }; } @@ -2290,11 +2641,15 @@ mod tests { fs::create_dir(&leftover).expect("create leftover"); fs::write(leftover.join("residue.arrow"), b"prior boot data").expect("write residue"); - let ptr = create_global_runtime(64 * 1024 * 1024, 0, spill_path, 0).expect("runtime build"); + let ptr = create_global_runtime(64 * 1024 * 1024, 0, spill_path, 0, false, 0, "lru") + .expect("runtime build"); assert!(ptr > 0); let cleaned = wait_until(2000, || !leftover.exists()); - assert!(cleaned, "prior-boot .stale leftover must be cleaned within 2s"); + assert!( + cleaned, + "prior-boot .stale leftover must be cleaned within 2s" + ); assert!( !tmp.path().join("datafusion-old.stale.stale").exists(), "phase 1 must NOT double-suffix existing .stale entries" @@ -2311,7 +2666,8 @@ mod tests { let strings: Vec = (0..total_rows) .map(|i| format!("long_string_value_{:06}_padding", i)) .collect(); - let string_view_array = StringViewArray::from_iter_values(strings.iter().map(|s| s.as_str())); + let string_view_array = + StringViewArray::from_iter_values(strings.iter().map(|s| s.as_str())); let int_array = Int64Array::from_iter_values(0..total_rows as i64); let schema = Arc::new(Schema::new(vec![ @@ -2346,9 +2702,11 @@ mod tests { let strings: Vec<&str> = (0..100).map(|_| "short").collect(); let string_view_array = StringViewArray::from_iter_values(strings.into_iter()); - let schema = Arc::new(Schema::new(vec![ - Field::new("str_col", DataType::Utf8View, false), - ])); + let schema = Arc::new(Schema::new(vec![Field::new( + "str_col", + DataType::Utf8View, + false, + )])); let batch = RecordBatch::try_new(schema, vec![Arc::new(string_view_array.clone())]).unwrap(); @@ -2361,9 +2719,11 @@ mod tests { #[test] fn stringview_gc_empty_array() { let string_view_array = StringViewArray::from_iter_values(std::iter::empty::<&str>()); - let schema = Arc::new(Schema::new(vec![ - Field::new("str_col", DataType::Utf8View, false), - ])); + let schema = Arc::new(Schema::new(vec![Field::new( + "str_col", + DataType::Utf8View, + false, + )])); let batch = RecordBatch::try_new(schema, vec![Arc::new(string_view_array)]).unwrap(); let compacted = compact_string_view_columns(batch); assert_eq!(compacted.num_rows(), 0); @@ -2409,9 +2769,11 @@ mod tests { #[test] fn no_view_columns_passthrough() { let int_array = Int64Array::from_iter_values(0..1000); - let schema = Arc::new(Schema::new(vec![ - Field::new("int_col", DataType::Int64, false), - ])); + let schema = Arc::new(Schema::new(vec![Field::new( + "int_col", + DataType::Int64, + false, + )])); let batch = RecordBatch::try_new(schema, vec![Arc::new(int_array)]).unwrap(); let compacted = compact_string_view_columns(batch.clone()); assert_eq!( @@ -2425,14 +2787,16 @@ mod tests { let strings: Vec = (0..1000) .map(|i| format!("long_string_value_{:06}_padding", i)) .collect(); - let string_view_array: Arc = - Arc::new(StringViewArray::from_iter_values(strings.iter().map(|s| s.as_str()))); + let string_view_array: Arc = Arc::new(StringViewArray::from_iter_values( + strings.iter().map(|s| s.as_str()), + )); - let schema = Arc::new(Schema::new(vec![ - Field::new("str_col", DataType::Utf8View, false), - ])); - let batch = - RecordBatch::try_new(schema, vec![Arc::clone(&string_view_array)]).unwrap(); + let schema = Arc::new(Schema::new(vec![Field::new( + "str_col", + DataType::Utf8View, + false, + )])); + let batch = RecordBatch::try_new(schema, vec![Arc::clone(&string_view_array)]).unwrap(); let compacted = compact_string_view_columns(batch); @@ -2457,18 +2821,23 @@ mod tests { let strings: Vec = (0..10_000) .map(|i| format!("long_string_value_{:06}_padding", i)) .collect(); - let full_array = - StringViewArray::from_iter_values(strings.iter().map(|s| s.as_str())); + let full_array = StringViewArray::from_iter_values(strings.iter().map(|s| s.as_str())); let sliced = full_array.slice(0, 100); let sliced_view: &StringViewArray = sliced.as_any().downcast_ref().unwrap(); assert!( - view_needs_gc(sliced_view.data_buffers(), sliced_view.total_buffer_bytes_used()), + view_needs_gc( + sliced_view.data_buffers(), + sliced_view.total_buffer_bytes_used() + ), "Sliced array must be detected as needing gc" ); assert!( - !view_needs_gc(full_array.data_buffers(), full_array.total_buffer_bytes_used()), + !view_needs_gc( + full_array.data_buffers(), + full_array.total_buffer_bytes_used() + ), "Non-sliced array must NOT need gc" ); } @@ -2481,11 +2850,23 @@ mod tests { // Clamps to the [1, 32] range used by the datafusion.reduce.target_partitions setting. set_reduce_target_partitions(0); - assert_eq!(get_reduce_target_partitions(), 1, "values below 1 clamp up to 1"); + assert_eq!( + get_reduce_target_partitions(), + 1, + "values below 1 clamp up to 1" + ); set_reduce_target_partitions(-5); - assert_eq!(get_reduce_target_partitions(), 1, "negative values clamp up to 1"); + assert_eq!( + get_reduce_target_partitions(), + 1, + "negative values clamp up to 1" + ); set_reduce_target_partitions(1000); - assert_eq!(get_reduce_target_partitions(), 32, "values above 32 clamp down to 32"); + assert_eq!( + get_reduce_target_partitions(), + 32, + "values above 32 clamp down to 32" + ); // Boundary values pass through unchanged. set_reduce_target_partitions(1); @@ -2615,15 +2996,25 @@ fn ascending_row_id_check_stream( /// Verify the fetch-result schema carries `__row_id__` plus every requested column. /// Body only runs under `debug_assertions` (called from a `debug_assert!`). -fn assert_fetch_result_schema(schema: &datafusion::arrow::datatypes::Schema, columns: &[String]) -> bool { +fn assert_fetch_result_schema( + schema: &datafusion::arrow::datatypes::Schema, + columns: &[String], +) -> bool { if schema.column_with_name(crate::ROW_ID_COLUMN_NAME).is_none() { let names: Vec = schema.fields().iter().map(|f| f.name().clone()).collect(); - panic!("fetch_by_row_ids: result schema missing {}, got {:?}", crate::ROW_ID_COLUMN_NAME, names); + panic!( + "fetch_by_row_ids: result schema missing {}, got {:?}", + crate::ROW_ID_COLUMN_NAME, + names + ); } for col in columns { if schema.column_with_name(col).is_none() { let names: Vec = schema.fields().iter().map(|f| f.name().clone()).collect(); - panic!("fetch_by_row_ids: result schema missing requested column {}, got {:?}", col, names); + panic!( + "fetch_by_row_ids: result schema missing requested column {}, got {:?}", + col, names + ); } } true diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/cache.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/cache.rs deleted file mode 100644 index d5fb186acbc51..0000000000000 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/cache.rs +++ /dev/null @@ -1,183 +0,0 @@ -/* - * 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. - */ - -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex}; - -use datafusion::execution::cache::cache_manager::{ - CachedFileMetadataEntry, FileMetadataCache, FileMetadataCacheEntry, -}; -use datafusion::execution::cache::DefaultFilesMetadataCache; -use datafusion::execution::cache::CacheAccessor; -use log::error; -use object_store::path::Path; - -// Cache type constants -pub const CACHE_TYPE_METADATA: &str = "METADATA"; -pub const CACHE_TYPE_STATS: &str = "STATISTICS"; - -// Helper function to log cache operations -fn log_cache_error(operation: &str, error: &str) { - error!("[CACHE ERROR] {} operation failed: {}", operation, error); -} - -// Wrapper to make Mutex implement FileMetadataCache -pub struct MutexFileMetadataCache { - pub inner: Mutex, - hit_count: AtomicUsize, - miss_count: AtomicUsize, -} - -impl MutexFileMetadataCache { - pub fn new(cache: DefaultFilesMetadataCache) -> Self { - Self { - inner: Mutex::new(cache), - hit_count: AtomicUsize::new(0), - miss_count: AtomicUsize::new(0), - } - } - - pub fn hit_count(&self) -> usize { - self.hit_count.load(Ordering::Relaxed) - } - - pub fn miss_count(&self) -> usize { - self.miss_count.load(Ordering::Relaxed) - } - - pub fn reset_stats(&self) { - self.hit_count.store(0, Ordering::Relaxed); - self.miss_count.store(0, Ordering::Relaxed); - } - - pub fn clear_cache(&self) { - if let Ok(cache) = self.inner.lock() { - cache.clear(); - } - } - - pub fn update_cache_limit(&self, new_limit: usize) { - if let Ok(cache) = self.inner.lock() { - cache.update_cache_limit(new_limit); - } - } - - pub fn get_cache_limit(&self) -> usize { - if let Ok(cache) = self.inner.lock() { - cache.cache_limit() - } else { - 0 - } - } -} - -impl CacheAccessor for MutexFileMetadataCache { - fn get(&self, k: &Path) -> Option { - match self.inner.lock() { - Ok(cache) => { - let result = cache.get(k); - if result.is_some() { - self.hit_count.fetch_add(1, Ordering::Relaxed); - } else { - self.miss_count.fetch_add(1, Ordering::Relaxed); - } - result - } - Err(e) => { - log_cache_error("get", &e.to_string()); - None - } - } - } - - fn put(&self, k: &Path, v: CachedFileMetadataEntry) -> Option { - match self.inner.lock() { - Ok(cache) => cache.put(k, v), - Err(e) => { - log_cache_error("put", &e.to_string()); - None - } - } - } - - fn remove(&self, k: &Path) -> Option { - match self.inner.lock() { - Ok(cache) => cache.remove(k), - Err(e) => { - log_cache_error("remove", &e.to_string()); - None - } - } - } - - fn contains_key(&self, k: &Path) -> bool { - match self.inner.lock() { - Ok(cache) => cache.contains_key(k), - Err(e) => { - log_cache_error("contains_key", &e.to_string()); - false - } - } - } - - fn len(&self) -> usize { - match self.inner.lock() { - Ok(cache) => cache.len(), - Err(e) => { - log_cache_error("len", &e.to_string()); - 0 - } - } - } - - fn clear(&self) { - match self.inner.lock() { - Ok(cache) => cache.clear(), - Err(e) => log_cache_error("clear", &e.to_string()), - } - } - - fn name(&self) -> String { - match self.inner.lock() { - Ok(cache) => cache.name(), - Err(e) => { - log_cache_error("name", &e.to_string()); - "cache_error".to_string() - } - } - } -} - -impl FileMetadataCache for MutexFileMetadataCache { - fn cache_limit(&self) -> usize { - match self.inner.lock() { - Ok(cache) => cache.cache_limit(), - Err(e) => { - log_cache_error("cache_limit", &e.to_string()); - 0 - } - } - } - - fn update_cache_limit(&self, limit: usize) { - match self.inner.lock() { - Ok(cache) => cache.update_cache_limit(limit), - Err(e) => log_cache_error("update_cache_limit", &e.to_string()), - } - } - - fn list_entries(&self) -> std::collections::HashMap { - match self.inner.lock() { - Ok(cache) => cache.list_entries(), - Err(e) => { - log_cache_error("list_entries", &e.to_string()); - std::collections::HashMap::new() - } - } - } -} diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/cache/custom_cache_manager.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/cache/custom_cache_manager.rs new file mode 100644 index 0000000000000..4c4adf85f69cf --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/cache/custom_cache_manager.rs @@ -0,0 +1,1272 @@ +/* + * 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. + */ + +use crate::cache::metadata_cache::MutexFileMetadataCache; +use crate::cache::statistics_cache::CustomStatisticsCache; +use crate::cache::statistics_cache::{ + compute_parquet_statistics, compute_parquet_statistics_from_metadata, +}; +use crate::cache::{metadata_cache, page_index}; +use crate::indexed_table::parquet_bridge; +use datafusion::datasource::physical_plan::parquet::metadata::DFParquetMetadata; +use datafusion::execution::cache::cache_manager::{ + CacheManagerConfig, FileMetadataCache, FileStatisticsCache, +}; +use datafusion::execution::cache::file_statistics_cache::DefaultFileStatisticsCache; +use datafusion::execution::cache::CacheAccessor; +use log::{debug, error}; +use native_bridge_common::log_debug; +use object_store::path::Path; +use object_store::ObjectMeta; +use object_store::ObjectStore; +use opensearch_tiered_storage::tiered_object_store::MetadataCachingStore; +use std::sync::Arc; + +/// Compute the page-index regions to warm, as SEPARATE column-index (CI) and +/// offset-index (OI) whole regions — matching exactly how the query-time +/// whole-region fetch (`page_index/page_index_io.rs::whole_index_region`) computes +/// and probes them. +/// +/// Each region folds ALL columns across ALL row groups for that one index kind: +/// - CI region = `min(start)..max(end)` over every column×RG `column_index` extent +/// - OI region = `min(start)..max(end)` over every column×RG `offset_index` extent +/// +/// Returns up to two ranges (CI first, then OI), skipping whichever kind the file +/// lacks. Returns an empty Vec if the file has no page index at all. +/// +/// IMPORTANT: warmup must write these as TWO keys (CI, OI) — NOT one merged +/// CI∪OI range. The query probes the CI whole region and the OI whole region +/// under separate keys, so a single merged key would never match (warm miss). +pub(crate) fn compute_page_index_range( + metadata: &parquet::file::metadata::ParquetMetaData, +) -> Vec> { + // Column-index whole region across all columns × all row groups. + let ci_region = metadata + .row_groups() + .iter() + .flat_map(|rg| rg.columns().iter()) + .fold(None::>, |acc, col| { + if let (Some(offset), Some(length)) = + (col.column_index_offset(), col.column_index_length()) + { + let start = offset as u64; + let end = start + length as u64; + match acc { + Some(a) => Some(a.start.min(start)..a.end.max(end)), + None => Some(start..end), + } + } else { + acc + } + }); + + // Offset-index whole region across all columns × all row groups. + let oi_region = metadata + .row_groups() + .iter() + .flat_map(|rg| rg.columns().iter()) + .fold(None::>, |acc, col| { + if let (Some(offset), Some(length)) = + (col.offset_index_offset(), col.offset_index_length()) + { + let start = offset as u64; + let end = start + length as u64; + match acc { + Some(a) => Some(a.start.min(start)..a.end.max(end)), + None => Some(start..end), + } + } else { + acc + } + }); + + let mut ranges = Vec::with_capacity(2); + if let Some(r) = ci_region { + ranges.push(r); + } + if let Some(r) = oi_region { + ranges.push(r); + } + ranges +} + +/// Create ObjectMeta from a local file path. +fn create_object_meta_from_file( + file_path: &str, +) -> Result, datafusion::common::DataFusionError> { + use chrono::{DateTime, Utc}; + use datafusion::common::DataFusionError; + + let metadata = std::fs::metadata(file_path).map_err(|e| { + DataFusionError::Execution(format!( + "Failed to get file metadata for {}: {}", + file_path, e + )) + })?; + + let file_size = metadata.len(); + + let modified = metadata + .modified() + .map(|t| DateTime::::from(t)) + .unwrap_or_else(|_| Utc::now()); + + let object_meta = ObjectMeta { + location: Path::from(file_path), + last_modified: modified, + size: file_size, + e_tag: None, + version: None, + }; + + Ok(vec![object_meta]) +} + +/// Custom CacheManager that holds cache references directly +pub struct CustomCacheManager { + /// Direct reference to the file metadata cache + file_metadata_cache: Option>, + /// Direct reference to the statistics cache + statistics_cache: Option>, + column_index_registered: bool, + offset_index_registered: bool, +} + +impl CustomCacheManager { + /// Create a new CustomCacheManager + pub fn new() -> Self { + Self { + file_metadata_cache: None, + statistics_cache: None, + column_index_registered: false, + offset_index_registered: false, + } + } + + /// Set the file metadata cache + pub fn set_file_metadata_cache(&mut self, cache: Arc) { + self.file_metadata_cache = Some(cache); + log_debug!("[CACHE INFO] File metadata cache set in CustomCacheManager"); + } + + /// Set the statistics cache + pub fn set_statistics_cache(&mut self, cache: Arc) { + self.statistics_cache = Some(cache); + log_debug!("[CACHE INFO] Statistics cache set in CustomCacheManager"); + } + + /// Register the column index cache with the given size limit. + /// Sets the limit on the process-global `COLUMN_INDEX_CACHE` singleton. + pub fn set_column_index_cache(&mut self, size_limit: usize) { + crate::cache::page_index::set_column_index_cache_limit(size_limit); + self.column_index_registered = true; + log_debug!( + "[CACHE INFO] Column index cache registered (limit={} bytes)", + size_limit + ); + } + + /// Register the offset index cache with the given size limit. + /// Sets the limit on the process-global `OFFSET_INDEX_CACHE` singleton. + pub fn set_offset_index_cache(&mut self, size_limit: usize) { + crate::cache::page_index::set_offset_index_cache_limit(size_limit); + self.offset_index_registered = true; + log_debug!( + "[CACHE INFO] Offset index cache registered (limit={} bytes)", + size_limit + ); + } + + /// Get the statistics cache + pub fn get_statistics_cache(&self) -> Option> { + self.statistics_cache.clone() + } + + /// Get the file metadata cache as Arc for DataFusion + pub fn get_file_metadata_cache_for_datafusion(&self) -> Option> { + self.file_metadata_cache + .as_ref() + .map(|cache| cache.clone() as Arc) + } + + /// Build a CacheManagerConfig from the caches stored in this CustomCacheManager + pub fn build_cache_manager_config(&self) -> CacheManagerConfig { + let mut config = CacheManagerConfig::default(); + + // Add file metadata cache if available + if let Some(cache) = self.get_file_metadata_cache_for_datafusion() { + config = config + .with_file_metadata_cache(Some(cache.clone())) + .with_metadata_cache_limit(cache.cache_limit()); + } + + // Add statistics cache if available - use CustomStatisticsCache directly + if let Some(stats_cache) = &self.statistics_cache { + config = config.with_file_statistics_cache(Some( + stats_cache.clone() as Arc + )); + } else { + // Default statistics cache if none set + let default_stats = Arc::new(DefaultFileStatisticsCache::default()); + config = config.with_file_statistics_cache(Some(default_stats)); + } + + config + } + + /// Add multiple files to all applicable caches. + /// + /// Footer metadata and statistics are derived from a **single** object-store + /// read per file: `load_parquet_metadata` fetches the footer, caches it, and + /// returns `(schema, ParquetMetaData)`. Statistics are then computed from that + /// already-decoded metadata — avoiding a second file read. + pub fn add_files( + &self, + file_paths: &[String], + rt_handle: &tokio::runtime::Handle, + ) -> Result, String> { + let mut results = Vec::new(); + + for file_path in file_paths { + let mut any_success = false; + let mut errors = Vec::new(); + + // Single footer fetch — warms metadata cache and returns the decoded metadata + // so statistics can be derived without a second IO round-trip. + match self.metadata_cache_put_returning_meta(file_path, rt_handle) { + Ok(Some((schema, pq_meta))) => { + any_success = true; + + // Derive statistics from the already-loaded metadata — no second read. + if let Some(stats_cache) = &self.statistics_cache { + let path = Path::from(file_path.as_str()); + if !stats_cache.contains_key(&path) { + match compute_parquet_statistics_from_metadata(&pq_meta, &schema) { + Ok(stats) => { + let meta = ObjectMeta { + location: path.clone(), + last_modified: chrono::Utc::now(), + size: std::fs::metadata(file_path) + .map(|m| m.len()) + .unwrap_or(0), + e_tag: None, + version: None, + }; + stats_cache.put_statistics(&path, Arc::new(stats), &meta); + } + Err(e) => { + errors.push(format!("Statistics cache: {}", e)); + } + } + } + } + } + Ok(None) => { + log_debug!( + "[CACHE INFO] File not added for metadata cache: {}", + file_path + ); + } + Err(e) => { + errors.push(format!("Metadata cache: {}", e)); + } + } + + let success = if !errors.is_empty() && !any_success { + false + } else { + any_success + }; + results.push((file_path.clone(), success)); + } + + Ok(results) + } + + /// Remove multiple files from all caches + pub fn remove_files(&self, file_paths: &[String]) -> Result, String> { + let mut results = Vec::new(); + + for file_path in file_paths { + let mut any_removed = false; + let mut errors = Vec::new(); + + // Remove from metadata cache + { + let path = Path::from(file_path.clone()); + if let Some(cache) = &self.file_metadata_cache { + match cache.inner.lock() { + Ok(cache_guard) => { + if cache_guard.remove(&path).is_some() { + any_removed = true; + } else { + log_debug!( + "[CACHE INFO] File not found in metadata cache: {}", + file_path + ); + } + } + Err(e) => { + errors.push(format!("Metadata cache: Cache remove failed: {}", e)); + } + } + } else { + errors.push("No metadata cache configured".to_string()); + } + } + + // Remove from statistics cache + if let Some(cache) = &self.statistics_cache { + let path = Path::from(file_path.clone()); + if cache.remove(&path).is_some() { + any_removed = true; + } + } + + // Evict from scoped page-index caches (CI + OI) by file prefix + if self.column_index_registered || self.offset_index_registered { + page_index::evict_file_from_scoped_cache(file_path); + any_removed = true; + } + + let removed = if !errors.is_empty() && !any_removed { + false + } else { + any_removed + }; + + results.push((file_path.clone(), removed)); + } + + Ok(results) + } + + /// Check if a file exists in any cache + pub fn contains_file(&self, file_path: &str) -> bool { + let mut found = false; + + // Check metadata cache + { + let path = Path::from(file_path); + if let Some(cache) = &self.file_metadata_cache { + if cache.get(&path).is_some() { + found = true; + } + } + } + + // Check statistics cache + if let Some(cache) = &self.statistics_cache { + let path = Path::from(file_path); + if cache.contains_key(&path) { + found = true; + } + } + + found + } + + /// Check if a file exists in a specific cache type + pub fn contains_file_by_type(&self, file_path: &str, cache_type: &str) -> bool { + match cache_type { + crate::cache::metadata_cache::CACHE_TYPE_METADATA => { + let path = Path::from(file_path); + self.file_metadata_cache + .as_ref() + .and_then(|cache| cache.get(&path)) + .is_some() + } + crate::cache::metadata_cache::CACHE_TYPE_STATS => self + .statistics_cache + .as_ref() + .map_or(false, |cache| cache.contains_key(&Path::from(file_path))), + metadata_cache::CACHE_TYPE_COLUMN_INDEX => { + if !self.column_index_registered { + return false; + } + let stats = page_index::column_index_cache_stats(); + // CI is keyed by (file, col) — a file is "present" if entries > 0 and + // we match by prefix; check via evict-probe is heavy so we approximate + // with entries > 0. A per-file lookup requires iterating DashMap — not + // worth it for a boolean check; callers use this for diagnostics only. + stats.entries > 0 + } + metadata_cache::CACHE_TYPE_OFFSET_INDEX => { + if !self.offset_index_registered { + return false; + } + let stats = page_index::offset_index_cache_stats(); + stats.entries > 0 + } + _ => false, + } + } + + /// Update the file metadata cache size limit + pub fn update_metadata_cache_limit(&self, new_limit: usize) { + if let Some(cache) = &self.file_metadata_cache { + cache.update_cache_limit(new_limit); + } + } + + /// Update the statistics cache size limit + pub fn update_statistics_cache_limit(&self, new_limit: usize) -> Result<(), String> { + if let Some(cache) = &self.statistics_cache { + cache + .update_size_limit(new_limit) + .map_err(|e| format!("Failed to update statistics cache limit: {:?}", e)) + } else { + Err("No statistics cache configured".to_string()) + } + } + + /// Get total memory consumed by all caches + pub fn get_total_memory_consumed(&self) -> usize { + let mut total = 0; + + // Add metadata cache memory + if let Some(cache) = &self.file_metadata_cache { + if let Ok(cache_guard) = cache.inner.lock() { + total += cache_guard.memory_used(); + } + } + + // Add statistics cache memory + if let Some(cache) = &self.statistics_cache { + total += cache.memory_consumed(); + } + + total + } + + /// Clear all caches + pub fn clear_all(&self) { + if let Some(cache) = &self.file_metadata_cache { + cache.clear(); + } + if let Some(cache) = &self.statistics_cache { + cache.clear(); + } + if self.column_index_registered || self.offset_index_registered { + page_index::clear_scoped_cache(); + } + } + + /// Clear specific cache type + pub fn clear_cache_type(&self, cache_type: &str) -> Result<(), String> { + match cache_type { + metadata_cache::CACHE_TYPE_METADATA => { + if let Some(cache) = &self.file_metadata_cache { + cache.clear(); + Ok(()) + } else { + Err("No metadata cache configured".to_string()) + } + } + metadata_cache::CACHE_TYPE_STATS => { + if let Some(cache) = &self.statistics_cache { + cache.clear(); + Ok(()) + } else { + Err("No statistics cache configured".to_string()) + } + } + metadata_cache::CACHE_TYPE_COLUMN_INDEX | metadata_cache::CACHE_TYPE_OFFSET_INDEX => { + page_index::clear_scoped_cache(); + Ok(()) + } + _ => Err(format!("Unknown cache type: {}", cache_type)), + } + } + + /// Get memory consumed by specific cache type + pub fn get_memory_consumed_by_type(&self, cache_type: &str) -> Result { + match cache_type { + metadata_cache::CACHE_TYPE_METADATA => { + if let Some(cache) = &self.file_metadata_cache { + if let Ok(cache_guard) = cache.inner.lock() { + Ok(cache_guard.memory_used()) + } else { + Err("Failed to lock metadata cache".to_string()) + } + } else { + Err("No metadata cache configured".to_string()) + } + } + metadata_cache::CACHE_TYPE_STATS => { + if let Some(cache) = &self.statistics_cache { + Ok(cache.memory_consumed()) + } else { + Err("No statistics cache configured".to_string()) + } + } + metadata_cache::CACHE_TYPE_COLUMN_INDEX => { + Ok(page_index::column_index_cache_stats().used_bytes) + } + metadata_cache::CACHE_TYPE_OFFSET_INDEX => { + Ok(page_index::offset_index_cache_stats().used_bytes) + } + _ => Err(format!("Unknown cache type: {}", cache_type)), + } + } + + /// Fetch a parquet file's footer, warm the metadata cache, and return the + /// decoded `(SchemaRef, Arc)` so the caller can derive + /// statistics without a second IO round-trip. + /// + /// Returns `Ok(None)` for non-parquet files. + fn metadata_cache_put_returning_meta( + &self, + file_path: &str, + rt_handle: &tokio::runtime::Handle, + ) -> Result< + Option<( + datafusion::arrow::datatypes::SchemaRef, + Arc, + )>, + String, + > { + if !file_path.to_lowercase().ends_with(".parquet") { + return Ok(None); + } + + let object_metas = create_object_meta_from_file(file_path) + .map_err(|e| format!("Failed to get object metadata: {}", e))?; + + let object_meta = object_metas + .first() + .ok_or_else(|| "No object metadata returned".to_string())?; + + let store: Arc = Arc::new(object_store::local::LocalFileSystem::new()); + + let metadata_cache = self + .file_metadata_cache + .as_ref() + .ok_or_else(|| "No file metadata cache configured".to_string())? + .clone() as Arc; + + let location = object_meta.location.clone(); + let meta = object_meta.clone(); + let (schema, _size, pq_meta) = rt_handle.block_on(async { + parquet_bridge::load_parquet_metadata_with_meta(store, &location, meta, metadata_cache) + .await + })?; + + Ok(Some((schema, pq_meta))) + } + + /// Warmup: load footer (lightweight) into heap, fetch page/offset index bytes + /// through the store (for Foyer caching), and promote those ranges to the + /// metadata Foyer tier via `MetadataCachingStore::put_metadata`. + /// + /// Key design: + /// - Footer (schema + RG stats) → heap file_metadata_cache (parsed `ParquetMetaData`, fast path) + /// - Footer raw bytes (last 64 KB) + page-index bytes → **promoted to metadata Foyer** + /// (never-evict SSD tier) via `store.put_metadata(...)`. The parsed footer in heap is + /// the fast path; the metadata-Foyer footer is the heap-eviction safety net (served from + /// SSD instead of S3 on cold-path re-parse). + /// - Page indexes NOT stored in heap (avoids 1GB+ memory bloat on wide schemas). + pub fn add_files_with_store( + &self, + file_paths: &[String], + store: Arc, + rt_handle: &tokio::runtime::Handle, + ) -> Result, String> { + let mut results = Vec::with_capacity(file_paths.len()); + for file_path in file_paths { + match self.warmup_file_with_store(file_path, &store, rt_handle) { + Ok(success) => { + results.push((file_path.clone(), success)); + } + Err(e) => { + error!( + "[CACHE ERROR] add_files_with_store failed for {}: {}", + file_path, e + ); + results.push((file_path.clone(), false)); + } + } + } + Ok(results) + } + + /// Warmup a single file + /// 1. Fetch footer only (PageIndexPolicy::Skip) → heap cache (lightweight parsed metadata) + /// 2. Derive statistics from the same parsed metadata → statistics cache + /// 3. Compute page/offset index byte ranges from the parsed footer; append the footer + /// range so it's also covered by the metadata Foyer promotion below + /// 4. Fetch those ranges through the store (populates **data Foyer** as a side effect of get_ranges) + /// 5. Promote those ranges to **metadata Foyer** (never-evict tier) via `store.put_metadata` + fn warmup_file_with_store( + &self, + file_path: &str, + store: &Arc, + rt_handle: &tokio::runtime::Handle, + ) -> Result { + if !file_path.to_lowercase().ends_with(".parquet") { + return Ok(false); + } + + // Step 1: Fetch footer only → heap cache (parsed ParquetMetaData) + let (parquet_metadata, object_meta) = + self.fetch_footer_to_heap(file_path, store, rt_handle)?; + + // Step 2: Derive statistics from the same parsed metadata → statistics cache. + // Reuses `parquet_metadata` so we don't re-fetch through the store. Failures + // here are non-fatal — they only mean the first query will recompute statistics. + if let Err(e) = + self.statistics_cache_put_from_parsed(file_path, &parquet_metadata, &object_meta) + { + error!( + "[warmup::warmup_file_with_store] statistics_cache_put failed for {}: {} (non-fatal)", + file_path, e + ); + } + + // Step 3: Compute page/offset index byte ranges from the parsed footer. + // NOTE: this now returns SEPARATE CI and OI whole regions (up to 2 ranges), + // matching the query-time whole-region probe keys exactly. A single merged + // CI∪OI range would never match the query's per-kind probes (warm miss). + let mut index_ranges = compute_page_index_range(&parquet_metadata); + + // Also promote the footer (last 64 KB) to metadata Foyer — heap-eviction safety net. + // Heap holds the parsed `ParquetMetaData` for fast lookups; the metadata Foyer entry + // is the SSD-served re-parse path when the heap evicts under memory pressure. + let footer_prefetch = 64 * 1024u64; // matches DataFusion's DEFAULT_FOOTER_READ_SIZE + let footer_start = object_meta.size.saturating_sub(footer_prefetch); + index_ranges.push(footer_start..object_meta.size); + + // Also promote the exact last-8-byte postscript [size-8, size] to metadata Foyer. + // DataFusion's FIRST footer read is an 8-byte read of the postscript (4-byte + // footer-metadata length + "PAR1" magic) to learn the footer length. On an + // exact-key warm tier that 8-byte read probes key [size-8, size], which the + // 64 KB footer range above does NOT match — so without this the very first + // metadata read always misses the warm tier. Promoting the exact 8 bytes under + // their own key makes that probe a warm hit. + let postscript_start = object_meta.size.saturating_sub(8); + index_ranges.push(postscript_start..object_meta.size); + + // Step 4: Fetch the ranges through the store (populates data Foyer on the way). + let fetched_bytes = + Self::fetch_ranges_via_store(store, file_path, &index_ranges, rt_handle)?; + + // Step 5: Promote bytes to metadata Foyer (never-evict tier). + // No-op when `store` is not a TieredObjectStore (default trait impl is no-op). + store.put_metadata(file_path, &index_ranges, &fetched_bytes); + + native_bridge_common::log_debug!( + "[warm-tier] warmed file='{}' size={} promoted {} metadata ranges ({} bytes)", + file_path, + object_meta.size, + index_ranges.len(), + index_ranges.iter().map(|r| r.end - r.start).sum::() + ); + + Ok(true) + } + + /// Fetch footer only (no page indexes) from the store and put into heap cache. + /// + /// Returns the parsed metadata and object meta (for file size). + /// + /// # Panics + /// Panics if called from within a tokio async context (uses `block_on`). + /// Must be called from FFM entry points (Java → Rust, non-async) or from + /// a dedicated synchronous thread. Integration tests use a separate OnceLock runtime. + fn fetch_footer_to_heap( + &self, + file_path: &str, + store: &Arc, + rt_handle: &tokio::runtime::Handle, + ) -> Result<(Arc, ObjectMeta), String> { + let path = Path::from(file_path.to_string()); + + // Head call to get file size (TieredObjectStore serves from registry) + let object_meta = rt_handle.block_on(async { + use object_store::ObjectStoreExt; + store + .head(&path) + .await + .map_err(|e| format!("Failed to head {}: {}", file_path, e)) + })?; + + let cache_ref = self + .file_metadata_cache + .as_ref() + .ok_or_else(|| "No file metadata cache configured".to_string())?; + let metadata_cache = cache_ref.clone() as Arc; + + // Do NOT pass file_metadata_cache here — that triggers PageIndexPolicy::Optional + // which loads page indexes into the heap struct. Instead, load footer only + // and manually put into the heap cache afterward. + let parquet_metadata: Arc = + rt_handle.block_on(async { + let df_metadata = DFParquetMetadata::new(store.as_ref(), &object_meta); + df_metadata + .fetch_metadata() + .await + .map_err(|e| format!("Failed to fetch footer: {}", e)) + })?; + + // Put lightweight footer-only metadata into heap cache + use datafusion::datasource::physical_plan::parquet::metadata::CachedParquetMetaData; + use datafusion::execution::cache::cache_manager::CachedFileMetadataEntry; + use datafusion::execution::cache::CacheAccessor; + let cached_entry = CachedFileMetadataEntry::new( + object_meta.clone(), + Arc::new(CachedParquetMetaData::new(Arc::clone(&parquet_metadata))), + ); + metadata_cache.put(&path, cached_entry); + + Ok((parquet_metadata, object_meta)) + } + + /// Fetch byte ranges from the store. Returns the fetched bytes in order. + fn fetch_ranges_via_store( + store: &Arc, + file_path: &str, + ranges: &[std::ops::Range], + rt_handle: &tokio::runtime::Handle, + ) -> Result, String> { + if ranges.is_empty() { + return Ok(vec![]); + } + let path = Path::from(file_path.to_string()); + rt_handle.block_on(async { + store + .get_ranges(&path, ranges) + .await + .map_err(|e| format!("Failed to fetch ranges for {}: {}", file_path, e)) + }) + } + + /// Compute and put statistics into cache (hot path — reads the local file) + /// + /// Used by [`Self::add_files`] (no-store flow) when the parquet lives on local fs + /// For warm shards (data on the per-shard remote store) the equivalent helper is + /// [`Self::statistics_cache_put_from_parsed`], which reuses already-parsed + /// `ParquetMetaData` from the warmup pass instead of opening the file locally + pub fn statistics_cache_compute_and_put(&self, file_path: &str) -> Result { + let cache = self + .statistics_cache + .as_ref() + .ok_or_else(|| "No statistics cache configured".to_string())?; + + let path = Path::from(file_path.to_string()); + + // Check if already cached + if cache.contains_key(&path) { + return Ok(true); + } + + // Compute statistics + match compute_parquet_statistics(file_path) { + Ok(stats) => { + let meta = ObjectMeta { + location: path.clone(), + last_modified: chrono::Utc::now(), + size: std::fs::metadata(file_path).map(|m| m.len()).unwrap_or(0), + e_tag: None, + version: None, + }; + + cache.put_statistics(&path, Arc::new(stats), &meta); + Ok(true) + } + Err(e) => Err(format!( + "Failed to compute statistics for {}: {}", + file_path, e + )), + } + } + + /// Warm-tier statistics warmer: derive statistics from already-parsed + /// [`parquet::file::metadata::ParquetMetaData`] and put them into the statistics + /// cache, reusing the same [`ObjectMeta`] the heap metadata cache used so + /// subsequent query lookups validate cleanly via `is_valid_for`. + /// + /// Called from [`Self::warmup_file_with_store`] right after the heap metadata + /// cache is warmed, so we avoid re-fetching the footer through the store + /// (which is a remote-IO call on warm shards). Returns `Ok(true)` on insert, + /// `Ok(false)` if the statistics cache is not configured or the file is + /// already cached, `Err(_)` on schema/derivation failure. + fn statistics_cache_put_from_parsed( + &self, + file_path: &str, + parquet_metadata: &Arc, + object_meta: &ObjectMeta, + ) -> Result { + use datafusion::parquet::arrow::parquet_to_arrow_schema; + + let cache = match self.statistics_cache.as_ref() { + Some(c) => c, + None => return Ok(false), + }; + + let path = Path::from(file_path.to_string()); + if cache.contains_key(&path) { + return Ok(false); + } + + let file_metadata = parquet_metadata.file_metadata(); + let schema = Arc::new( + parquet_to_arrow_schema( + file_metadata.schema_descr(), + file_metadata.key_value_metadata(), + ) + .map_err(|e| format!("failed to derive arrow schema for {}: {}", file_path, e))?, + ); + + let stats = DFParquetMetadata::statistics_from_parquet_metadata(parquet_metadata, &schema) + .map_err(|e| format!("failed to compute statistics for {}: {}", file_path, e))?; + + cache.put_statistics(&path, Arc::new(stats), object_meta); + Ok(true) + } + + /// Batch compute and cache statistics for multiple files + pub fn statistics_cache_batch_compute_and_put( + &self, + file_paths: &[String], + ) -> Result { + let cache = self + .statistics_cache + .as_ref() + .ok_or_else(|| "No statistics cache configured".to_string())?; + + let mut success_count = 0; + let mut failed_files = Vec::new(); + + for file_path in file_paths { + let path = Path::from(file_path.clone()); + + if cache.contains_key(&path) { + success_count += 1; + continue; + } + + match compute_parquet_statistics(file_path) { + Ok(stats) => { + let meta = ObjectMeta { + location: path.clone(), + last_modified: chrono::Utc::now(), + size: std::fs::metadata(file_path).map(|m| m.len()).unwrap_or(0), + e_tag: None, + version: None, + }; + + cache.put_statistics(&path, Arc::new(stats), &meta); + success_count += 1; + } + Err(e) => { + native_bridge_common::log_debug!( + "[STATS CACHE ERROR] Failed to compute statistics for {}: {}", + file_path, + e + ); + failed_files.push(file_path.clone()); + } + } + } + + if !failed_files.is_empty() { + native_bridge_common::log_debug!( + "[STATS CACHE WARNING] Failed to compute statistics for {} files: {:?}", + failed_files.len(), + failed_files + ); + } + + Ok(success_count) + } + + /// Get or compute statistics + pub fn statistics_cache_get_or_compute(&self, file_path: &str) -> Result { + let cache = self + .statistics_cache + .as_ref() + .ok_or_else(|| "No statistics cache configured".to_string())?; + + let path = Path::from(file_path.to_string()); + + if cache.get(&path).is_some() { + return Ok(true); + } + + self.statistics_cache_compute_and_put(file_path) + } + + /// Get statistics cache hit count + pub fn statistics_cache_hit_count(&self) -> usize { + self.statistics_cache + .as_ref() + .map(|cache| cache.hit_count()) + .unwrap_or(0) + } + + /// Get statistics cache miss count + pub fn statistics_cache_miss_count(&self) -> usize { + self.statistics_cache + .as_ref() + .map(|cache| cache.miss_count()) + .unwrap_or(0) + } + + /// Get statistics cache hit rate + pub fn statistics_cache_hit_rate(&self) -> f64 { + self.statistics_cache + .as_ref() + .map(|cache| cache.hit_rate()) + .unwrap_or(0.0) + } + + /// Get statistics cache entry count + pub fn statistics_cache_entry_count(&self) -> usize { + self.statistics_cache + .as_ref() + .map(|cache| >::len(cache)) + .unwrap_or(0) + } + + /// Get statistics cache size limit in bytes + pub fn statistics_cache_size_limit(&self) -> usize { + self.statistics_cache + .as_ref() + .map(|cache| cache.current_size_limit()) + .unwrap_or(0) + } + + /// Reset statistics cache stats + pub fn statistics_cache_reset_stats(&self) { + if let Some(cache) = &self.statistics_cache { + cache.reset_stats(); + } + } + + /// Get metadata cache hit count + pub fn metadata_cache_hit_count(&self) -> usize { + self.file_metadata_cache + .as_ref() + .map(|cache| cache.hit_count()) + .unwrap_or(0) + } + + /// Get metadata cache miss count + pub fn metadata_cache_miss_count(&self) -> usize { + self.file_metadata_cache + .as_ref() + .map(|cache| cache.miss_count()) + .unwrap_or(0) + } + + /// Get metadata cache entry count + pub fn metadata_cache_entry_count(&self) -> usize { + self.file_metadata_cache + .as_ref() + .map(|cache| >::len(cache)) + .unwrap_or(0) + } + + /// Get metadata cache size limit in bytes + pub fn metadata_cache_size_limit(&self) -> usize { + self.file_metadata_cache + .as_ref() + .map(|cache| cache.get_cache_limit()) + .unwrap_or(0) + } + + /// Reset metadata cache stats + pub fn metadata_cache_reset_stats(&self) { + if let Some(cache) = &self.file_metadata_cache { + cache.reset_stats(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cache::eviction_policy::PolicyType; + use crate::cache::page_index::{ + clear_scoped_cache_for_test, column_index_cache_stats, offset_index_cache_stats, + SCOPED_CACHE_TEST_GUARD, + }; + use crate::cache::{CACHE_TYPE_COLUMN_INDEX, CACHE_TYPE_OFFSET_INDEX}; + + #[test] + fn set_column_index_cache_registers_and_sets_limit() { + let _g = SCOPED_CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + + let mut mgr = CustomCacheManager::new(); + assert!(!mgr.column_index_registered); + + let limit = 16 * 1024 * 1024; // 16 MB + mgr.set_column_index_cache(limit); + + assert!(mgr.column_index_registered); + assert_eq!(column_index_cache_stats().limit_bytes, limit); + + clear_scoped_cache_for_test(); + } + + #[test] + fn set_offset_index_cache_registers_and_sets_limit() { + let _g = SCOPED_CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + + let mut mgr = CustomCacheManager::new(); + assert!(!mgr.offset_index_registered); + + let limit = 32 * 1024 * 1024; // 32 MB + mgr.set_offset_index_cache(limit); + + assert!(mgr.offset_index_registered); + assert_eq!(offset_index_cache_stats().limit_bytes, limit); + + clear_scoped_cache_for_test(); + } + + #[test] + fn clear_cache_type_column_index_clears_scoped_cache() { + let _g = SCOPED_CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + + let mut mgr = CustomCacheManager::new(); + mgr.set_column_index_cache(16 * 1024 * 1024); + + // clear_cache_type must succeed for COLUMN_INDEX + assert!(mgr.clear_cache_type(CACHE_TYPE_COLUMN_INDEX).is_ok()); + // and for OFFSET_INDEX too (both route to clear_scoped_cache) + assert!(mgr.clear_cache_type(CACHE_TYPE_OFFSET_INDEX).is_ok()); + + clear_scoped_cache_for_test(); + } + + #[test] + fn get_memory_consumed_by_type_returns_scoped_stats() { + let _g = SCOPED_CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + + let mut mgr = CustomCacheManager::new(); + mgr.set_column_index_cache(16 * 1024 * 1024); + mgr.set_offset_index_cache(32 * 1024 * 1024); + + // On empty cache both return 0 bytes (no entries yet). + assert_eq!( + mgr.get_memory_consumed_by_type(CACHE_TYPE_COLUMN_INDEX) + .unwrap(), + 0 + ); + assert_eq!( + mgr.get_memory_consumed_by_type(CACHE_TYPE_OFFSET_INDEX) + .unwrap(), + 0 + ); + + clear_scoped_cache_for_test(); + } + + #[test] + fn remove_files_evicts_scoped_cache_when_registered() { + let _g = SCOPED_CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + + let mut mgr = CustomCacheManager::new(); + mgr.set_column_index_cache(16 * 1024 * 1024); + + // Calling remove_files on a non-existent file must not panic. + let result = mgr.remove_files(&["/nonexistent/file.parquet".to_string()]); + assert!(result.is_ok()); + + clear_scoped_cache_for_test(); + } + + #[test] + fn clear_all_clears_scoped_cache_when_registered() { + let _g = SCOPED_CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + + let mut mgr = CustomCacheManager::new(); + mgr.set_column_index_cache(16 * 1024 * 1024); + mgr.set_offset_index_cache(32 * 1024 * 1024); + + // Must not panic even with no metadata/stats caches set. + mgr.clear_all(); + + clear_scoped_cache_for_test(); + } + + // ── compute_page_index_range ────────────────────────────────────────────── + + /// Write an in-memory parquet file and return its raw bytes. `stats` controls + /// whether page indexes (column-index + offset-index) are emitted into the footer. + fn parquet_bytes( + num_cols: usize, + num_rg: usize, + stats: parquet::file::properties::EnabledStatistics, + ) -> bytes::Bytes { + use arrow::array::{Int64Array, RecordBatch}; + use arrow::datatypes::{DataType, Field, Schema}; + use parquet::arrow::ArrowWriter; + use parquet::file::properties::WriterProperties; + + let fields: Vec = (0..num_cols) + .map(|c| Field::new(format!("col_{}", c), DataType::Int64, false)) + .collect(); + let schema = Arc::new(Schema::new(fields)); + + let props = WriterProperties::builder() + .set_max_row_group_size(32) + .set_data_page_row_count_limit(8) + .set_write_batch_size(8) + .set_statistics_enabled(stats) + .build(); + + let mut buf: Vec = Vec::new(); + { + let mut w = ArrowWriter::try_new(&mut buf, schema.clone(), Some(props)).unwrap(); + for rg in 0..num_rg { + let base = (rg * 100) as i64; + let cols: Vec> = (0..num_cols) + .map(|c| { + let vals: Vec = + (base..base + 40).map(|v| v + (c as i64 * 1000)).collect(); + Arc::new(Int64Array::from(vals)) as Arc + }) + .collect(); + let batch = RecordBatch::try_new(schema.clone(), cols).unwrap(); + w.write(&batch).unwrap(); + } + w.close().unwrap(); + } + bytes::Bytes::from(buf) + } + + /// A page-indexed file yields exactly TWO regions — the column-index (CI) whole + /// region first, then the offset-index (OI) whole region — and each region is the + /// tight min..max fold over the corresponding per-column extents in the footer. + #[test] + fn compute_page_index_range_returns_tight_ci_and_oi_regions() { + use parquet::file::properties::EnabledStatistics; + use parquet::file::reader::FileReader; + use parquet::file::serialized_reader::SerializedFileReader; + + let buf = parquet_bytes(3, 2, EnabledStatistics::Page); + let reader = SerializedFileReader::new(buf).unwrap(); + let metadata = reader.metadata(); + + let ranges = compute_page_index_range(metadata); + assert_eq!( + ranges.len(), + 2, + "page-indexed file must yield separate CI and OI regions" + ); + let (ci, oi) = (ranges[0].clone(), ranges[1].clone()); + assert!(ci.end > ci.start, "CI region must be non-empty"); + assert!(oi.end > oi.start, "OI region must be non-empty"); + assert_ne!( + ci, oi, + "CI and OI must be distinct warm-tier keys (not merged)" + ); + + // Compute the expected tight folds independently and compare. + let mut exp_ci: Option> = None; + let mut exp_oi: Option> = None; + for rg in metadata.row_groups() { + for col in rg.columns() { + if let (Some(off), Some(len)) = + (col.column_index_offset(), col.column_index_length()) + { + let (s, e) = (off as u64, off as u64 + len as u64); + exp_ci = Some(match exp_ci { + Some(a) => a.start.min(s)..a.end.max(e), + None => s..e, + }); + } + if let (Some(off), Some(len)) = + (col.offset_index_offset(), col.offset_index_length()) + { + let (s, e) = (off as u64, off as u64 + len as u64); + exp_oi = Some(match exp_oi { + Some(a) => a.start.min(s)..a.end.max(e), + None => s..e, + }); + } + } + } + assert_eq!( + Some(ci), + exp_ci, + "CI region must equal the tight min..max fold of all column_index extents" + ); + assert_eq!( + Some(oi), + exp_oi, + "OI region must equal the tight min..max fold of all offset_index extents" + ); + } + + /// With page statistics disabled the column index is absent (it requires + /// page-level stats), though the writer may still emit an offset index. The + /// function must equal the independent `[CI?, OI?]` fold — skipping absent + /// kinds — which exercises the "one kind present" branch. + #[test] + fn compute_page_index_range_matches_independent_fold_no_page_stats() { + use parquet::file::properties::EnabledStatistics; + use parquet::file::reader::FileReader; + use parquet::file::serialized_reader::SerializedFileReader; + + let buf = parquet_bytes(2, 1, EnabledStatistics::None); + let reader = SerializedFileReader::new(buf).unwrap(); + let metadata = reader.metadata(); + + let mut exp_ci: Option> = None; + let mut exp_oi: Option> = None; + for rg in metadata.row_groups() { + for col in rg.columns() { + if let (Some(off), Some(len)) = + (col.column_index_offset(), col.column_index_length()) + { + let (s, e) = (off as u64, off as u64 + len as u64); + exp_ci = Some(match exp_ci { + Some(a) => a.start.min(s)..a.end.max(e), + None => s..e, + }); + } + if let (Some(off), Some(len)) = + (col.offset_index_offset(), col.offset_index_length()) + { + let (s, e) = (off as u64, off as u64 + len as u64); + exp_oi = Some(match exp_oi { + Some(a) => a.start.min(s)..a.end.max(e), + None => s..e, + }); + } + } + } + let mut expected: Vec> = Vec::new(); + if let Some(r) = exp_ci.clone() { + expected.push(r); + } + if let Some(r) = exp_oi { + expected.push(r); + } + + assert_eq!( + compute_page_index_range(metadata), + expected, + "result must equal the independent [CI?, OI?] fold, skipping absent index kinds" + ); + // Column index requires page-level statistics; with stats disabled it must be absent. + assert!( + exp_ci.is_none(), + "column index must be absent when statistics are disabled" + ); + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/eviction_policy.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/cache/eviction_policy.rs similarity index 68% rename from sandbox/plugins/analytics-backend-datafusion/rust/src/eviction_policy.rs rename to sandbox/plugins/analytics-backend-datafusion/rust/src/cache/eviction_policy.rs index 6fe2a7402b3b8..389652acf15dd 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/eviction_policy.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/cache/eviction_policy.rs @@ -12,6 +12,8 @@ use datafusion::common::instant; use instant::Instant; +use std::sync::atomic::Ordering; +use std::sync::Arc; use thiserror::Error; /// Error types for cache operations @@ -24,35 +26,54 @@ pub enum CacheError { /// Result type for cache operations pub type CacheResult = Result; -/// Core trait for cache eviction policies +/// Core trait for cache eviction policies. +/// +/// All methods take `&self` — implementations use interior mutability +/// (`DashMap`, atomics) so the trait object can be shared as `Arc` +/// without an outer `Mutex`. This means `get` on the parent cache never contends +/// on a global lock, only on the per-shard DashMap locks inside the policy. pub trait CachePolicy: Send + Sync { - /// Called when a cache entry is accessed - fn on_access(&mut self, key: &str, size: usize); + /// Called when a cache entry is accessed (updates recency/frequency metadata). + fn on_access(&self, key: &str, size: usize); - /// Called when a cache entry is inserted - fn on_insert(&mut self, key: &str, size: usize); - /// Called when a cache entry is removed - fn on_remove(&mut self, key: &str); + /// Called when a cache entry is inserted. + fn on_insert(&self, key: &str, size: usize); - /// Select entries for eviction to reach target size - /// Returns keys to evict, ordered by eviction priority + /// Called when a cache entry is removed. + fn on_remove(&self, key: &str); + + /// Select entries for eviction to free at least `target_size` bytes. + /// Returns string keys ordered by eviction priority (lowest-priority first). fn select_for_eviction(&self, target_size: usize) -> Vec; - /// Reset policy state - fn clear(&mut self); + /// Reset all policy state (called on cache clear). + fn clear(&self); - /// Get the name of this policy + /// Name of this policy, for logging/stats. fn policy_name(&self) -> &'static str; } -/// Policy types -#[derive(Debug, Clone)] -pub enum PolicyType { +/// Unified eviction policy selector for all caches in this crate. +/// +/// - `Lru` / `Lfu` — used by `CustomStatisticsCache` and the metadata cache +/// (backed by [`CachePolicy`] + [`create_policy`]). +/// - `Fifo` — used by `BoundedCache` (CI/OI scoped caches) via +/// `ScopedEvictionPolicy` + `FifoPolicy`. +/// +/// One enum for all caches means `df_create_cache` parses the Java-supplied +/// eviction string once and routes uniformly without separate enums per family. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CacheEvictionPolicy { Lru, Lfu, + Fifo, } -/// Simple cache entry metadata +/// Backward-compatible alias — will be removed once all call sites are migrated. +pub type PolicyType = CacheEvictionPolicy; + +/// Metadata tracked per cached entry for eviction ordering. +/// Stored inside the policy's `DashMap`, mutated via `get_mut`. #[derive(Debug, Clone)] pub struct CacheEntryMetadata { pub size: usize, @@ -69,6 +90,8 @@ impl CacheEntryMetadata { } } + /// Update recency and frequency. Called via `DashMap::get_mut` which holds + /// only the per-shard write lock — not a global lock. pub fn on_access(&mut self) { self.last_accessed = Instant::now(); self.access_count += 1; @@ -97,37 +120,31 @@ impl Default for LruPolicy { } impl CachePolicy for LruPolicy { - fn on_access(&mut self, key: &str, size: usize) { + fn on_access(&self, key: &str, size: usize) { match self.entries.get_mut(key) { Some(mut entry) => { entry.on_access(); } None => { + // Entry missing from policy metadata (can happen transiently) — insert it. let metadata = CacheEntryMetadata::new(key.to_string(), size); self.entries.insert(key.to_string(), metadata); - self.total_size - .fetch_add(size, std::sync::atomic::Ordering::Relaxed); + self.total_size.fetch_add(size, Ordering::Relaxed); } } } - fn on_insert(&mut self, key: &str, size: usize) { + fn on_insert(&self, key: &str, size: usize) { let metadata = CacheEntryMetadata::new(key.to_string(), size); - if let Some(old_entry) = self.entries.insert(key.to_string(), metadata) { - let old_size = old_entry.size; - self.total_size - .fetch_sub(old_size, std::sync::atomic::Ordering::Relaxed); + self.total_size.fetch_sub(old_entry.size, Ordering::Relaxed); } - - self.total_size - .fetch_add(size, std::sync::atomic::Ordering::Relaxed); + self.total_size.fetch_add(size, Ordering::Relaxed); } - fn on_remove(&mut self, key: &str) { + fn on_remove(&self, key: &str) { if let Some((_, entry)) = self.entries.remove(key) { - self.total_size - .fetch_sub(entry.size, std::sync::atomic::Ordering::Relaxed); + self.total_size.fetch_sub(entry.size, Ordering::Relaxed); } } @@ -167,10 +184,9 @@ impl CachePolicy for LruPolicy { candidates } - fn clear(&mut self) { + fn clear(&self) { self.entries.clear(); - self.total_size - .store(0, std::sync::atomic::Ordering::Relaxed); + self.total_size.store(0, Ordering::Relaxed); } fn policy_name(&self) -> &'static str { @@ -200,7 +216,7 @@ impl Default for LfuPolicy { } impl CachePolicy for LfuPolicy { - fn on_access(&mut self, key: &str, size: usize) { + fn on_access(&self, key: &str, size: usize) { match self.entries.get_mut(key) { Some(mut entry) => { entry.on_access(); @@ -208,29 +224,22 @@ impl CachePolicy for LfuPolicy { None => { let metadata = CacheEntryMetadata::new(key.to_string(), size); self.entries.insert(key.to_string(), metadata); - self.total_size - .fetch_add(size, std::sync::atomic::Ordering::Relaxed); + self.total_size.fetch_add(size, Ordering::Relaxed); } } } - fn on_insert(&mut self, key: &str, size: usize) { + fn on_insert(&self, key: &str, size: usize) { let metadata = CacheEntryMetadata::new(key.to_string(), size); - if let Some(old_entry) = self.entries.insert(key.to_string(), metadata) { - let old_size = old_entry.size; - self.total_size - .fetch_sub(old_size, std::sync::atomic::Ordering::Relaxed); + self.total_size.fetch_sub(old_entry.size, Ordering::Relaxed); } - - self.total_size - .fetch_add(size, std::sync::atomic::Ordering::Relaxed); + self.total_size.fetch_add(size, Ordering::Relaxed); } - fn on_remove(&mut self, key: &str) { + fn on_remove(&self, key: &str) { if let Some((_, entry)) = self.entries.remove(key) { - self.total_size - .fetch_sub(entry.size, std::sync::atomic::Ordering::Relaxed); + self.total_size.fetch_sub(entry.size, Ordering::Relaxed); } } @@ -273,10 +282,9 @@ impl CachePolicy for LfuPolicy { candidates } - fn clear(&mut self) { + fn clear(&self) { self.entries.clear(); - self.total_size - .store(0, std::sync::atomic::Ordering::Relaxed); + self.total_size.store(0, Ordering::Relaxed); } fn policy_name(&self) -> &'static str { @@ -284,11 +292,19 @@ impl CachePolicy for LfuPolicy { } } -/// Create a cache policy instance -pub fn create_policy(policy_type: PolicyType) -> Box { +/// Create a [`CachePolicy`] (LRU or LFU) from `CacheEvictionPolicy`. +/// +/// Returns `None` for `Fifo` — FIFO uses `ScopedEvictionPolicy` via +/// `BoundedCache::with_named_policy`, not this trait. Callers that receive +/// `None` should route to `BoundedCache` instead. +/// +/// When a new variant is added to `CacheEvictionPolicy`, the compiler will +/// flag the missing arm here. +pub fn create_policy(policy_type: CacheEvictionPolicy) -> Option> { match policy_type { - PolicyType::Lru => Box::new(LruPolicy::new()), - PolicyType::Lfu => Box::new(LfuPolicy::new()), + CacheEvictionPolicy::Lru => Some(Arc::new(LruPolicy::new())), + CacheEvictionPolicy::Lfu => Some(Arc::new(LfuPolicy::new())), + CacheEvictionPolicy::Fifo => None, } } @@ -314,10 +330,10 @@ mod tests { #[test] fn test_create_policy() { - let lru_policy = create_policy(PolicyType::Lru); + let lru_policy = create_policy(PolicyType::Lru).unwrap(); assert_eq!(lru_policy.policy_name(), "lru"); - let lfu_policy = create_policy(PolicyType::Lfu); + let lfu_policy = create_policy(PolicyType::Lfu).unwrap(); assert_eq!(lfu_policy.policy_name(), "lfu"); } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/cache/metadata_cache.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/cache/metadata_cache.rs new file mode 100644 index 0000000000000..2a18cd341f59b --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/cache/metadata_cache.rs @@ -0,0 +1,352 @@ +/* + * 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. + */ + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use crate::parquet_page_cache::is_scoped_page_index_enabled; +use datafusion::datasource::physical_plan::parquet::metadata::CachedParquetMetaData; +use datafusion::execution::cache::cache_manager::{ + CachedFileMetadataEntry, FileMetadataCache, FileMetadataCacheEntry, +}; +use datafusion::execution::cache::CacheAccessor; +use datafusion::execution::cache::DefaultFilesMetadataCache; +use datafusion::parquet::file::metadata::ParquetMetaData; +use native_bridge_common::log_error; +use object_store::path::Path; + +// Cache type constants +pub const CACHE_TYPE_METADATA: &str = "METADATA"; +pub const CACHE_TYPE_STATS: &str = "STATISTICS"; +pub const CACHE_TYPE_COLUMN_INDEX: &str = "COLUMN_INDEX"; +pub const CACHE_TYPE_OFFSET_INDEX: &str = "OFFSET_INDEX"; + +fn log_cache_error(operation: &str, error: &str) { + log_error!("[CACHE ERROR] {} operation failed: {}", operation, error); +} + +/// Return a cache entry whose `ParquetMetaData` carries footer-only metadata (no +/// `ColumnIndex` / `OffsetIndex`). If the entry already lacks a page index — or +/// isn't a `CachedParquetMetaData` at all — it's returned unchanged (no clone, no +/// rebuild). +/// +/// This is the single chokepoint that enforces the footer-only invariant: every +/// `put` runs the entry through here before it lands in the shared LRU. +fn strip_page_index(entry: CachedFileMetadataEntry) -> CachedFileMetadataEntry { + let Some(cached) = entry + .file_metadata + .as_any() + .downcast_ref::() + else { + return entry; + }; + let meta = cached.parquet_metadata(); + if meta.column_index().is_none() && meta.offset_index().is_none() { + // Already footer-only — keep the existing Arc, avoid a rebuild. + return entry; + } + // Rebuild without the page index. The heavy decoded `ColumnIndex` / + // `OffsetIndex` are released when the original Arc drops; the footer + // (row-group + column chunk stats) is preserved. + let stripped = ParquetMetaData::clone(meta) + .into_builder() + .set_column_index(None) + .set_offset_index(None) + .build(); + CachedFileMetadataEntry::new( + entry.meta, + Arc::new(CachedParquetMetaData::new(Arc::new(stripped))), + ) +} + +// Wrapper to make Mutex implement FileMetadataCache +pub struct MutexFileMetadataCache { + pub inner: Mutex, + hit_count: AtomicUsize, + miss_count: AtomicUsize, +} + +impl MutexFileMetadataCache { + pub fn new(cache: DefaultFilesMetadataCache) -> Self { + Self { + inner: Mutex::new(cache), + hit_count: AtomicUsize::new(0), + miss_count: AtomicUsize::new(0), + } + } + + pub fn hit_count(&self) -> usize { + self.hit_count.load(Ordering::Relaxed) + } + + pub fn miss_count(&self) -> usize { + self.miss_count.load(Ordering::Relaxed) + } + + pub fn reset_stats(&self) { + self.hit_count.store(0, Ordering::Relaxed); + self.miss_count.store(0, Ordering::Relaxed); + } + + pub fn clear_cache(&self) { + if let Ok(cache) = self.inner.lock() { + cache.clear(); + } + } + + pub fn update_cache_limit(&self, new_limit: usize) { + if let Ok(cache) = self.inner.lock() { + cache.update_cache_limit(new_limit); + } + } + + pub fn get_cache_limit(&self) -> usize { + if let Ok(cache) = self.inner.lock() { + cache.cache_limit() + } else { + 0 + } + } +} + +impl CacheAccessor for MutexFileMetadataCache { + fn get(&self, k: &Path) -> Option { + match self.inner.lock() { + Ok(cache) => { + let result = cache.get(k); + if result.is_some() { + self.hit_count.fetch_add(1, Ordering::Relaxed); + } else { + self.miss_count.fetch_add(1, Ordering::Relaxed); + } + result + } + Err(e) => { + log_cache_error("get", &e.to_string()); + None + } + } + } + + fn put(&self, k: &Path, v: CachedFileMetadataEntry) -> Option { + // When scoped page-index is enabled: strip ColumnIndex + OffsetIndex from + // the entry before caching. The level-1 cache stays footer-only; page-level + // pruning is handled by the scoped cache (`parquet_page_cache`). + // + // When scoped page-index is disabled (fallback mode): retain the full entry + // including page indexes so DataFusion's default page pruning path continues + // to function. In this mode `load_parquet_metadata` fetches with + // `PageIndexPolicy::ReadAll`, so the full index is present to retain. + let v = if is_scoped_page_index_enabled() { + strip_page_index(v) + } else { + v + }; + match self.inner.lock() { + Ok(cache) => cache.put(k, v), + Err(e) => { + log_cache_error("put", &e.to_string()); + None + } + } + } + + fn remove(&self, k: &Path) -> Option { + match self.inner.lock() { + Ok(cache) => cache.remove(k), + Err(e) => { + log_cache_error("remove", &e.to_string()); + None + } + } + } + + fn contains_key(&self, k: &Path) -> bool { + match self.inner.lock() { + Ok(cache) => cache.contains_key(k), + Err(e) => { + log_cache_error("contains_key", &e.to_string()); + false + } + } + } + + fn len(&self) -> usize { + match self.inner.lock() { + Ok(cache) => cache.len(), + Err(e) => { + log_cache_error("len", &e.to_string()); + 0 + } + } + } + + fn clear(&self) { + match self.inner.lock() { + Ok(cache) => cache.clear(), + Err(e) => log_cache_error("clear", &e.to_string()), + } + } + + fn name(&self) -> String { + match self.inner.lock() { + Ok(cache) => cache.name(), + Err(e) => { + log_cache_error("name", &e.to_string()); + "cache_error".to_string() + } + } + } +} + +impl FileMetadataCache for MutexFileMetadataCache { + fn cache_limit(&self) -> usize { + match self.inner.lock() { + Ok(cache) => cache.cache_limit(), + Err(e) => { + log_cache_error("cache_limit", &e.to_string()); + 0 + } + } + } + + fn update_cache_limit(&self, limit: usize) { + match self.inner.lock() { + Ok(cache) => cache.update_cache_limit(limit), + Err(e) => log_cache_error("update_cache_limit", &e.to_string()), + } + } + + fn list_entries(&self) -> std::collections::HashMap { + match self.inner.lock() { + Ok(cache) => cache.list_entries(), + Err(e) => { + log_cache_error("list_entries", &e.to_string()); + std::collections::HashMap::new() + } + } + } +} + +#[cfg(test)] +mod strip_page_index_tests { + use super::*; + use datafusion::arrow::array::{Int64Array, RecordBatch}; + use datafusion::arrow::datatypes::{DataType, Field, Schema}; + use datafusion::parquet::arrow::arrow_reader::{ArrowReaderMetadata, ArrowReaderOptions}; + use datafusion::parquet::arrow::ArrowWriter; + use datafusion::parquet::file::properties::{EnabledStatistics, WriterProperties}; + use object_store::ObjectMeta; + use prost::bytes::Bytes; + + fn parquet_with_page_index() -> Bytes { + let schema = Arc::new(Schema::new(vec![Field::new("v", DataType::Int64, false)])); + let batch = RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int64Array::from((0..4096i64).collect::>()))], + ) + .unwrap(); + let props = WriterProperties::builder() + .set_statistics_enabled(EnabledStatistics::Page) + .set_data_page_row_count_limit(128) + .build(); + let mut buf: Vec = Vec::new(); + let mut w = ArrowWriter::try_new(&mut buf, schema, Some(props)).unwrap(); + w.write(&batch).unwrap(); + w.close().unwrap(); + Bytes::from(buf) + } + + fn object_meta(bytes: &Bytes) -> ObjectMeta { + ObjectMeta { + location: Path::from("data.parquet"), + last_modified: chrono::Utc::now(), + size: bytes.len() as u64, + e_tag: None, + version: None, + } + } + + fn full_index_entry(bytes: &Bytes) -> CachedFileMetadataEntry { + let meta = ArrowReaderMetadata::load( + &bytes.clone(), + ArrowReaderOptions::new().with_page_index(true), + ) + .unwrap(); + let pq = meta.metadata().clone(); + assert!(pq.column_index().is_some() && pq.offset_index().is_some()); + CachedFileMetadataEntry::new(object_meta(bytes), Arc::new(CachedParquetMetaData::new(pq))) + } + + fn page_index_present(entry: &CachedFileMetadataEntry) -> bool { + let cached = entry + .file_metadata + .as_any() + .downcast_ref::() + .unwrap(); + let m = cached.parquet_metadata(); + m.column_index().is_some() || m.offset_index().is_some() + } + + #[test] + fn put_strips_page_index_and_get_returns_footer_only() { + let bytes = parquet_with_page_index(); + let entry = full_index_entry(&bytes); + assert!( + page_index_present(&entry), + "precondition: entry has page index" + ); + + let cache = MutexFileMetadataCache::new(DefaultFilesMetadataCache::new(64 * 1024 * 1024)); + let key = Path::from("data.parquet"); + cache.put(&key, entry); + + let got = cache.get(&key).expect("entry must be retrievable"); + assert!( + !page_index_present(&got), + "cached entry must be footer-only after put" + ); + let cached = got + .file_metadata + .as_any() + .downcast_ref::() + .unwrap(); + let m = cached.parquet_metadata(); + assert!(m.num_row_groups() > 0); + assert!( + m.row_group(0).column(0).statistics().is_some(), + "footer stats must survive" + ); + } + + #[test] + fn strip_is_noop_for_footer_only_entry() { + let bytes = parquet_with_page_index(); + let meta = ArrowReaderMetadata::load( + &bytes.clone(), + ArrowReaderOptions::new().with_page_index(false), + ) + .unwrap(); + let pq = meta.metadata().clone(); + assert!(pq.column_index().is_none() && pq.offset_index().is_none()); + let entry = CachedFileMetadataEntry::new( + object_meta(&bytes), + Arc::new(CachedParquetMetaData::new(Arc::clone(&pq))), + ); + let stripped = strip_page_index(entry); + let cached = stripped + .file_metadata + .as_any() + .downcast_ref::() + .unwrap(); + assert!( + Arc::ptr_eq(cached.parquet_metadata(), &pq), + "footer-only entry must be returned unchanged (same Arc)" + ); + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/cache/mod.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/cache/mod.rs new file mode 100644 index 0000000000000..52c032825f0dc --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/cache/mod.rs @@ -0,0 +1,40 @@ +/* + * 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. + */ + +//! Cache infrastructure for the analytics backend. +//! +//! # Structure +//! +//! - [`eviction_policy`] — pluggable eviction policy trait (`CachePolicy`) and +//! built-in implementations (`LruPolicy`, `LfuPolicy`). Add new policies here +//! (e.g. S3-FIFO) without touching the cache implementations. +//! - [`metadata_cache`] — `MutexFileMetadataCache`: wraps DataFusion's +//! `DefaultFilesMetadataCache` with hit/miss counters and enforces the +//! footer-only invariant via `strip_page_index` at every `put`. +//! - [`statistics_cache`] — `CustomStatisticsCache`: byte-bounded LRU cache +//! for per-file `Statistics` (row-group min/max/null-count). +//! - [`custom_manager`] — `CustomCacheManager`: ties the metadata and +//! statistics caches together for pre-warming and lifecycle management. +//! - [`page_index`] — scoped parquet page-index caches (ColumnIndex + +//! OffsetIndex), cell-granular and backed by `BoundedCache` / +//! `Box`. + +pub mod custom_cache_manager; +pub mod eviction_policy; +pub mod metadata_cache; +pub mod page_index; +pub mod statistics_cache; + +// Flat re-exports so existing call sites keep working without path changes. +pub use custom_cache_manager::CustomCacheManager; +pub use eviction_policy::{create_policy, CachePolicy, CacheResult, PolicyType}; +pub use metadata_cache::{ + MutexFileMetadataCache, CACHE_TYPE_COLUMN_INDEX, CACHE_TYPE_METADATA, CACHE_TYPE_OFFSET_INDEX, + CACHE_TYPE_STATS, +}; +pub use statistics_cache::{compute_parquet_statistics, CustomStatisticsCache}; diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/cache/page_index/cache_keys.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/cache/page_index/cache_keys.rs new file mode 100644 index 0000000000000..8bcc121a1646e --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/cache/page_index/cache_keys.rs @@ -0,0 +1,65 @@ +/* + * 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. + */ + +//! Cache key types for the two scoped page-index caches. + +use std::fmt::Display; +use std::sync::Arc; + +use parquet::file::page_index::offset_index::OffsetIndexMetaData; + +/// ColumnIndex cache key — one decoded `ColumnIndexMetaData` **cell** per +/// `(file, column, row-group)`. The page index for a given column+RG is an +/// intrinsic property of the file: it is identical no matter which *other* +/// columns a query filters on, or which literal a predicate uses. Keying at the +/// cell granularity means a column's per-page string min/max is decoded and +/// stored **once per file**, then reused by every query whose predicate touches +/// that column — regardless of the predicate-column *combination* or the +/// surviving-row-group *set*. (The prior set-keyed design re-decoded and +/// re-stored a column for every distinct predicate/RG combination — storage grew +/// with query diversity, not schema width.) +/// +/// Both scan paths resolve the same `(file, col, rg)` for the same logical +/// request, so cells are shared across paths → cross-path sharing. +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +pub(crate) struct CiCellKey { + pub(crate) path: Arc, + pub(crate) col: usize, + pub(crate) rg: usize, +} + +impl Display for CiCellKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}:{}", self.path, self.col, self.rg) + } +} + +/// OffsetIndex cache key — one decoded value per `(file, column)`, where the +/// value is that column's `OffsetIndexMetaData` for **every** row group (a +/// `Vec` indexed by RG). Unlike the ColumnIndex, the OffsetIndex is read at scan +/// time for any RG DataFusion chooses to scan — and DataFusion picks that set +/// itself, after our load — so a column's OffsetIndex must always cover all RGs +/// (an empty entry on a scanned RG panics / breaks reads). RG can therefore never +/// be a key axis here; the cell is the whole-column, all-RG offset index. Keyed +/// only on `(file, col)`, so any query that reads a column reuses its offset +/// index irrespective of projection or predicate. +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +pub(crate) struct OiCellKey { + pub(crate) path: Arc, + pub(crate) col: usize, +} + +impl Display for OiCellKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}", self.path, self.col) + } +} + +/// One column's OffsetIndex across all row groups (indexed by RG). The value type +/// of [`OFFSET_INDEX_CACHE`]. +pub(crate) type OiColumn = Vec; diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/cache/page_index/cache_store.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/cache/page_index/cache_store.rs new file mode 100644 index 0000000000000..76925e7859f81 --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/cache/page_index/cache_store.rs @@ -0,0 +1,738 @@ +/* + * 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. + */ + +//! Byte-bounded concurrent cache with a pluggable eviction policy. +//! +//! # Architecture +//! +//! ```text +//! BoundedCache> +//! ├── DashMap ← lock-free concurrent reads +//! ├── Mutex<{ used_bytes, limit }> ← write-path accounting only +//! └── P (eviction policy) ← owns the eviction queue, uses interior mutability +//! ``` +//! +//! **Reads are lock-free** — `get` only touches the `DashMap` shard lock. +//! +//! **Writes take one `parking_lot::Mutex`** for `used_bytes` + limit accounting. +//! The eviction policy is called inside the lock for consistency. +//! +//! **Pluggable eviction**: [`ScopedEvictionPolicy`] is the trait. Today +//! [`FifoPolicy`] is the only implementation. S3-FIFO (ghost-set promotion) +//! can be added later by implementing the same trait. +//! +//! **Lazy deletion on overwrite**: re-inserting an existing key leaves a stale +//! entry in the eviction queue. [`BoundedCache::drain_to_limit`] skips stale +//! victims with a single `DashMap::remove` miss — O(1) overwrite, no O(n) +//! retain scan. +//! +//! **`evict_by_prefix`** is the only O(n) operation: it scans the eviction +//! queue to remove all cells for a deleted/replaced file. This is a rare +//! cold-path operation (file deletion or merge), not on the query hot path. + +use std::cell::UnsafeCell; +use std::collections::VecDeque; +use std::fmt::Display; +use std::hash::Hash; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering::Relaxed}; + +use crate::eviction_policy::CacheEvictionPolicy; +use dashmap::DashMap; +use parking_lot::Mutex; + +/// Fallback byte budget used only in tests that bypass the Java startup path. +/// In production the Java settings consumer always calls +/// `set_column_index_cache_limit` / `set_offset_index_cache_limit` during +/// plugin initialization before any query runs, so this value is never used +/// outside tests. +pub(crate) const DEFAULT_SCOPED_CACHE_LIMIT: usize = 150 * 1024 * 1024; + +// ── Eviction policy trait ──────────────────────────────────────────────────── + +/// Pluggable eviction policy for [`BoundedCache`]. +/// +/// Implementations must use interior mutability (`Mutex`, atomics, etc.) so +/// they can be called through a shared reference. All methods are called from +/// inside the `BoundedCache` write lock, so implementations do not need their +/// own locks — but must still be `Send + Sync` for the `Lazy` +/// statics. +/// +/// # Implementing a new policy +/// +/// 1. Implement this trait with interior mutability. +/// 2. Wire it in `mod.rs` by passing it to `BoundedCache::new`. +/// 3. Expose a `PolicyType` variant in the Java API and route it from +/// `df_create_cache` in `ffm.rs`. +pub(super) trait ScopedEvictionPolicy: Send + Sync { + /// Called when a new entry is inserted (or an existing key is overwritten). + /// The policy should record `key` as the most-recently inserted entry. + fn on_insert(&self, key: K); + + /// Called when an entry is accessed on the hot read path. + /// FIFO ignores this; frequency-aware policies (LFU, S3-FIFO) update + /// access counts here. + fn on_access(&self, key: &K); + + /// Pop and return the next eviction candidate. + /// + /// Called repeatedly by [`BoundedCache::drain_to_limit`] until + /// `used_bytes <= limit`. The caller performs lazy deletion: if the + /// returned key is no longer in the `DashMap` (stale entry from an + /// overwrite), `drain_to_limit` skips it and calls `next_victim` again. + fn next_victim(&self) -> Option; + + /// Called when an entry is explicitly removed (prefix eviction, clear). + /// Allows the policy to remove the entry from its internal bookkeeping. + fn on_remove(&self, key: &K); + + /// Reset all internal state (called on `clear_keep_limit`). + fn clear(&self); +} + +// ── FIFO policy ────────────────────────────────────────────────────────────── + +/// Insert-order (FIFO) eviction policy. +/// +/// Oldest-inserted entry is evicted first. Access order is not tracked — reads +/// are fully lock-free. Stale entries (from overwrites) are left in the queue +/// and skipped by the `BoundedCache` eviction loop (lazy deletion). +/// +/// # No inner lock +/// +/// All `ScopedEvictionPolicy` methods are called exclusively from inside +/// `BoundedCache`'s outer `Mutex` lock, which already serializes +/// all mutations. An additional inner lock on the queue would be a +/// no-contention lock/unlock cycle with zero benefit. We use `UnsafeCell` +/// instead, with the outer lock as the synchronization invariant. +/// +/// # Safety invariant +/// +/// Every method that calls `queue_mut()` or `queue_ref()` must be called while +/// the `BoundedCache` write lock is held. `on_access` is a no-op for FIFO and +/// is called from the lock-free `get` path — it does not touch the queue. +/// +/// S3-FIFO (planned successor) wraps two FIFO queues with a ghost set. If its +/// `on_access` needs to update state from the read path, it would need its own +/// interior mutability (e.g. atomics for frequency counters). +pub(super) struct FifoPolicy { + /// Guarded by the outer `BoundedCache` write lock — no inner lock needed. + queue: UnsafeCell>, +} + +// SAFETY: `FifoPolicy` is only mutated under `BoundedCache`'s `Mutex`. +unsafe impl Send for FifoPolicy {} +unsafe impl Sync for FifoPolicy {} + +impl FifoPolicy { + pub(super) fn new() -> Self { + Self { + queue: UnsafeCell::new(VecDeque::new()), + } + } + + /// Borrow the queue mutably. Caller must hold the outer write lock. + #[inline] + fn queue_mut(&self) -> &mut VecDeque { + // SAFETY: caller holds the `BoundedCache` write lock. + unsafe { &mut *self.queue.get() } + } +} + +impl ScopedEvictionPolicy for FifoPolicy { + fn on_insert(&self, key: K) { + self.queue_mut().push_back(key); + } + + fn on_access(&self, _key: &K) { + // FIFO does not track access order — no queue mutation, reads stay lock-free. + } + + fn next_victim(&self) -> Option { + self.queue_mut().pop_front() + } + + fn on_remove(&self, key: &K) { + // O(n) scan — only called from evict_by_prefix (cold path). + self.queue_mut().retain(|k| k != key); + } + + fn clear(&self) { + self.queue_mut().clear(); + } +} + +// ── Write-lock state ───────────────────────────────────────────────────────── + +/// Mutable accounting serialised under the `BoundedCache` write lock. +/// The eviction queue lives in the policy; this struct tracks only bytes. +struct WriteState { + used_bytes: usize, + limit: usize, +} + +impl WriteState { + fn new(limit: usize) -> Self { + Self { + used_bytes: 0, + limit, + } + } +} + +// ── BoundedCache ───────────────────────────────────────────────────────────── + +/// Snapshot of one scoped cache's counters plus occupancy. +/// Surfaced on node-stats and used by tests. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct ScopedCacheStats { + pub hits: u64, + pub misses: u64, + pub evictions: u64, + pub entries: usize, + pub used_bytes: usize, + pub limit_bytes: usize, +} + +/// Byte-bounded concurrent cache parameterised over an eviction policy `P`. +/// +/// `K` must implement `Display` so `evict_by_prefix` can match keys by their +/// string representation (e.g. `"path:col:rg"` → prefix `"path"`). +pub(super) struct BoundedCache> +where + K: Eq + Hash + Clone + Display + Send + Sync + 'static, + V: Clone + Send + Sync + 'static, + P: ScopedEvictionPolicy, +{ + /// Value store — DashMap for lock-free concurrent reads. + map: DashMap, + /// Byte accounting under one lock. The eviction queue lives in `policy`. + write: Mutex, + /// Eviction policy — uses interior mutability, called inside `write` lock. + policy: P, + /// Byte cap snapshot for lock-free `stats()`. + limit_snapshot: AtomicUsize, + // Lock-free diagnostic counters. + hits: AtomicU64, + misses: AtomicU64, + evictions: AtomicU64, + used_bytes_snapshot: AtomicUsize, +} + +impl BoundedCache> +where + K: Eq + Hash + Clone + Display + Send + Sync + 'static, + V: Clone + Send + Sync + 'static, +{ + /// Create a `BoundedCache` with the named eviction policy. + /// + /// Only `Fifo` is accepted — `Lru`/`Lfu` belong to `CustomStatisticsCache`. + /// The match is exhaustive over `CacheEvictionPolicy` so adding a new + /// variant (e.g. `S3Fifo`) will be a compile error here until it's wired. + pub(crate) fn with_named_policy(limit: usize, policy: CacheEvictionPolicy) -> Self { + use crate::cache::eviction_policy::CacheEvictionPolicy; + let CacheEvictionPolicy::Fifo = policy else { + unreachable!("BoundedCache only supports Fifo; use CustomStatisticsCache for Lru/Lfu"); + }; + Self::with_policy(limit, FifoPolicy::new()) + } + + /// Create a new cache with FIFO eviction — shorthand for tests. + pub(super) fn new(limit: usize) -> Self { + Self::with_named_policy(limit, CacheEvictionPolicy::Fifo) + } +} + +impl BoundedCache +where + K: Eq + Hash + Clone + Display + Send + Sync + 'static, + V: Clone + Send + Sync + 'static, + P: ScopedEvictionPolicy, +{ + /// Create a cache with an explicit policy. Use this when plugging in a + /// non-default policy (e.g. S3-FIFO in a future PR). + pub(super) fn with_policy(limit: usize, policy: P) -> Self { + Self { + map: DashMap::new(), + write: Mutex::new(WriteState::new(limit)), + policy, + limit_snapshot: AtomicUsize::new(limit), + hits: AtomicU64::new(0), + misses: AtomicU64::new(0), + evictions: AtomicU64::new(0), + used_bytes_snapshot: AtomicUsize::new(0), + } + } + + /// Lock-free read. Contends only on the DashMap per-shard read lock. + pub(super) fn get(&self, key: &K) -> Option { + match self.map.get(key) { + Some(entry) => { + self.policy.on_access(key); + self.hits.fetch_add(1, Relaxed); + Some(entry.0.clone()) + } + None => { + self.misses.fetch_add(1, Relaxed); + None + } + } + } + + /// Insert a key-value pair with its byte cost. + /// + /// If `size > limit` the entry is silently dropped. Takes the write lock + /// for accounting; IO must be done by the caller before calling this. + pub(super) fn insert(&self, key: K, value: V, size: usize) { + let limit = self.limit_snapshot.load(Relaxed); + if size > limit { + return; + } + + let old_size = self + .map + .insert(key.clone(), (value, size)) + .map(|(_, s)| s) + .unwrap_or(0); + + let mut w = self.write.lock(); + + if old_size > 0 { + // Overwrite: update byte accounting. Stale FIFO entry is left in + // place for lazy deletion by drain_to_limit. + w.used_bytes = w.used_bytes.saturating_sub(old_size); + } + + w.used_bytes += size; + self.policy.on_insert(key); + + let evicted = self.drain_to_limit(&mut w); + self.used_bytes_snapshot.store(w.used_bytes, Relaxed); + drop(w); + + if evicted > 0 { + self.evictions.fetch_add(evicted, Relaxed); + } + } + + /// Insert multiple entries under a single write-lock acquisition. + /// + /// Preferred when a query produces several cells at once (e.g. all CI + /// cells for a vectored multi-column fetch). One lock + one eviction pass + /// instead of N separate calls. + pub(super) fn insert_batch(&self, entries: impl IntoIterator) { + let limit = self.limit_snapshot.load(Relaxed); + + // DashMap updates are outside the write lock — independently concurrent. + let mut to_account: Vec<(K, usize, usize)> = Vec::new(); + for (key, value, size) in entries { + if size > limit { + continue; + } + let old_size = self + .map + .insert(key.clone(), (value, size)) + .map(|(_, s)| s) + .unwrap_or(0); + to_account.push((key, size, old_size)); + } + + if to_account.is_empty() { + return; + } + + let mut w = self.write.lock(); + for (key, size, old_size) in to_account { + if old_size > 0 { + w.used_bytes = w.used_bytes.saturating_sub(old_size); + } + w.used_bytes += size; + self.policy.on_insert(key); + } + + let evicted = self.drain_to_limit(&mut w); + self.used_bytes_snapshot.store(w.used_bytes, Relaxed); + drop(w); + + if evicted > 0 { + self.evictions.fetch_add(evicted, Relaxed); + } + } + + /// Evict policy victims until `used_bytes <= limit`. + /// Stale victims (overwritten entries) are skipped via lazy deletion. + /// Must be called inside the write lock. + fn drain_to_limit(&self, w: &mut WriteState) -> u64 { + let mut evicted = 0u64; + while w.used_bytes > w.limit { + let Some(victim) = self.policy.next_victim() else { + break; + }; + if let Some((_, (_, size))) = self.map.remove(&victim) { + w.used_bytes = w.used_bytes.saturating_sub(size); + evicted += 1; + } + // If map.remove returned None: stale entry, used_bytes already + // adjusted at overwrite time — skip without double-decrementing. + } + evicted + } + + /// Update the byte cap and immediately evict if over budget. + pub(super) fn set_limit(&self, limit: usize) { + self.limit_snapshot.store(limit, Relaxed); + let mut w = self.write.lock(); + w.limit = limit; + let evicted = self.drain_to_limit(&mut w); + self.used_bytes_snapshot.store(w.used_bytes, Relaxed); + drop(w); + if evicted > 0 { + self.evictions.fetch_add(evicted, Relaxed); + } + } + + /// Drop all entries and reset counters, keeping the current limit. + pub(super) fn clear_keep_limit(&self) { + self.map.clear(); + let mut w = self.write.lock(); + w.used_bytes = 0; + self.policy.clear(); + drop(w); + self.hits.store(0, Relaxed); + self.misses.store(0, Relaxed); + self.evictions.store(0, Relaxed); + self.used_bytes_snapshot.store(0, Relaxed); + } + + /// Evict all entries whose `Display` representation starts with `prefix`. + /// Used when a parquet file is deleted or replaced. + /// + /// O(n) scan of the eviction queue — cold path only (file deletion/merge). + pub(super) fn evict_by_prefix(&self, prefix: &str) { + // Collect victims by iterating the DashMap (avoids holding the write + // lock while doing string comparisons on every FIFO entry). + let victims: Vec = self + .map + .iter() + .filter(|e| e.key().to_string().starts_with(prefix)) + .map(|e| e.key().clone()) + .collect(); + + if victims.is_empty() { + return; + } + + let mut w = self.write.lock(); + let mut evicted = 0u64; + for victim in &victims { + if let Some((_, (_, size))) = self.map.remove(victim) { + w.used_bytes = w.used_bytes.saturating_sub(size); + evicted += 1; + } + self.policy.on_remove(victim); + } + self.used_bytes_snapshot.store(w.used_bytes, Relaxed); + drop(w); + + if evicted > 0 { + self.evictions.fetch_add(evicted, Relaxed); + } + } + + /// Lock-free stats snapshot. + pub(super) fn stats(&self) -> ScopedCacheStats { + ScopedCacheStats { + hits: self.hits.load(Relaxed), + misses: self.misses.load(Relaxed), + evictions: self.evictions.load(Relaxed), + entries: self.map.len(), + used_bytes: self.used_bytes_snapshot.load(Relaxed), + limit_bytes: self.limit_snapshot.load(Relaxed), + } + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + #[derive(Clone, PartialEq, Eq, Hash)] + struct Key(String); + impl std::fmt::Display for Key { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } + } + impl Key { + fn new(s: impl Into) -> Self { + Key(s.into()) + } + } + + fn make_cache(limit: usize) -> BoundedCache> { + BoundedCache::new(limit) + } + + // ── basic correctness ───────────────────────────────────────────────────── + + #[test] + fn insert_and_get_roundtrip() { + let c = make_cache(1024); + c.insert(Key::new("a"), vec![1, 2, 3], 3); + assert_eq!(c.get(&Key::new("a")), Some(vec![1, 2, 3])); + assert_eq!(c.get(&Key::new("missing")), None); + } + + #[test] + fn used_bytes_tracks_inserts_and_evictions() { + let c = make_cache(10); + c.insert(Key::new("a"), vec![], 4); + c.insert(Key::new("b"), vec![], 4); + assert_eq!(c.stats().used_bytes, 8); + c.insert(Key::new("c"), vec![], 4); + let s = c.stats(); + assert!( + s.used_bytes <= 10, + "used_bytes={} must be <= limit=10", + s.used_bytes + ); + assert!(s.evictions >= 1); + } + + #[test] + fn overwrite_does_not_leak_bytes() { + let c = make_cache(1024); + c.insert(Key::new("a"), vec![1], 10); + c.insert(Key::new("a"), vec![2], 5); + assert_eq!(c.stats().used_bytes, 5); + assert_eq!(c.get(&Key::new("a")), Some(vec![2])); + } + + #[test] + fn entry_too_large_is_dropped() { + let c = make_cache(10); + c.insert(Key::new("huge"), vec![], 11); + assert_eq!(c.get(&Key::new("huge")), None); + assert_eq!(c.stats().used_bytes, 0); + } + + #[test] + fn evict_by_prefix_removes_matching_entries() { + let c = make_cache(1024); + c.insert(Key::new("file1:col0"), vec![], 10); + c.insert(Key::new("file1:col1"), vec![], 10); + c.insert(Key::new("file2:col0"), vec![], 10); + c.evict_by_prefix("file1"); + assert_eq!(c.get(&Key::new("file1:col0")), None); + assert_eq!(c.get(&Key::new("file1:col1")), None); + assert_eq!(c.get(&Key::new("file2:col0")), Some(vec![])); + assert_eq!(c.stats().used_bytes, 10); + } + + #[test] + fn clear_resets_all_state() { + let c = make_cache(1024); + c.insert(Key::new("a"), vec![], 10); + c.insert(Key::new("b"), vec![], 20); + c.clear_keep_limit(); + let s = c.stats(); + assert_eq!(s.used_bytes, 0); + assert_eq!(s.entries, 0); + assert_eq!(s.hits, 0); + assert_eq!(s.misses, 0); + assert_eq!(s.evictions, 0); + assert_eq!(c.limit_snapshot.load(Relaxed), 1024); + } + + #[test] + fn insert_batch_one_lock_same_result_as_individual() { + let c1 = make_cache(1024); + let c2 = make_cache(1024); + let entries = vec![ + (Key::new("a"), vec![1u8], 10), + (Key::new("b"), vec![2u8], 20), + (Key::new("c"), vec![3u8], 30), + ]; + for (k, v, s) in entries.clone() { + c1.insert(k, v, s); + } + c2.insert_batch(entries.into_iter()); + assert_eq!(c1.get(&Key::new("a")), c2.get(&Key::new("a"))); + assert_eq!(c1.get(&Key::new("b")), c2.get(&Key::new("b"))); + assert_eq!(c1.get(&Key::new("c")), c2.get(&Key::new("c"))); + assert_eq!(c1.stats().used_bytes, c2.stats().used_bytes); + } + + // ── pluggable policy ────────────────────────────────────────────────────── + + /// Verify that `with_policy` compiles and works with a custom policy, + /// proving the extensibility point works without modifying `BoundedCache`. + #[test] + fn custom_policy_compiles_and_works() { + // A trivial "always evict the first key ever inserted" policy (same as FIFO). + let cache: BoundedCache, FifoPolicy> = + BoundedCache::with_policy(20, FifoPolicy::new()); + cache.insert(Key::new("x"), vec![], 10); + cache.insert(Key::new("y"), vec![], 10); + cache.insert(Key::new("z"), vec![], 10); // triggers eviction of "x" + assert_eq!(cache.get(&Key::new("x")), None, "x must have been evicted"); + assert!(cache.stats().used_bytes <= 20); + } + + // ── concurrency ─────────────────────────────────────────────────────────── + + #[test] + fn concurrent_inserts_stay_within_limit() { + const THREADS: usize = 8; + const PER_THREAD: usize = 200; + const ENTRY_SIZE: usize = 10; + const LIMIT: usize = THREADS * PER_THREAD * ENTRY_SIZE / 4; + + let cache = Arc::new(make_cache(LIMIT)); + let mut handles = vec![]; + + for t in 0..THREADS { + let c = Arc::clone(&cache); + handles.push(std::thread::spawn(move || { + for i in 0..PER_THREAD { + c.insert(Key::new(format!("t{t}:k{i}")), vec![t as u8], ENTRY_SIZE); + } + })); + } + for h in handles { + h.join().unwrap(); + } + + let s = cache.stats(); + assert!( + s.used_bytes <= LIMIT, + "used_bytes={} > limit={}", + s.used_bytes, + LIMIT + ); + let map_total: usize = cache.map.iter().map(|e| e.value().1).sum(); + assert_eq!(map_total, s.used_bytes); + } + + #[test] + fn concurrent_reads_and_writes_no_panic() { + let cache = Arc::new(make_cache(200)); + let mut handles = vec![]; + for t in 0..4usize { + let c = Arc::clone(&cache); + handles.push(std::thread::spawn(move || { + for i in 0..500 { + c.insert(Key::new(format!("k{}", i % 20)), vec![t as u8], 10); + } + })); + } + for _ in 0..4 { + let c = Arc::clone(&cache); + handles.push(std::thread::spawn(move || { + for i in 0..500 { + let _ = c.get(&Key::new(format!("k{}", i % 20))); + } + })); + } + for h in handles { + h.join().unwrap(); + } + let s = cache.stats(); + assert!(s.used_bytes <= 200); + let map_total: usize = cache.map.iter().map(|e| e.value().1).sum(); + assert_eq!(map_total, s.used_bytes); + } + + #[test] + fn concurrent_insert_and_evict_by_prefix_consistent() { + let cache = Arc::new(make_cache(4096)); + let mut handles = vec![]; + for t in 0..3usize { + let c = Arc::clone(&cache); + handles.push(std::thread::spawn(move || { + for i in 0..300 { + c.insert(Key::new(format!("hot/t{t}:k{i}")), vec![], 10); + } + })); + } + for t in 0..3usize { + let c = Arc::clone(&cache); + handles.push(std::thread::spawn(move || { + for i in 0..300 { + c.insert(Key::new(format!("cold/t{t}:k{i}")), vec![], 10); + } + })); + } + for h in handles { + h.join().unwrap(); + } + cache.evict_by_prefix("hot/"); + for entry in cache.map.iter() { + assert!(!entry.key().0.starts_with("hot/")); + } + let map_total: usize = cache.map.iter().map(|e| e.value().1).sum(); + assert_eq!(map_total, cache.stats().used_bytes); + } + + #[test] + fn concurrent_insert_batch_consistent() { + const THREADS: usize = 8; + const LIMIT: usize = THREADS * 50 * 5 * 8 / 3; + let cache = Arc::new(make_cache(LIMIT)); + let mut handles = vec![]; + for t in 0..THREADS { + let c = Arc::clone(&cache); + handles.push(std::thread::spawn(move || { + for b in 0..50usize { + let batch = (0..5usize) + .map(|i| (Key::new(format!("t{t}:b{b}:i{i}")), vec![t as u8], 8)); + c.insert_batch(batch); + } + })); + } + for h in handles { + h.join().unwrap(); + } + let s = cache.stats(); + assert!(s.used_bytes <= LIMIT); + let map_total: usize = cache.map.iter().map(|e| e.value().1).sum(); + assert_eq!(map_total, s.used_bytes); + } + + #[test] + fn concurrent_set_limit_and_inserts_consistent() { + let cache = Arc::new(make_cache(1000)); + let mut handles = vec![]; + for t in 0..4usize { + let c = Arc::clone(&cache); + handles.push(std::thread::spawn(move || { + for i in 0..400 { + c.insert(Key::new(format!("t{t}:k{i}")), vec![], 10); + } + })); + } + let c2 = Arc::clone(&cache); + handles.push(std::thread::spawn(move || { + for _ in 0..20 { + c2.set_limit(200); + std::hint::spin_loop(); + c2.set_limit(1000); + } + })); + for h in handles { + h.join().unwrap(); + } + let limit = cache.limit_snapshot.load(Relaxed); + let s = cache.stats(); + assert!(s.used_bytes <= limit); + let map_total: usize = cache.map.iter().map(|e| e.value().1).sum(); + assert_eq!(map_total, s.used_bytes); + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/cache/page_index/column_schema_resolver.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/cache/page_index/column_schema_resolver.rs new file mode 100644 index 0000000000000..984d387446dcb --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/cache/page_index/column_schema_resolver.rs @@ -0,0 +1,114 @@ +/* + * 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. + */ + +//! Predicate-column name → parquet leaf-index resolution. +//! +//! Resolution is done against the file's OWN schema (derived from the footer) +//! rather than the shared table schema to ensure correct leaf indices under +//! schema evolution (see [`resolve_predicate_parquet_columns`] for details). + +use std::collections::HashSet; +use std::sync::Arc; + +use arrow::datatypes::SchemaRef; +use datafusion::parquet::arrow::arrow_reader::statistics::StatisticsConverter; +use datafusion::parquet::file::metadata::ParquetMetaData; +use parquet::arrow::parquet_to_arrow_schema; + +/// Map the query's predicate-column names to **this file's** parquet leaf +/// indices, resolving against the file's OWN schema so the indices are correct +/// even when the file is missing columns (schema evolution). +/// +/// # Why the file's own schema, not the shared table schema +/// +/// `StatisticsConverter`/`parquet_column` map a column by finding its position in +/// the supplied arrow schema and then matching that position to a parquet leaf +/// (`get_column_root_idx`). The table schema is the **union** of all +/// files' columns [N]; a given file may physically contain fewer[M] (e.g. +/// the merged file has M leaves — the absent columns are all-null and not +/// written). Resolving against the N-field union therefore maps a column to the +/// WRONG leaf in a M-leaf file. We would then build +/// the scoped ColumnIndex/OffsetIndex at the wrong leaf and leave the real one an +/// empty placeholder — and DataFusion's pruner, which resolves against the file's +/// physical schema, reads the real leaf and panics on the empty `page_locations` +/// (`statistics.rs` `page_locations.last().unwrap()`). +/// +/// Deriving the arrow schema from the file footer (`parquet_to_arrow_schema`) +/// gives a 1:1 field↔leaf correspondence for that file, so the resolved index +/// matches what DataFusion dereferences. Columns absent from the file are skipped. +pub fn resolve_predicate_parquet_columns( + _arrow_schema: &SchemaRef, + metadata: &ParquetMetaData, + predicate_column_names: &[String], +) -> Vec { + let parquet_schema = metadata.file_metadata().schema_descr(); + // Per-file arrow schema: 1:1 with this file's parquet leaves, so a column's + // arrow position maps to its true leaf. (The passed `_arrow_schema` is the + // union table schema and is intentionally NOT used for index resolution — + // see the doc comment.) + let file_arrow_schema = match parquet_to_arrow_schema( + parquet_schema, + metadata.file_metadata().key_value_metadata(), + ) { + Ok(s) => Arc::new(s), + // If we can't derive the file schema (malformed footer, unsupported type), + // return empty. Empty is the safe conservative choice: the caller skips the + // scoped load and falls back to footer-only. + Err(_) => return vec![], + }; + resolve_with_schema(&file_arrow_schema, metadata, predicate_column_names) +} + +/// Resolve TWO name-sets (e.g. predicate columns and projection columns) against +/// the same file in one pass. Deriving the per-file arrow schema +/// (`parquet_to_arrow_schema`) is the dominant cost of name→leaf resolution on +/// wide schemas (it rebuilds the whole file's Schema); the two callers in the +/// indexed setup loop previously each rebuilt it, so doing it once here removes a +/// full schema reconstruction per file per query. Pure refactor — each returned +/// Vec is identical to calling `resolve_predicate_parquet_columns` separately. +pub fn resolve_predicate_parquet_columns_pair( + _union_schema: &SchemaRef, + metadata: &ParquetMetaData, + predicate_col_names: &[String], + projection_col_names: &[String], +) -> (Vec, Vec) { + let parquet_schema = metadata.file_metadata().schema_descr(); + match parquet_to_arrow_schema( + parquet_schema, + metadata.file_metadata().key_value_metadata(), + ) { + Ok(s) => { + let file_arrow_schema = Arc::new(s); + ( + resolve_with_schema(&file_arrow_schema, metadata, predicate_col_names), + resolve_with_schema(&file_arrow_schema, metadata, projection_col_names), + ) + } + // Can't derive the file schema — return empty for both sets. + Err(_) => (vec![], vec![]), + } +} + +/// Resolve predicate column names → parquet leaf indices against a specific arrow +/// schema, via the same `StatisticsConverter` mapping DataFusion's pruner uses. +pub(super) fn resolve_with_schema( + arrow_schema: &SchemaRef, + metadata: &ParquetMetaData, + predicate_column_names: &[String], +) -> Vec { + let parquet_schema = metadata.file_metadata().schema_descr(); + let mut set = HashSet::new(); + for name in predicate_column_names { + if let Ok(conv) = StatisticsConverter::try_new(name, arrow_schema, parquet_schema) { + if let Some(idx) = conv.parquet_column_index() { + set.insert(idx); + } + } + } + set.into_iter().collect() +} diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/cache/page_index/mod.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/cache/page_index/mod.rs new file mode 100644 index 0000000000000..003271e018f8d --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/cache/page_index/mod.rs @@ -0,0 +1,222 @@ +/* + * 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. + */ + +//! Scoped parquet page-index caches — TWO caches, by consumer. +//! +//! # Why this exists +//! +//! Parquet metadata loading pulls the **entire page index** — `ColumnIndex` +//! (per-page min/max; the per-page *string* min/max is the heap hog) plus +//! `OffsetIndex` (per-page byte offsets), for every column of every row group. +//! On wide schemas this is very memory expensive. +//! The level-1 metadata cache is kept footer-only (see +//! [`crate::cache`]); this module rebuilds a *scoped* page index per query and +//! caches it, shared by both scan paths (the DataFusion `ListingTable` path and +//! the custom indexed-table executor). +//! +//! # Two caches, because the two indexes have different drivers +//! +//! The `ColumnIndex` and `OffsetIndex` are consumed by different parts of +//! DataFusion / parquet, with **different natural cache keys**. Forcing +//! them into one key makes the projection-driven OffsetIndex poison the +//! predicate-driven ColumnIndex's broad cross-path sharing (the failure mode of +//! the prior iteration). So they are split: +//! +//! - **ColumnIndex — predicate-driven.** Read only at *prune* time, and only for +//! the predicate column being evaluated +//! (`page_filter::PagesPruningStatistics`, `offset_index[rg][predicate_col]`). +//! Key: `(file, predicate_cols, surviving_rgs)`. Deterministic in the +//! *predicate* (independent of what you `SELECT`), so the same filter shares +//! its entry across scan paths **and** across queries with different +//! projections. This is the heavy index (string min/max) and the big heap win. +//! Scoped to predicate columns (`NONE` placeholders elsewhere) and, optionally, +//! to the row groups that pass footer-stats pruning ([`surviving_row_groups`]). +//! +//! - **OffsetIndex — projection-driven.** Read at *scan* time for **projected** +//! columns (`InMemoryRowGroup::fetch_ranges`, `projection.leaf_included(idx)`), +//! and at prune time for the predicate column, and at column 0 for the +//! page-skip metric. Key: `(file, projection_cols)` where +//! `projection_cols = predicate ∪ projection ∪ {0}`. This is the cheap, fixed-width +//! index (no per-page string stats). Built for **all row groups** (an empty +//! OffsetIndex on a row group DataFusion scans panics / breaks reads, and +//! DataFusion chooses the scanned set itself, after our load). +//! +//! Each cache stores only its decoded vector (`ParquetColumnIndex` / +//! `ParquetOffsetIndex`) — never a full `ParquetMetaData` (no footer +//! duplication). On lookup the two are **grafted** onto the caller's +//! already-resident footer via [`ParquetMetaData::into_builder`] → +//! `set_column_index`/`set_offset_index`. +//! +//! **Consequence for tests:** a lookup returns a *fresh* `Arc`, so `Arc::ptr_eq` +//! is the wrong signal for "served from cache" — assert via the per-cache hit +//! counters ([`column_index_cache_stats`] / [`offset_index_cache_stats`]). +//! +//! ## Correctness / fallback +//! +//! Any failure (file has no page index, a column lacks an index range, a +//! decode/IO error) makes the load return `None`. The caller keeps its +//! footer-only metadata and the pruner conservatively no-ops (scans the whole +//! row group) — never a wrong result. +//! +//! ## Upstream note +//! +//! arrow-rs is moving toward first-class selective metadata decoding +//! (apache/arrow-rs#8643 open; the `ParquetStatisticsPolicy::skip_except` pattern +//! merged in #8797 / #8714 for encoding stats). None yet expose a page-index +//! column/row-group projection, so we hand-roll it with the deprecated +//! [`read_columns_indexes`]/[`read_offset_indexes`] (the only public subset +//! decoders). Migrate to `ParquetMetaDataOptions` when it grows a page-index knob. + +pub mod cache_keys; +pub mod cache_store; +pub mod column_schema_resolver; +pub mod page_index_io; + +use crate::cache::eviction_policy::CacheEvictionPolicy; +use cache_keys::{CiCellKey, OiCellKey, OiColumn}; +use cache_store::{BoundedCache, DEFAULT_SCOPED_CACHE_LIMIT}; + +use datafusion::parquet::file::page_index::column_index::ColumnIndexMetaData; +use once_cell::sync::Lazy; +use std::sync::atomic::{AtomicBool, Ordering}; + +/// Process-global kill-switch for the scoped page-index feature. +/// When false, `metadata_cache::put` retains the full page index (fallback mode), +/// and the scoped optimizer/augmentation loops are no-ops. +/// Toggled via `datafusion.scoped_page_index.enabled` dynamic cluster setting. +pub(crate) static SCOPED_PAGE_INDEX_ENABLED: AtomicBool = AtomicBool::new(true); + +/// Returns true when the scoped page-index feature is enabled (default). +pub fn is_scoped_page_index_enabled() -> bool { + SCOPED_PAGE_INDEX_ENABLED.load(Ordering::Relaxed) +} + +/// Enable or disable the scoped page-index feature. Called from the Java settings consumer. +pub fn set_scoped_page_index_enabled(enabled: bool) { + SCOPED_PAGE_INDEX_ENABLED.store(enabled, Ordering::Relaxed); +} + +/// Whether the page-index loader fetches each file's WHOLE index region in one +/// `get_ranges` (decode stays scoped) instead of per-column scoped fetches. +/// +/// Only worthwhile on a remote/warm object store: the warm tier is keyed by the +/// exact byte-range string, and eager shard-init populates the whole-region range, +/// so a narrower per-column fetch computes a different key and misses. Fetching the +/// whole region makes the query's key match what warming wrote → a warm hit. For +/// local stores there is no warm tier and fewer bytes is better, so it stays off. +/// Set from `create_reader` when a Java-supplied (remote) store is wired in. +pub(crate) static WHOLE_REGION_FETCH_ENABLED: AtomicBool = AtomicBool::new(false); + +/// Returns true when whole-region page-index fetch is enabled (remote stores). +pub fn is_whole_region_fetch_enabled() -> bool { + WHOLE_REGION_FETCH_ENABLED.load(Ordering::Relaxed) +} + +/// Enable/disable whole-region page-index fetch. Called from `create_reader` based +/// on whether a remote object store is in use (`store_ptr > 0`). +pub fn set_whole_region_fetch_enabled(enabled: bool) { + WHOLE_REGION_FETCH_ENABLED.store(enabled, Ordering::Relaxed); +} + +pub use cache_store::ScopedCacheStats; +pub use column_schema_resolver::{ + resolve_predicate_parquet_columns, resolve_predicate_parquet_columns_pair, +}; +pub use page_index_io::load_scoped_page_index_cols; + +// Process-global caches + +pub(crate) static COLUMN_INDEX_CACHE: Lazy> = + Lazy::new(|| { + BoundedCache::with_named_policy(DEFAULT_SCOPED_CACHE_LIMIT, CacheEvictionPolicy::Fifo) + }); + +pub(crate) static OFFSET_INDEX_CACHE: Lazy> = Lazy::new(|| { + BoundedCache::with_named_policy(DEFAULT_SCOPED_CACHE_LIMIT, CacheEvictionPolicy::Fifo) +}); + +/// Set the ColumnIndex cache's byte budget. Called from startup wiring with the +/// configured limit. Idempotent; shrinking evicts immediately. Zero ignored. +pub fn set_column_index_cache_limit(limit: usize) { + if limit > 0 { + COLUMN_INDEX_CACHE.set_limit(limit); + } +} + +/// Set the OffsetIndex cache's byte budget. Called from startup wiring with the +/// configured limit. Idempotent; shrinking evicts immediately. Zero ignored. +pub fn set_offset_index_cache_limit(limit: usize) { + if limit > 0 { + OFFSET_INDEX_CACHE.set_limit(limit); + } +} + +/// Counters + occupancy of the ColumnIndex (predicate-driven) cache. Lock-free. +pub fn column_index_cache_stats() -> ScopedCacheStats { + COLUMN_INDEX_CACHE.stats() +} + +/// Counters + occupancy of the OffsetIndex (projection-driven) cache. Lock-free. +pub fn offset_index_cache_stats() -> ScopedCacheStats { + OFFSET_INDEX_CACHE.stats() +} + +/// Drop all entries and reset counters in BOTH caches, keeping the budgets. For +/// operational testing — reset and re-measure without a cluster restart. +pub fn clear_scoped_cache() { + COLUMN_INDEX_CACHE.clear_keep_limit(); + OFFSET_INDEX_CACHE.clear_keep_limit(); +} + +/// Evict all page-index cells for a specific file from both caches. +/// +/// Called when a segment file is deleted or replaced so stale cells don't survive +/// in the cache under the same `(path, col, rg)` key. The page-index caches have +/// no freshness check (unlike the metadata cache's `is_valid_for`), so stale cells +/// from a re-written file would otherwise be served as hits — wrong data. +pub fn evict_file_from_scoped_cache(file_path: &str) { + COLUMN_INDEX_CACHE.evict_by_prefix(file_path); + OFFSET_INDEX_CACHE.evict_by_prefix(file_path); +} + +/// Crate-wide guard so every test that touches the process-global caches mutually +/// excludes (distinct fixtures alone aren't enough — the `InMemory` path is always +/// "data.parquet"). Shared (not per-module) so all cache users serialize. +#[cfg(test)] +pub(crate) static SCOPED_CACHE_TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// Clear both caches AND restore the default limit on each. +#[cfg(test)] +pub(crate) fn clear_scoped_cache_for_test() { + COLUMN_INDEX_CACHE.clear_keep_limit(); + COLUMN_INDEX_CACHE.set_limit(DEFAULT_SCOPED_CACHE_LIMIT); + OFFSET_INDEX_CACHE.clear_keep_limit(); + OFFSET_INDEX_CACHE.set_limit(DEFAULT_SCOPED_CACHE_LIMIT); +} + +#[cfg(test)] +pub(crate) fn set_column_index_cache_limit_for_test(limit: usize) { + COLUMN_INDEX_CACHE.set_limit(limit); +} + +/// Combined view (sum of both caches) — test-only convenience for assertions that +/// only need "is the scoped machinery doing anything". Production code reads the +/// two caches separately ([`column_index_cache_stats`] / [`offset_index_cache_stats`]). +#[cfg(test)] +pub(crate) fn scoped_cache_stats() -> ScopedCacheStats { + let a = column_index_cache_stats(); + let b = offset_index_cache_stats(); + ScopedCacheStats { + hits: a.hits + b.hits, + misses: a.misses + b.misses, + evictions: a.evictions + b.evictions, + entries: a.entries + b.entries, + used_bytes: a.used_bytes + b.used_bytes, + limit_bytes: a.limit_bytes.max(b.limit_bytes), + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/cache/page_index/page_index_io.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/cache/page_index/page_index_io.rs new file mode 100644 index 0000000000000..87463d0456961 --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/cache/page_index/page_index_io.rs @@ -0,0 +1,1902 @@ +/* + * 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. + */ + +//! Page-index load entry points and their supporting internals. +//! +//! [`load_scoped_page_index_cols`] is the single public entry point — it loads +//! the ColumnIndex for predicate columns (all RGs) and the OffsetIndex for +//! projection columns, using the two process-global caches. + +use std::collections::{HashMap, HashSet}; +use std::mem; +use std::ops::Range; +use std::sync::Arc; + +use arrow::array::{ArrayRef, BooleanArray, UInt64Array}; +use arrow::datatypes::SchemaRef; +use datafusion::parquet::arrow::arrow_reader::statistics::StatisticsConverter; +use datafusion::parquet::errors::{ParquetError, Result as ParquetResult}; +use datafusion::parquet::file::metadata::{ + ColumnChunkMetaData, OffsetIndexBuilder, ParquetColumnIndex, ParquetMetaData, + ParquetOffsetIndex, +}; +use datafusion::parquet::file::page_index::column_index::ColumnIndexMetaData; +use datafusion::parquet::file::page_index::index_reader::{ + read_columns_indexes, read_offset_indexes, +}; +use datafusion::parquet::file::reader::{ChunkReader, Length}; +use datafusion::physical_expr::PhysicalExpr; +use datafusion::physical_optimizer::pruning::{PruningPredicate, PruningStatistics}; +use datafusion::scalar::ScalarValue; +use object_store::ObjectStore; +use parquet::file::page_index::offset_index::OffsetIndexMetaData; +use prost::bytes::{buf, Buf, Bytes}; + +use super::cache_keys::{CiCellKey, OiCellKey, OiColumn}; +use super::{COLUMN_INDEX_CACHE, OFFSET_INDEX_CACHE}; + +/// Load + graft a scoped page index: ColumnIndex for `predicate_cols` (all RGs), +/// OffsetIndex for `projection_cols` (∪ predicate ∪ col 0). This is the single +/// production entry point used by both the listing path (`ScopedPageIndexReaderFactory`) +/// and the indexed executor augmentation loop. +pub async fn load_scoped_page_index_cols( + store: &Arc, + location: &object_store::path::Path, + footer_meta: &Arc, + predicate_cols: &[usize], + projection_cols: &[usize], +) -> Option> { + attach_scoped_page_index_to_metadata( + store, + location, + footer_meta, + predicate_cols, + Some(projection_cols), + ) + .await +} + +async fn attach_scoped_page_index_to_metadata( + store: &Arc, + location: &object_store::path::Path, + footer_meta: &Arc, + predicate_cols: &[usize], + projection_cols: Option<&[usize]>, +) -> Option> { + // Nothing to build when both predicate_cols and projection_cols are empty/absent. + // An empty projection slice means "no specific projection" → treat as None (all columns). + let oi_proj = match projection_cols { + Some(p) if !p.is_empty() => Some(p), + _ => None, // empty or absent → OI covers all columns + }; + if predicate_cols.is_empty() && oi_proj.is_none() && projection_cols.is_some() { + // All empty — nothing to build. + return None; + } + // CI and OI IO run concurrently — wall time = max(ci, oi) not their sum. + let (ci_result, oi_result) = tokio::join!( + async { + if predicate_cols.is_empty() { + Some(None) + } else { + Some(Some( + get_or_build_column_index(store, location, footer_meta, predicate_cols).await?, + )) + } + }, + get_or_build_offset_index(store, location, footer_meta, predicate_cols, oi_proj), + ); + let column_index = ci_result?; + let offset_index = oi_result?; + Some(graft(footer_meta, column_index, offset_index)) +} + +/// Build a fresh `ParquetMetaData` = `footer` with the page-index pair grafted +/// on. Clones the footer to get an owned value for the builder — but with +/// `ParquetMetaData.row_groups` held behind an `Arc` (see the arrow-rs change), +/// that clone is a refcount bump, not a deep copy of every row group's +/// column-chunk metadata. On wide / many-row-group files (e.g. textbench's +/// ~403-col, ~64-RG footers) that deep copy was ~60ms/query; sharing makes the +/// graft effectively free. +fn graft( + footer_meta: &Arc, + column_index: Option, + offset_index: ParquetOffsetIndex, +) -> Arc { + let base = ParquetMetaData::clone(footer_meta); + let rebuilt = base + .into_builder() + .set_column_index(column_index) + .set_offset_index(Some(offset_index)) + .build(); + Arc::new(rebuilt) +} + +// ── ColumnIndex cache lookup + build (per `(file, col, rg)` cell) ──────────── + +/// Assemble the full-width `[rg][col]` `ColumnIndex` matrix (real cells only at +/// `predicate_cols` × all RGs; `NONE` everywhere else) by looking up each +/// `(file, col, rg)` cell in the cache and decoding only the cells that miss. +/// +/// Each cell is keyed on `(file, col, rg)` and decoded once per file — reused +/// across every query that filters on the same column regardless of predicates. +async fn get_or_build_column_index( + store: &Arc, + location: &object_store::path::Path, + footer_meta: &Arc, + predicate_cols: &[usize], +) -> Option { + let num_rgs = footer_meta.num_row_groups(); + if num_rgs == 0 { + return None; + } + let num_cols = footer_meta.file_metadata().schema_descr().num_columns(); + + debug_assert!( + predicate_cols.iter().all(|&i| i < num_cols), + "predicate_cols contains out-of-bounds index (num_cols={num_cols}): {predicate_cols:?}" + ); + if predicate_cols.iter().any(|&i| i >= num_cols) { + return None; + } + + let build_rgs: Vec = (0..num_rgs).collect(); + let path: Arc = Arc::from(location.as_ref()); + + // Initially filled NONE for all RGs and all cols - as placeholders + let mut col_index_matrix: ParquetColumnIndex = (0..num_rgs) + .map(|_| (0..num_cols).map(|_| ColumnIndexMetaData::NONE).collect()) + .collect(); + + // Phase 1: serve every needed cell that is already cached; collect misses. + let mut missing_col_rg_matrix: Vec<(usize, usize)> = Vec::new(); // (col, rg) + for &rg in &build_rgs { + for &col in predicate_cols { + let key = CiCellKey { + path: path.clone(), + col, + rg, + }; + match COLUMN_INDEX_CACHE.get(&key) { + Some(cell) => col_index_matrix[rg][col] = cell, + None => missing_col_rg_matrix.push((col, rg)), + } + } + } + // Phase 2: decode the missing cells (vectored fetch grouped by RG), place + // them in the matrix, and populate the cache. + // Use insert_batch so all cells from this query are inserted under a single + // write-lock acquisition — avoids per-cell lock overhead and runs eviction + // exactly once after the batch rather than once per cell. + if !missing_col_rg_matrix.is_empty() { + let built = + build_column_index_cells(store, location, footer_meta, &missing_col_rg_matrix).await?; + + let batch = built.iter().map(|cell| { + debug_assert!( + cell.rg < col_index_matrix.len() && cell.col < col_index_matrix[cell.rg].len(), + "cell ({}, {}) out of matrix bounds ({num_rgs} rgs, {num_cols} cols)", + cell.col, + cell.rg, + ); + ( + CiCellKey { + path: path.clone(), + col: cell.col, + rg: cell.rg, + }, + cell.data.clone(), + cell.size, + ) + }); + COLUMN_INDEX_CACHE.insert_batch(batch); + + for cell in built { + col_index_matrix[cell.rg][cell.col] = cell.data; + } + } + + Some(col_index_matrix) +} + +/// One row group's decode unit: the scoped columns and their precomputed chunk +/// metadata. Built once per RG and used by BOTH the fetch (to union the chunks' +/// byte extents) and the decode (to pass to `read_*_indexes`), so `chunks` is +/// cloned from the footer exactly once. +struct RgPlan { + rg: usize, + cols: Vec, + chunks: Vec, +} + +impl RgPlan { + fn new(footer_meta: &Arc, rg: usize, cols: Vec) -> Self { + let rgm = footer_meta.row_group(rg); + let chunks = cols.iter().map(|&i| rgm.column(i).clone()).collect(); + Self { rg, cols, chunks } + } +} + +struct CiCell { + col: usize, + rg: usize, + data: ColumnIndexMetaData, + size: usize, +} + +struct OiCell { + col: usize, + data: OiColumn, + size: usize, +} + +/// Range-read + decode the requested `(col, rg)` ColumnIndex cells, grouping by +/// row group so each RG's columns share one vectored fetch + decode. `None` if +/// any requested column lacks a column-index range (→ footer-only fallback). +async fn build_column_index_cells( + store: &Arc, + location: &object_store::path::Path, + footer_meta: &Arc, + col_rg_matrix: &[(usize, usize)], +) -> Option> { + let mut by_rg: HashMap> = HashMap::new(); + for &(col, rg) in col_rg_matrix { + by_rg.entry(rg).or_default().push(col); + } + let plan: Vec = by_rg + .into_iter() + .map(|(rg, cols)| RgPlan::new(footer_meta, rg, cols)) + .collect(); + + // Fetch (wide whole-region on remote, narrow per-RG on local). Decode below is + // always scoped to the requested columns. + let buffers = fetch_index_buffers(store, location, footer_meta, &plan, ci_extent).await?; + + let mut out: Vec = Vec::with_capacity(col_rg_matrix.len()); + for (i, p) in plan.iter().enumerate() { + // Deprecated but the only PUBLIC column-subset decoder (arrow-rs#8643). + #[allow(deprecated)] + let decoded = read_columns_indexes(&buffers.reader(i), &p.chunks).ok()??; + if decoded.len() != p.cols.len() { + return None; + } + let rgm = footer_meta.row_group(p.rg); + for (entry, &col) in decoded.into_iter().zip(p.cols.iter()) { + let size = rgm.column(col).column_index_length().unwrap_or(0).max(0) as usize; + out.push(CiCell { + col, + rg: p.rg, + data: entry, + size, + }); + } + } + Some(out) +} + +// ── OffsetIndex cache lookup + build (per `(file, col)` cell, all RGs) ─────── + +/// Assemble the full-width `[rg][col]` `OffsetIndex` matrix (real entries only at +/// the resolved offset columns; empty placeholders elsewhere) from per-`(file, +/// col)` cells, decoding only the columns that miss. +/// +/// The resolved offset-column set is `predicate ∪ projection ∪ {0}` (`projection_cols +/// == None` → all columns); see [`OiCellKey`] for why each must be real. Each +/// cached cell is a column's OffsetIndex across **all** row groups, keyed only on +/// `(file, col)`, so it is decoded once per file and reused across every query +/// that reads that column irrespective of projection or predicate. +async fn get_or_build_offset_index( + store: &Arc, + location: &object_store::path::Path, + footer_meta: &Arc, + predicate_cols: &[usize], + projection_cols: Option<&[usize]>, +) -> Option { + let num_rgs = footer_meta.num_row_groups(); + if num_rgs == 0 { + return None; + } + let num_cols = footer_meta.file_metadata().schema_descr().num_columns(); + + // Resolve which columns need a real OffsetIndex: + // None → no explicit projection, read everything → all columns get a real entry. + // Some → build {col 0} ∪ predicate_cols ∪ proj_cols, clamped to num_cols. + // Col 0 is always included because the page-skip metric reads it + // regardless of what the query projects or filters on. + // Predicate-only queries (empty proj_cols) still get col 0 + predicate + // columns; projection-only queries get col 0 + projected columns. + let off_cols: Vec = match projection_cols { + None => (0..num_cols).collect(), + Some(proj_cols) => { + let mut set: HashSet = HashSet::new(); + set.insert(0); // page-skip metric always reads col 0 + for &c in predicate_cols { + set.insert(c); + } + for &c in proj_cols { + set.insert(c); + } + debug_assert!( + set.iter().all(|&c| c < num_cols), + "column index out of bounds (num_cols={num_cols}): {set:?}" + ); + set.into_iter().filter(|&c| c < num_cols).collect() + } + }; + if off_cols.is_empty() { + return None; + } + + let path: Arc = Arc::from(location.as_ref()); + // Placeholder for columns we don't build: a SINGLE page spanning the whole row + // group, NOT an empty page-locations list. A scoped OffsetIndex is grafted as a + // full-width `[rg][col]` matrix; consumers (DataFusion's page pruner, arrow's + // reader, our indexed pruner) index it by absolute column and dereference + // `page_locations` (`.last()`, `[0]`, `windows(2)`). An EMPTY placeholder + // panics those (`page_locations.last().unwrap()` etc.) if any path touches a + // column we scoped out — which is hard to predict across every query shape + // (count/agg, SingleCollector prefetch, schema-evolved files). A one-page + // placeholder is always safe to dereference and makes pruning conservatively + // keep the whole RG (1 page = all rows → can't prune), never a wrong result. + // + // The single page MUST carry the column chunk's REAL byte offset+size, not + // (0, 0). When a row selection actually READS a placeholdered column (e.g. a + // `match() | sort | head` that page-selects the projected columns), arrow + // derives the fetch byte range from this page location. A (0, 0) page makes + // arrow compute a range whose `end < start`, and the read path's + // `range.end - range.start` then underflows (`parquet_bridge.rs` get_byte_ranges + // → "subtract with overflow" / release "capacity overflow"). Spanning the real + // chunk extent (`byte_range()` = dictionary/data page start + compressed size) + // makes any derived range a valid full-chunk read — the one-page semantics are + // preserved (still "can't prune"), just with coordinates arrow can subtract. + let placeholder_for = |rg_idx: usize, col_idx: usize| -> OffsetIndexMetaData { + let rg = footer_meta.row_group(rg_idx); + let (col_start, col_len) = rg.column(col_idx).byte_range(); + let mut b = OffsetIndexBuilder::new(); + // compressed_page_size is i32; clamp the (already-bounded) chunk length so a + // pathologically large column can't wrap the cast. + let size = i32::try_from(col_len).unwrap_or(i32::MAX); + b.append_offset_and_size(col_start as i64, size); + b.append_row_count(rg.num_rows()); + b.build() + }; + // Each column chunk has its OWN byte offset, so the placeholder is per-(rg, col): + // unlike the earlier (0, 0) placeholder it cannot be shared across columns. The + // scoped columns get their real OffsetIndex scattered in afterward, overwriting + // these placeholders. + let mut matrix: ParquetOffsetIndex = (0..num_rgs) + .map(|rg| (0..num_cols).map(|col| placeholder_for(rg, col)).collect()) + .collect(); + + // Phase 1: serve cached columns; collect misses. + let mut missing: Vec = Vec::new(); + for &col in &off_cols { + let key = OiCellKey { + path: path.clone(), + col, + }; + match OFFSET_INDEX_CACHE.get(&key) { + Some(column) => scatter_offset_column(&mut matrix, col, &column), + None => missing.push(col), + } + } + + // Phase 2: decode the missing columns (each spanning all RGs), scatter into + // the matrix, and populate the cache. + // Use insert_batch: all missing OI columns for this query in one lock acquisition. + if !missing.is_empty() { + let built = + build_offset_index_columns(store, location, footer_meta, &missing, num_rgs).await?; + + let batch = built.iter().map(|cell| { + ( + OiCellKey { + path: path.clone(), + col: cell.col, + }, + cell.data.clone(), + cell.size, + ) + }); + OFFSET_INDEX_CACHE.insert_batch(batch); + + for cell in built { + scatter_offset_column_owned(&mut matrix, cell.col, cell.data); + } + } + + Some(matrix) +} + +/// Place a column's all-RG OffsetIndex (indexed by RG) into the matrix at `col`. +fn scatter_offset_column(matrix: &mut ParquetOffsetIndex, col: usize, column: &OiColumn) { + for (rg, entry) in column.iter().enumerate() { + if rg < matrix.len() { + matrix[rg][col] = entry.clone(); + } + } +} + +/// Consuming version of [`scatter_offset_column`] — used after inserting into +/// the cache so we move rather than clone the per-RG entries. +fn scatter_offset_column_owned(matrix: &mut ParquetOffsetIndex, col: usize, column: OiColumn) { + for (rg, entry) in column.into_iter().enumerate() { + if rg < matrix.len() { + matrix[rg][col] = entry; + } + } +} + +/// Range-read + decode the OffsetIndex for each requested column across **every** +/// row group (read-time safety — see [`OiCellKey`]). `None` if any column lacks +/// an offset-index range (→ footer-only fallback). +async fn build_offset_index_columns( + store: &Arc, + location: &object_store::path::Path, + footer_meta: &Arc, + cols: &[usize], + num_rgs: usize, +) -> Option> { + // Same `cols` on every RG (read-time safety). Fetch, then decode scoped per RG. + let plan: Vec = (0..num_rgs) + .map(|rg| RgPlan::new(footer_meta, rg, cols.to_vec())) + .collect(); + let buffers = fetch_index_buffers(store, location, footer_meta, &plan, oi_extent).await?; + + // Per-column accumulator: one OiColumn slot per requested col, filled RG by RG. + let mut columns: Vec = cols.iter().map(|_| Vec::with_capacity(num_rgs)).collect(); + for (i, p) in plan.iter().enumerate() { + #[allow(deprecated)] + let decoded = read_offset_indexes(&buffers.reader(i), &p.chunks).ok()??; + if decoded.len() != cols.len() { + return None; + } + for (k, entry) in decoded.into_iter().enumerate() { + columns[k].push(entry); + } + } + + let mut out: Vec = Vec::with_capacity(cols.len()); + for (k, &col) in cols.iter().enumerate() { + let size = footer_meta + .row_groups() + .iter() + .map(|rg| rg.column(col).offset_index_length().unwrap_or(0).max(0) as usize) + .sum(); + out.push(OiCell { + col, + data: mem::take(&mut columns[k]), + size, + }); + } + Some(out) +} + +/// The page-index extent (byte offset..end) of one column chunk, for one index kind. +/// The only thing that differs between the CI and OI fetch paths. +type ChunkExtent = fn(&ColumnChunkMetaData) -> Option>; + +fn ci_extent(c: &ColumnChunkMetaData) -> Option> { + let off = u64::try_from(c.column_index_offset()?).ok()?; + let len = u64::try_from(c.column_index_length()?).ok()?; + Some(off..off + len) +} + +fn oi_extent(c: &ColumnChunkMetaData) -> Option> { + let off = u64::try_from(c.offset_index_offset()?).ok()?; + let len = u64::try_from(c.offset_index_length()?).ok()?; + Some(off..off + len) +} + +/// Fetch the page-index bytes for a decode plan and return one [`BufferChunkReader`] +/// The fetched page-index bytes for a decode plan. The caller builds a transient +/// [`BufferChunkReader`] per decode iteration via [`IndexBuffers::reader`] — readers +/// are never stored across the loop. +enum IndexBuffers { + /// Wide (remote): every plan entry decodes from the ONE whole-region buffer. + Shared { base: u64, bytes: Bytes }, + /// Narrow (local): one scoped buffer per plan entry, aligned to `plan` order. + PerEntry(Vec<(u64, Bytes)>), +} + +impl IndexBuffers { + /// Reader for the `i`-th plan entry. A `BufferChunkReader` is a `(u64, Bytes)` + /// where `Bytes::clone` is a refcount bump, so this is cheap and short-lived. + fn reader(&self, i: usize) -> BufferChunkReader { + match self { + IndexBuffers::Shared { base, bytes } => BufferChunkReader { + base: *base, + bytes: bytes.clone(), + }, + IndexBuffers::PerEntry(v) => BufferChunkReader { + base: v[i].0, + bytes: v[i].1.clone(), + }, + } + } +} + +/// Fetch the page-index bytes for a decode plan. The single place the fetch strategy +/// is decided: +/// +/// - **Remote/warm store** ([`is_whole_region_fetch_enabled`]) — ONE `get_ranges` +/// over the file's whole index region (all cols × all RGs; lenient — missing +/// columns skipped). Matches the range key eager warm-population writes, so a +/// warmed file is a hit instead of remote IO. All entries share that one buffer; +/// only the scoped columns are decoded out, so heap is unchanged — just the bytes +/// fetched widen. +/// - **Local store** — the original narrow per-entry fetch of just the scoped +/// columns' extents (fewer bytes; no warm tier to match). +/// +/// `extent` selects the index kind. `None` (→ footer-only fallback) if there is no +/// such index region, or — narrow path — any scoped column lacks an extent. +async fn fetch_index_buffers( + store: &Arc, + location: &object_store::path::Path, + footer_meta: &Arc, + plan: &[RgPlan], + extent: ChunkExtent, +) -> Option { + if super::is_whole_region_fetch_enabled() { + // Whole region spans ALL cols × ALL RGs, so it's derived from the footer, + // not the (scoped) plan. + let region = whole_index_region(footer_meta, extent)?; + let buffers = store + .get_ranges(location, std::slice::from_ref(®ion)) + .await + .ok()?; + return Some(IndexBuffers::Shared { + base: region.start, + bytes: buffers.first()?.clone(), + }); + } + // Narrow: one scoped fetch per plan entry, reusing each entry's precomputed chunks. + let mut fetch_ranges: Vec> = Vec::with_capacity(plan.len()); + for p in plan { + fetch_ranges.push(union_extent(&p.chunks, extent)?); + } + let buffers = store.get_ranges(location, &fetch_ranges).await.ok()?; + if buffers.len() != fetch_ranges.len() { + return None; + } + Some(IndexBuffers::PerEntry( + fetch_ranges.iter().map(|r| r.start).zip(buffers).collect(), + )) +} + +/// `min(start)..max(end)` over EVERY `(col, rg)` chunk's `extent`, skipping columns +/// without one (lenient). The whole-file index region for the wide fetch — the +/// single source of truth for the range key warm-population must also write. +fn whole_index_region( + footer_meta: &Arc, + extent: ChunkExtent, +) -> Option> { + let num_cols = footer_meta.file_metadata().schema_descr().num_columns(); + let mut acc: Option> = None; + for rg in 0..footer_meta.num_row_groups() { + let rgm = footer_meta.row_group(rg); + for col in 0..num_cols { + if let Some(r) = extent(rgm.column(col)) { + acc = Some(merge(acc, r)); + } + } + } + acc +} + +/// Union of `extent` across `chunks`; any missing extent bails to `None` (the narrow +/// path requires all requested columns to have an index, else footer-only fallback). +fn union_extent(chunks: &[ColumnChunkMetaData], extent: ChunkExtent) -> Option> { + let mut acc: Option> = None; + for c in chunks { + acc = Some(merge(acc, extent(c)?)); + } + acc +} + +fn merge(acc: Option>, r: Range) -> Range { + match acc { + None => r, + Some(a) => a.start.min(r.start)..a.end.max(r.end), + } +} + +/// A [`ChunkReader`] over an in-memory byte buffer representing the file region +/// `[base, base + bytes.len())`. The arrow-rs page-index readers call +/// `get_bytes(absolute_offset, len)`; we translate into the buffer. +struct BufferChunkReader { + base: u64, + bytes: Bytes, +} + +impl Length for BufferChunkReader { + fn len(&self) -> u64 { + self.base + self.bytes.len() as u64 + } +} + +impl ChunkReader for BufferChunkReader { + type T = buf::Reader; + + fn get_read(&self, start: u64) -> ParquetResult { + let rel = self.rel(start, 0)?; + Ok(self.bytes.slice(rel..).reader()) + } + + fn get_bytes(&self, start: u64, length: usize) -> ParquetResult { + let rel = self.rel(start, length)?; + Ok(self.bytes.slice(rel..rel + length)) + } +} + +impl BufferChunkReader { + /// Translate an absolute file offset `start` to a buffer-relative index. + /// The fork's `read_columns_indexes`/`read_offset_indexes` call `get_bytes` + /// with absolute file offsets (from chunk metadata); `self.base` is the + /// absolute start of the fetched buffer, so `start - base` gives the + /// position within `self.bytes`. + fn rel(&self, start: u64, length: usize) -> ParquetResult { + let rel = start.checked_sub(self.base).ok_or_else(|| { + ParquetError::General(format!( + "page-index read offset {start} precedes buffer base {}", + self.base + )) + })?; + let rel = usize::try_from(rel) + .map_err(|e| ParquetError::General(format!("offset overflow: {e}")))?; + if rel + length > self.bytes.len() { + return Err(ParquetError::General(format!( + "page-index read [{rel}..{}) exceeds buffer of len {}", + rel + length, + self.bytes.len() + ))); + } + Ok(rel) + } +} +#[cfg(test)] +mod tests { + use super::super::column_schema_resolver::{ + resolve_predicate_parquet_columns, resolve_predicate_parquet_columns_pair, + }; + use super::super::{ + clear_scoped_cache_for_test, column_index_cache_stats, offset_index_cache_stats, + scoped_cache_stats, set_column_index_cache_limit_for_test, set_whole_region_fetch_enabled, + ScopedCacheStats, SCOPED_CACHE_TEST_GUARD, + }; + use super::*; + use crate::indexed_table::page_pruner::{build_pruning_predicate, PagePruner}; + use arrow::array::{Int32Array, RecordBatch}; + use arrow::datatypes::{DataType, Field, Schema}; + use datafusion::common::ScalarValue; + use datafusion::logical_expr::Operator; + use datafusion::parquet::arrow::arrow_reader::{ + ArrowReaderMetadata, ArrowReaderOptions, RowSelection, RowSelector, + }; + use datafusion::parquet::arrow::ArrowWriter; + use datafusion::parquet::file::properties::{EnabledStatistics, WriterProperties}; + use datafusion::physical_expr::expressions::{BinaryExpr, Column as PhysColumn, Literal}; + use datafusion::physical_expr::PhysicalExpr; + use object_store::memory::InMemory; + use object_store::path::Path as ObjPath; + use object_store::{ObjectStoreExt, PutPayload}; + + use super::super::SCOPED_CACHE_TEST_GUARD as CACHE_TEST_GUARD; + + // ── fixtures + expr helpers ────────────────────────────────────────── + + /// 2 columns (`price`, `qty`), 32 rows, 1 row group, 4 pages of 8 rows. + fn two_col_parquet() -> (Bytes, SchemaRef) { + let schema = Arc::new(Schema::new(vec![ + Field::new("price", DataType::Int32, false), + Field::new("qty", DataType::Int32, false), + ])); + let prices: Vec = (0..32).collect(); + let qtys: Vec = (100..132).collect(); + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from(prices)), + Arc::new(Int32Array::from(qtys)), + ], + ) + .unwrap(); + let props = WriterProperties::builder() + .set_max_row_group_size(32) + .set_data_page_row_count_limit(8) + .set_write_batch_size(8) + .set_statistics_enabled(EnabledStatistics::Page) + .build(); + let mut buf: Vec = Vec::new(); + let mut w = ArrowWriter::try_new(&mut buf, schema.clone(), Some(props)).unwrap(); + w.write(&batch).unwrap(); + w.close().unwrap(); + (Bytes::from(buf), schema) + } + + /// 4 row groups of 10 rows (`id` 0..40, `v` = id*2), page size 5. + fn four_rg_parquet() -> (Bytes, SchemaRef) { + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new("v", DataType::Int32, false), + ])); + let ids: Vec = (0..40).collect(); + let vs: Vec = (0..40).map(|x| x * 2).collect(); + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from(ids)), + Arc::new(Int32Array::from(vs)), + ], + ) + .unwrap(); + let props = WriterProperties::builder() + .set_max_row_group_size(10) + .set_data_page_row_count_limit(5) + .set_write_batch_size(5) + .set_statistics_enabled(EnabledStatistics::Page) + .build(); + let mut buf: Vec = Vec::new(); + let mut w = ArrowWriter::try_new(&mut buf, schema.clone(), Some(props)).unwrap(); + w.write(&batch).unwrap(); + w.close().unwrap(); + (Bytes::from(buf), schema) + } + + /// 4 columns (2 int `n0`,`n1` + 2 wide string `s0`,`s1`), 1 RG, multiple pages. + fn wide4_parquet() -> (Bytes, SchemaRef) { + use arrow::array::StringArray; + let schema = Arc::new(Schema::new(vec![ + Field::new("n0", DataType::Int32, false), + Field::new("n1", DataType::Int32, false), + Field::new("s0", DataType::Utf8, false), + Field::new("s1", DataType::Utf8, false), + ])); + const ROWS: i32 = 256; + let n0: Vec = (0..ROWS).collect(); + let n1: Vec = (0..ROWS).collect(); + let s0: Vec = (0..ROWS).map(|r| format!("s0_{r:05}_padpadpad")).collect(); + let s1: Vec = (0..ROWS).map(|r| format!("s1_{r:05}_padpadpad")).collect(); + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from(n0)), + Arc::new(Int32Array::from(n1)), + Arc::new(StringArray::from(s0)), + Arc::new(StringArray::from(s1)), + ], + ) + .unwrap(); + let props = WriterProperties::builder() + .set_max_row_group_size(ROWS as usize) + .set_data_page_row_count_limit(32) + .set_write_batch_size(32) + .set_statistics_enabled(EnabledStatistics::Page) + .build(); + let mut buf: Vec = Vec::new(); + let mut w = ArrowWriter::try_new(&mut buf, schema.clone(), Some(props)).unwrap(); + w.write(&batch).unwrap(); + w.close().unwrap(); + (Bytes::from(buf), schema) + } + + async fn stage(bytes: Bytes) -> (Arc, ObjPath) { + let store: Arc = Arc::new(InMemory::new()); + let loc = ObjPath::from("data.parquet"); + store + .put(&loc, PutPayload::from_bytes(bytes)) + .await + .unwrap(); + (store, loc) + } + + fn footer_only(bytes: &Bytes) -> Arc { + ArrowReaderMetadata::load( + &bytes.clone(), + ArrowReaderOptions::new().with_page_index(false), + ) + .unwrap() + .metadata() + .clone() + } + + fn full_index(bytes: &Bytes) -> Arc { + ArrowReaderMetadata::load( + &bytes.clone(), + ArrowReaderOptions::new().with_page_index(true), + ) + .unwrap() + .metadata() + .clone() + } + + fn col(name: &str, idx: usize) -> Arc { + Arc::new(PhysColumn::new(name, idx)) + } + fn lit_int(v: i32) -> Arc { + Arc::new(Literal::new(ScalarValue::Int32(Some(v)))) + } + fn pred(name: &str, idx: usize, op: Operator, v: i32) -> Arc { + Arc::new(BinaryExpr::new(col(name, idx), op, lit_int(v))) + } + fn kept(sel: &RowSelection) -> usize { + sel.iter().filter(|s| !s.skip).map(|s| s.row_count).sum() + } + fn ci() -> ScopedCacheStats { + column_index_cache_stats() + } + fn oi() -> ScopedCacheStats { + offset_index_cache_stats() + } + + fn read_selected_column( + bytes: &Bytes, + meta: &Arc, + leaf_col: usize, + selection: RowSelection, + ) -> std::result::Result, String> { + use datafusion::parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder; + use datafusion::parquet::arrow::ProjectionMask; + + let arm = ArrowReaderMetadata::try_new(Arc::clone(meta), ArrowReaderOptions::new()) + .map_err(|e| format!("try_new metadata: {e}"))?; + let builder = ParquetRecordBatchReaderBuilder::new_with_metadata(bytes.clone(), arm); + let proj = ProjectionMask::leaves(builder.parquet_schema(), [leaf_col]); + let mut reader = builder + .with_row_groups(vec![0]) + .with_projection(proj) + .with_row_selection(selection) + .build() + .map_err(|e| format!("build reader: {e}"))?; + let mut out = Vec::new(); + while let Some(next) = reader.next() { + let batch = next.map_err(|e| format!("read batch: {e}"))?; + let a = batch + .column(0) + .as_any() + .downcast_ref::() + .ok_or("projected column was not Int32")?; + for i in 0..a.len() { + out.push(a.value(i)); + } + } + Ok(out) + } + + // ── baseline / correctness ──────────────────────────────────────────── + + #[tokio::test] + async fn footer_only_has_no_page_index() { + let (bytes, _schema) = two_col_parquet(); + let fo = footer_only(&bytes); + assert!(fo.column_index().is_none()); + assert!(fo.offset_index().is_none()); + } + + #[tokio::test] + async fn empty_column_set_returns_none() { + let _g = CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + let (bytes, _schema) = two_col_parquet(); + let (store, loc) = stage(bytes.clone()).await; + let fo = footer_only(&bytes); + assert!(load_scoped_page_index_cols(&store, &loc, &fo, &[], &[]) + .await + .is_none()); + assert_eq!(ci().entries, 0); + assert_eq!(oi().entries, 0); + } + + #[tokio::test] + async fn scoped_index_is_predicate_scoped_for_column_index() { + let _g = CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + let (bytes, schema) = two_col_parquet(); + let (store, loc) = stage(bytes.clone()).await; + let fo = footer_only(&bytes); + + let cols = resolve_predicate_parquet_columns(&schema, &fo, &["price".to_string()]); + assert_eq!(cols, vec![0]); + + let aug = load_scoped_page_index_cols(&store, &loc, &fo, &cols, &[]) + .await + .unwrap(); + let c = aug.column_index().unwrap(); + let o = aug.offset_index().unwrap(); + assert!( + !matches!(c[0][0], ColumnIndexMetaData::NONE), + "predicate col has real CI" + ); + assert!( + matches!(c[0][1], ColumnIndexMetaData::NONE), + "non-predicate col CI is NONE" + ); + assert!( + !o[0][0].page_locations().is_empty() && !o[0][1].page_locations().is_empty(), + "OffsetIndex real for every column (all-col default)" + ); + } + + // The pair-resolve must return exactly what two separate single-name resolves + // return — it only shares the per-file arrow-schema derivation, nothing else. + #[tokio::test] + async fn resolve_pair_equals_two_single_resolves() { + let (bytes, schema) = two_col_parquet(); + let fo = footer_only(&bytes); + let names_a = vec!["price".to_string()]; + let names_b = vec!["qty".to_string(), "price".to_string()]; + let mut single_a = resolve_predicate_parquet_columns(&schema, &fo, &names_a); + let mut single_b = resolve_predicate_parquet_columns(&schema, &fo, &names_b); + let (mut pair_a, mut pair_b) = + resolve_predicate_parquet_columns_pair(&schema, &fo, &names_a, &names_b); + single_a.sort_unstable(); + single_b.sort_unstable(); + pair_a.sort_unstable(); + pair_b.sort_unstable(); + assert_eq!(pair_a, single_a, "pair predicate result must match single"); + assert_eq!(pair_b, single_b, "pair projection result must match single"); + assert_eq!(pair_a, vec![0]); + assert_eq!(pair_b, vec![0, 1]); + } + + /// Regression: a match()-only query has NO residual predicate columns + /// (`parquet_cols` empty) but DOES project columns (`offset_cols` non-empty). + /// The scoped load must still build the OffsetIndex for the projected column + /// so the parquet reader fetches only matched-row pages, not whole chunks. + /// (Bug: the load short-circuited on empty `parquet_cols`, skipping the + /// OffsetIndex → reader over-read ~2.5× the bytes on `... | stats ... by URL`.) + #[tokio::test] + async fn projection_only_builds_offset_index_without_predicate() { + let _g = CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + let (bytes, _schema) = two_col_parquet(); // price=col0, qty=col1 + let (store, loc) = stage(bytes.clone()).await; + let fo = footer_only(&bytes); + + // No predicate columns; project qty (col 1). + let parquet_cols: Vec = vec![]; + let offset_cols: Vec = vec![1]; + let aug = load_scoped_page_index_cols(&store, &loc, &fo, &parquet_cols, &offset_cols) + .await + .expect("projection-only load must produce grafted metadata (not None)"); + + // No predicate → no ColumnIndex grafted. + assert!( + aug.column_index().is_none(), + "no predicate → ColumnIndex absent" + ); + + // OffsetIndex must be real for the projected col (1) AND col 0 (loader + // always unions in {0}); other behavior unchanged. + let o = aug.offset_index().expect("OffsetIndex must be grafted"); + assert!( + !o[0][1].page_locations().is_empty(), + "projected col qty has real OffsetIndex" + ); + assert!( + !o[0][0].page_locations().is_empty(), + "col 0 OffsetIndex real (always unioned)" + ); + } + + #[tokio::test] + async fn scoped_pruning_matches_full_index() { + let _g = CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + let (bytes, schema) = two_col_parquet(); + let (store, loc) = stage(bytes.clone()).await; + let fo = footer_only(&bytes); + let cols = resolve_predicate_parquet_columns(&schema, &fo, &["price".to_string()]); + let aug = load_scoped_page_index_cols(&store, &loc, &fo, &cols, &[]) + .await + .unwrap(); + let full = full_index(&bytes); + let pp = + build_pruning_predicate(&pred("price", 0, Operator::GtEq, 20), schema.clone()).unwrap(); + let s = PagePruner::new(&schema, Arc::clone(&aug)).prune_rg(&pp, 0, None); + let f = PagePruner::new(&schema, full).prune_rg(&pp, 0, None); + assert_eq!(s.as_ref().map(kept), f.as_ref().map(kept)); + assert_eq!(s.as_ref().map(kept), Some(16)); + } + + /// Schema-evolution fixture: a file whose physical layout is `[extra, price]` + /// (so `price` is parquet leaf **1**), 32 rows / 4 pages. Used to prove the + /// predicate→leaf resolution does NOT depend on a column's position in a wider + /// *union* schema. + fn evolved_extra_price_parquet() -> Bytes { + let schema = Arc::new(Schema::new(vec![ + Field::new("extra", DataType::Int32, false), + Field::new("price", DataType::Int32, false), + ])); + let extra: Vec = (1000..1032).collect(); + let prices: Vec = (0..32).collect(); + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from(extra)), + Arc::new(Int32Array::from(prices)), + ], + ) + .unwrap(); + let props = WriterProperties::builder() + .set_max_row_group_size(32) + .set_data_page_row_count_limit(8) + .set_write_batch_size(8) + .set_statistics_enabled(EnabledStatistics::Page) + .build(); + let mut buf: Vec = Vec::new(); + let mut w = ArrowWriter::try_new(&mut buf, schema.clone(), Some(props)).unwrap(); + w.write(&batch).unwrap(); + w.close().unwrap(); + Bytes::from(buf) + } + + /// Regression for the schema-evolution wrong-count bug: when the query's + /// (union) schema lists `price` at a DIFFERENT position than the file's + /// physical layout, the predicate must still resolve to the file's TRUE leaf + /// and scoped pruning must match the full-index pruning. Previously the + /// resolver used the union-schema position, scoped the page index at the wrong + /// leaf, and the residual mis-pruned → over-count. + #[tokio::test] + async fn scoped_resolution_is_per_file_under_schema_evolution() { + let _g = CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + let bytes = evolved_extra_price_parquet(); + let (store, loc) = stage(bytes.clone()).await; + let fo = footer_only(&bytes); + + // The UNION/table schema the query carries: `price` at position 0 — but in + // THIS file `price` is physically leaf 1 (after `extra`). + let union_schema: SchemaRef = Arc::new(Schema::new(vec![ + Field::new("price", DataType::Int32, false), + Field::new("qty", DataType::Int32, false), + Field::new("extra", DataType::Int32, false), + ])); + + // Must resolve to the file's TRUE leaf for `price` = 1, NOT the union + // position 0 (which is `extra` in this file). + let cols = resolve_predicate_parquet_columns(&union_schema, &fo, &["price".to_string()]); + assert_eq!( + cols, + vec![1], + "price must resolve to its per-file leaf (1), not union pos 0" + ); + + let aug = load_scoped_page_index_cols(&store, &loc, &fo, &cols, &[]) + .await + .unwrap(); + let full = full_index(&bytes); + // `price` pages: 0..8,8..16,16..24,24..32; `price >= 20` keeps the last two + // pages (rows 16..32 = 16 rows). Build the pruning predicate against the + // FILE schema (price at index 1) so the converter matches the data. + let file_schema: SchemaRef = Arc::new(Schema::new(vec![ + Field::new("extra", DataType::Int32, false), + Field::new("price", DataType::Int32, false), + ])); + let pp = + build_pruning_predicate(&pred("price", 1, Operator::GtEq, 20), file_schema.clone()) + .unwrap(); + let s = PagePruner::new(&file_schema, Arc::clone(&aug)).prune_rg(&pp, 0, None); + let f = PagePruner::new(&file_schema, full).prune_rg(&pp, 0, None); + assert_eq!( + s.as_ref().map(kept), + f.as_ref().map(kept), + "scoped pruning must match full index" + ); + assert_eq!(s.as_ref().map(kept), Some(16)); + clear_scoped_cache_for_test(); + } + + /// Page pruning over a column-scoped index must be a SAFE SUPERSET of the + /// full-index pruning — never drop a row the full index would keep (that + /// would be an under-count / lost result). It MAY keep extra rows (the + /// residual mask drops them post-decode), so equality is NOT required and is + /// the wrong invariant. + /// + /// The hazard this guards: with the page index scoped to `price` only, `qty` + /// is a one-page non-panicking OffsetIndex placeholder with a `NONE` + /// ColumnIndex. If the pruner TRUSTED that placeholder's (absent) stats it + /// would build a bogus single-page grid for `qty` and could mis-prune. The + /// `page_pruner` fix treats a `NONE`-ColumnIndex column as "no usable stats" + /// (like a schema-evolution-absent column) → it contributes "unknown" and + /// never prunes on `qty`, so the scoped result stays a conservative superset. + #[tokio::test] + async fn scoped_pruning_is_safe_superset_with_placeholdered_residual_col() { + let _g = CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + // price 0..32 (pages 0..8,8..16,16..24,24..32); qty 100..132 (pages + // 100..108,108..116,116..124,124..132). 1 RG, 4 pages each. + let (bytes, schema) = two_col_parquet(); + let (store, loc) = stage(bytes.clone()).await; + let fo = footer_only(&bytes); + + // Scope the page index to `price` ONLY (mimics the predicate-scoped indexed + // path). `qty` therefore gets the one-page placeholder + NONE ColumnIndex. + let cols = resolve_predicate_parquet_columns(&schema, &fo, &["price".to_string()]); + assert_eq!(cols, vec![0]); + let aug = load_scoped_page_index_cols(&store, &loc, &fo, &cols, &[]) + .await + .unwrap(); + let full = full_index(&bytes); + + // Residual references BOTH columns: price >= 16 (full keeps pages 2,3) AND + // qty <= 115 (full keeps pages 0,1) → full intersection prunes to 0 rows. + let price_ge = pred("price", 0, Operator::GtEq, 16); + let qty_le = pred("qty", 1, Operator::LtEq, 115); + let residual: Arc = + Arc::new(BinaryExpr::new(price_ge, Operator::And, qty_le)); + let pp = build_pruning_predicate(&residual, schema.clone()).unwrap(); + + let s_kept = PagePruner::new(&schema, Arc::clone(&aug)) + .prune_rg(&pp, 0, None) + .map(|s| kept(&s)); + let f_kept = PagePruner::new(&schema, full) + .prune_rg(&pp, 0, None) + .map(|s| kept(&s)); + // Superset invariant: scoped must keep AT LEAST what full keeps (never + // fewer). It keeps more here (16 vs 0) because it correctly cannot prune + // the placeholdered `qty` — that's safe; the residual mask removes the + // extras post-decode. A scoped result SMALLER than full would be the real + // bug (lost rows). `None` = "kept everything" (no pruning) = the maximal + // superset, also safe. + let s = s_kept.unwrap_or(usize::MAX); + let f = f_kept.unwrap_or(usize::MAX); + assert!( + s >= f, + "scoped page pruning must be a safe superset of full ({} kept) but kept fewer ({})", + f, + s + ); + clear_scoped_cache_for_test(); + } + + #[tokio::test] + async fn scoped_index_reads_non_predicate_projected_column() { + let _g = CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + let (bytes, schema) = two_col_parquet(); + let (store, loc) = stage(bytes.clone()).await; + let fo = footer_only(&bytes); + let cols = resolve_predicate_parquet_columns(&schema, &fo, &["price".to_string()]); + let aug = load_scoped_page_index_cols(&store, &loc, &fo, &cols, &[]) + .await + .unwrap(); + let selection = RowSelection::from(vec![RowSelector::skip(16), RowSelector::select(16)]); + let scoped_vals = read_selected_column(&bytes, &aug, 1, selection.clone()).unwrap(); + let full = full_index(&bytes); + let full_vals = read_selected_column(&bytes, &full, 1, selection).unwrap(); + let expected: Vec = (116..132).collect(); + assert_eq!(scoped_vals, expected); + assert_eq!(scoped_vals, full_vals); + } + + // ── cache behavior: hits, independence, eviction ────────────────────── + + /// Second identical load is a pure hit in BOTH caches; no new cells/bytes. + /// Cells: predicate `price` → 1 CI cell `(col0,rg0)`; all-column OffsetIndex + /// (the default) → 2 OI cells (one per column). + #[tokio::test] + async fn second_load_is_cache_hit() { + let _g = CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + let (bytes, schema) = two_col_parquet(); + let (store, loc) = stage(bytes.clone()).await; + let fo = footer_only(&bytes); + let cols = resolve_predicate_parquet_columns(&schema, &fo, &["price".to_string()]); + + let _ = load_scoped_page_index_cols(&store, &loc, &fo, &cols, &[]) + .await + .unwrap(); + let (c1, o1) = (ci(), oi()); + assert_eq!( + (c1.hits, c1.misses, c1.entries), + (0, 1, 1), + "1 CI cell (price,rg0)" + ); + assert_eq!( + (o1.hits, o1.misses, o1.entries), + (0, 2, 2), + "2 OI cells (col0,col1)" + ); + assert!(c1.used_bytes > 0 && o1.used_bytes > 0); + + let _ = load_scoped_page_index_cols(&store, &loc, &fo, &cols, &[]) + .await + .unwrap(); + let (c2, o2) = (ci(), oi()); + assert_eq!( + (c2.hits, c2.misses, c2.entries, c2.used_bytes), + (1, 1, 1, c1.used_bytes) + ); + assert_eq!( + (o2.hits, o2.misses, o2.entries, o2.used_bytes), + (2, 2, 2, o1.used_bytes) + ); + } + + /// Distinct predicate columns → distinct CI cells, but the OffsetIndex column + /// cells are SHARED. Both loads default to the all-column OffsetIndex, so the + /// second load re-reads the SAME 2 OI cells from cache (no new cells). This is + /// the whole point of cell-keying: a column's index is stored once per file. + #[tokio::test] + async fn distinct_predicates_share_offset_index() { + let _g = CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + let (bytes, schema) = two_col_parquet(); + let (store, loc) = stage(bytes.clone()).await; + let fo = footer_only(&bytes); + let c_price = resolve_predicate_parquet_columns(&schema, &fo, &["price".to_string()]); + let c_qty = resolve_predicate_parquet_columns(&schema, &fo, &["qty".to_string()]); + + let _ = load_scoped_page_index_cols(&store, &loc, &fo, &c_price, &[]) + .await + .unwrap(); + let _ = load_scoped_page_index_cols(&store, &loc, &fo, &c_qty, &[]) + .await + .unwrap(); + + assert_eq!( + ci().entries, + 2, + "distinct predicate cells: (price,rg0) + (qty,rg0)" + ); + assert_eq!( + oi().entries, + 2, + "all-column OffsetIndex: 2 column cells, shared" + ); + // Second (qty) load re-read the same 2 OI cells from cache. + assert_eq!(oi().hits, 2); + } + + /// The cell-keying payoff: a predicate that ADDS a column reuses the cell the + /// first predicate already decoded, instead of re-decoding it inside a new + /// set-keyed entry. `price` then `{price, qty}` → `price`'s cell is a HIT; only + /// `qty`'s cell is freshly decoded. (Under the old set-keyed cache this was a + /// full miss that re-decoded `price`.) + #[tokio::test] + async fn adding_predicate_column_reuses_existing_cell() { + let _g = CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + let (bytes, schema) = two_col_parquet(); + let (store, loc) = stage(bytes.clone()).await; + let fo = footer_only(&bytes); + let c_price = resolve_predicate_parquet_columns(&schema, &fo, &["price".to_string()]); + let c_both = resolve_predicate_parquet_columns( + &schema, + &fo, + &["price".to_string(), "qty".to_string()], + ); + + let _ = load_scoped_page_index_cols(&store, &loc, &fo, &c_price, &[]) + .await + .unwrap(); + assert_eq!( + (ci().hits, ci().misses, ci().entries), + (0, 1, 1), + "price cell decoded" + ); + + // Predicate now covers {price, qty}: price's cell hits, qty's cell misses. + let _ = load_scoped_page_index_cols(&store, &loc, &fo, &c_both, &[]) + .await + .unwrap(); + assert_eq!( + (ci().hits, ci().misses, ci().entries), + (1, 2, 2), + "price cell reused (hit); only qty cell freshly decoded" + ); + clear_scoped_cache_for_test(); + } + + /// Two predicates on the SAME column with DIFFERENT literals resolve to the + /// same `(file, col)` parquet column, so they share the one CI cell — predicate + /// *value* never multiplies cache entries. (`status>=400` vs `status>=100`.) + #[tokio::test] + async fn different_literals_same_column_share_cell() { + let _g = CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + let (bytes, schema) = two_col_parquet(); + let (store, loc) = stage(bytes.clone()).await; + let fo = footer_only(&bytes); + // Both predicates are on `price` (col 0) — only the literal differs, which + // never enters the cache key. + let cols = resolve_predicate_parquet_columns(&schema, &fo, &["price".to_string()]); + + let _ = load_scoped_page_index_cols(&store, &loc, &fo, &cols, &[]) + .await + .unwrap(); + let _ = load_scoped_page_index_cols(&store, &loc, &fo, &cols, &[]) + .await + .unwrap(); + assert_eq!( + ci().entries, + 1, + "same column → one cell regardless of literal" + ); + assert_eq!(ci().hits, 1); + clear_scoped_cache_for_test(); + } + + /// CI hit/miss accounting across two predicate-column sets. + #[tokio::test] + async fn stats_count_hits_and_misses() { + let _g = CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + let (bytes, schema) = two_col_parquet(); + let (store, loc) = stage(bytes.clone()).await; + let fo = footer_only(&bytes); + let c_price = resolve_predicate_parquet_columns(&schema, &fo, &["price".to_string()]); + let c_qty = resolve_predicate_parquet_columns(&schema, &fo, &["qty".to_string()]); + + let _ = load_scoped_page_index_cols(&store, &loc, &fo, &c_price, &[]) + .await + .unwrap(); + assert_eq!((ci().hits, ci().misses), (0, 1)); + let _ = load_scoped_page_index_cols(&store, &loc, &fo, &c_price, &[]) + .await + .unwrap(); + assert_eq!((ci().hits, ci().misses), (1, 1)); + let _ = load_scoped_page_index_cols(&store, &loc, &fo, &c_qty, &[]) + .await + .unwrap(); + assert_eq!((ci().hits, ci().misses), (1, 2)); + let _ = load_scoped_page_index_cols(&store, &loc, &fo, &c_price, &[]) + .await + .unwrap(); + let _ = load_scoped_page_index_cols(&store, &loc, &fo, &c_qty, &[]) + .await + .unwrap(); + let s = ci(); + assert_eq!((s.hits, s.misses, s.entries, s.evictions), (3, 2, 2, 0)); + } + + /// Byte-bounded LRU on the (now cell-keyed) ColumnIndex cache: with the budget + /// sized to hold ~1.5 cells, loading two distinct column cells evicts the LRU + /// one; the cache never exceeds its limit and never degrades to "cache + /// nothing"; the most-recently-used cell survives. + #[tokio::test] + async fn lru_evicts_over_byte_budget() { + let _g = CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + let (bytes, schema) = two_col_parquet(); + let (store, loc) = stage(bytes.clone()).await; + let fo = footer_only(&bytes); + let c_price = resolve_predicate_parquet_columns(&schema, &fo, &["price".to_string()]); + let c_qty = resolve_predicate_parquet_columns(&schema, &fo, &["qty".to_string()]); + + // Measure one CI cell (predicate `price` = col0 at the single RG), then set + // a budget of ~1.5 cells so a second distinct cell forces an eviction. + let _ = load_scoped_page_index_cols(&store, &loc, &fo, &c_price, &[]) + .await + .unwrap(); + let one_cell = ci().used_bytes; + assert!(one_cell > 0); + let budget = one_cell + one_cell / 2; + clear_scoped_cache_for_test(); + set_column_index_cache_limit_for_test(budget); + + let _ = load_scoped_page_index_cols(&store, &loc, &fo, &c_price, &[]) + .await + .unwrap(); // cell (col0) + let _ = load_scoped_page_index_cols(&store, &loc, &fo, &c_qty, &[]) + .await + .unwrap(); // cell (col1) → evicts col0 + + assert!( + ci().used_bytes <= budget, + "CI bytes {} must stay within {}", + ci().used_bytes, + budget + ); + assert_eq!(ci().entries, 1, "only the most-recent cell fits"); + assert!(ci().evictions >= 1, "the LRU cell must have evicted"); + + // The most-recently-used cell (qty/col1) must still be a hit. + let hits_before = ci().hits; + let _ = load_scoped_page_index_cols(&store, &loc, &fo, &c_qty, &[]) + .await + .unwrap(); + assert_eq!(ci().hits, hits_before + 1, "MRU cell must remain cached"); + + clear_scoped_cache_for_test(); + } + + /// CI cells are keyed per `(file, col, rg)`. Loading a predicate column on a + /// 4-RG file caches 4 cells (one per RG); a repeat query hits all 4. + #[tokio::test] + async fn rg_scoped_key_includes_surviving_rgs() { + let _g = CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + let (bytes, schema) = four_rg_parquet(); + let (store, loc) = stage(bytes.clone()).await; + let fo = footer_only(&bytes); + let cols = resolve_predicate_parquet_columns(&schema, &fo, &["id".to_string()]); + + // Cold: 4 RGs × 1 predicate col → 4 CI misses, 4 CI entries. + let _ = load_scoped_page_index_cols(&store, &loc, &fo, &cols, &[]) + .await + .unwrap(); + assert_eq!( + (ci().misses, ci().entries), + (4, 4), + "4 RGs × col → 4 CI cells" + ); + + // Warm: all 4 cells hit, entry count unchanged. + let _ = load_scoped_page_index_cols(&store, &loc, &fo, &cols, &[]) + .await + .unwrap(); + assert_eq!((ci().hits, ci().entries), (4, 4), "warm: all 4 cells hit"); + + // OI: empty projection → all 2 columns × 1 file → 2 OI entries. + assert_eq!(oi().entries, 2, "OI: 2 cols (all cols) × 1 file"); + clear_scoped_cache_for_test(); + } + + /// The combined payoff across BOTH axes: a second query that adds a new + /// predicate column AND scans a wider RG set decodes only the genuinely new + /// `(col, rg)` cells. Uses `wide4` (1 RG) for the column axis and asserts CI + /// cell-level hit/miss deltas. + #[tokio::test] + async fn new_column_combination_caches_only_new_column_cells() { + let _g = CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + let (bytes, schema) = wide4_parquet(); // n0,n1,s0,s1 — 1 RG + let (store, loc) = stage(bytes.clone()).await; + let fo = footer_only(&bytes); + let c_n0 = resolve_predicate_parquet_columns(&schema, &fo, &["n0".to_string()]); + let c_n0_n1 = + resolve_predicate_parquet_columns(&schema, &fo, &["n0".to_string(), "n1".to_string()]); + let c_n1_s0 = + resolve_predicate_parquet_columns(&schema, &fo, &["n1".to_string(), "s0".to_string()]); + + // {n0}: 1 new cell. + let _ = load_scoped_page_index_cols(&store, &loc, &fo, &c_n0, &[]) + .await + .unwrap(); + assert_eq!((ci().hits, ci().misses, ci().entries), (0, 1, 1)); + // {n0,n1}: n0 hits, n1 new. + let _ = load_scoped_page_index_cols(&store, &loc, &fo, &c_n0_n1, &[]) + .await + .unwrap(); + assert_eq!( + (ci().hits, ci().misses, ci().entries), + (1, 2, 2), + "n0 reused; n1 new" + ); + // {n1,s0}: n1 hits, s0 new. + let _ = load_scoped_page_index_cols(&store, &loc, &fo, &c_n1_s0, &[]) + .await + .unwrap(); + assert_eq!( + (ci().hits, ci().misses, ci().entries), + (2, 3, 3), + "n1 reused; s0 new" + ); + clear_scoped_cache_for_test(); + } + + /// OffsetIndex equivalent: different projections cache only the new column + /// cells. Project {s0} (offset cols n1∪s0∪{0}), then {s1} (offset cols + /// n1∪s1∪{0}) — the shared cols (0, n1) hit; only the genuinely new projected + /// column is decoded. + #[tokio::test] + async fn different_projections_cache_only_new_offset_columns() { + let _g = CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + let (bytes, schema) = wide4_parquet(); // n0=0,n1=1,s0=2,s1=3 — 1 RG + let (store, loc) = stage(bytes.clone()).await; + let fo = footer_only(&bytes); + let pred_cols = resolve_predicate_parquet_columns(&schema, &fo, &["n1".to_string()]); + + // Project s0 (col 2): offset cols = {0, 1(n1), 2(s0)} → 3 new cells. + let _ = load_scoped_page_index_cols(&store, &loc, &fo, &pred_cols, &[2]) + .await + .unwrap(); + assert_eq!( + (oi().hits, oi().misses, oi().entries), + (0, 3, 3), + "cols 0,1,2" + ); + + // Project s1 (col 3): offset cols = {0, 1, 3}. Cols 0 & 1 hit; col 3 new. + let _ = load_scoped_page_index_cols(&store, &loc, &fo, &pred_cols, &[3]) + .await + .unwrap(); + assert_eq!( + (oi().hits, oi().misses, oi().entries), + (2, 4, 4), + "cols 0,1 reused (2 hits); only col 3 freshly decoded" + ); + clear_scoped_cache_for_test(); + } + + // ── Step 2: column-scoping the OffsetIndex ──────────────────────────── + + #[tokio::test] + async fn col_scoped_offset_index_only_for_requested_columns() { + let _g = CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + let (bytes, schema) = wide4_parquet(); + let (store, loc) = stage(bytes.clone()).await; + let fo = footer_only(&bytes); + let pred_cols = resolve_predicate_parquet_columns(&schema, &fo, &["n1".to_string()]); + assert_eq!(pred_cols, vec![1]); + + let aug = load_scoped_page_index_cols(&store, &loc, &fo, &pred_cols, &[2]) + .await + .unwrap(); + let o = aug.offset_index().unwrap(); + // wide4 has 256 rows / page-size 32 → real columns have multiple pages. + let full = full_index(&bytes); + let real_pages = full.offset_index().unwrap()[0][0].page_locations().len(); + assert!(real_pages > 1, "fixture should have multi-page columns"); + // Scoped columns (predicate 1 ∪ projection 2 ∪ metric 0) carry the REAL + // page index; the rest carry a single whole-RG placeholder page (non-empty + // so any consumer dereference is safe — never empty, which would panic). + for &c in &[0usize, 1, 2] { + assert_eq!( + o[0][c].page_locations().len(), + real_pages, + "col {c} (pred/proj/metric) real OI" + ); + } + assert_eq!( + o[0][3].page_locations().len(), + 1, + "col 3 (scoped out) OI is a single-page placeholder, not real and not empty" + ); + } + + /// Regression: the single-page placeholder for a scoped-OUT column must carry + /// the column chunk's REAL byte offset + size — not (0, 0). A (0, 0) placeholder + /// makes arrow derive a fetch byte range with `end < start` when a row selection + /// actually reads that column (e.g. `match() | sort | head`), and the read path's + /// `range.end - range.start` then underflows ("subtract with overflow" in debug / + /// "capacity overflow" in release; see indexed_table/parquet_bridge.rs get_byte_ranges). + /// The placeholder page must span `[col_start, col_start + col_len)` so any range + /// derived from it is a valid full-chunk read. + #[tokio::test] + async fn placeholder_offset_index_spans_real_chunk_byte_range() { + let _g = CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + let (bytes, schema) = wide4_parquet(); // n0,n1,s0,s1 — 1 RG, multi-page columns + let (store, loc) = stage(bytes.clone()).await; + let fo = footer_only(&bytes); + // Scope to n1 (pred) + s0 (proj). Col 3 (s1) is scoped out → placeholder. + let pred_cols = resolve_predicate_parquet_columns(&schema, &fo, &["n1".to_string()]); + let aug = load_scoped_page_index_cols(&store, &loc, &fo, &pred_cols, &[2]) + .await + .unwrap(); + let o = aug.offset_index().unwrap(); + + // The scoped-out column's placeholder is a single page... + let ph_pages = o[0][3].page_locations(); + assert_eq!( + ph_pages.len(), + 1, + "scoped-out col 3 must have a single placeholder page" + ); + let ph = &ph_pages[0]; + + // ...whose coordinates equal the real column chunk byte range from the footer. + let (col_start, col_len) = fo.row_group(0).column(3).byte_range(); + assert_eq!( + ph.offset as u64, col_start, + "placeholder page offset must be the real chunk start (not 0)" + ); + assert_eq!( + ph.compressed_page_size as u64, col_len, + "placeholder page size must be the real chunk compressed size (not 0)" + ); + assert!( + col_start > 0 && col_len > 0, + "fixture sanity: real chunk has non-zero offset+size" + ); + + // The byte range a reader derives from this page must NOT underflow: + // end (offset+size) >= start (offset). + let end = ph.offset as u64 + ph.compressed_page_size as u64; + assert!( + end >= ph.offset as u64, + "derived range must not underflow (end >= start)" + ); + assert_eq!( + ph.first_row_index, 0, + "single placeholder page starts at row 0" + ); + clear_scoped_cache_for_test(); + } + + #[tokio::test] + async fn col_scoped_reads_projected_non_predicate_column() { + let _g = CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + let (bytes, schema) = two_col_parquet(); + let (store, loc) = stage(bytes.clone()).await; + let fo = footer_only(&bytes); + let pred_cols = resolve_predicate_parquet_columns(&schema, &fo, &["price".to_string()]); + let aug = load_scoped_page_index_cols(&store, &loc, &fo, &pred_cols, &[1]) + .await + .unwrap(); + let selection = RowSelection::from(vec![RowSelector::skip(16), RowSelector::select(16)]); + let scoped_vals = read_selected_column(&bytes, &aug, 1, selection.clone()).unwrap(); + let full = full_index(&bytes); + let full_vals = read_selected_column(&bytes, &full, 1, selection).unwrap(); + let expected: Vec = (116..132).collect(); + assert_eq!(scoped_vals, expected); + assert_eq!(scoped_vals, full_vals); + } + + #[tokio::test] + async fn col_scoping_reduces_offset_index_bytes() { + let _g = CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + let (bytes, schema) = wide4_parquet(); + let (store, loc) = stage(bytes.clone()).await; + let fo = footer_only(&bytes); + let pred_cols = resolve_predicate_parquet_columns(&schema, &fo, &["n1".to_string()]); + + let _ = load_scoped_page_index_cols(&store, &loc, &fo, &pred_cols, &[]) + .await + .unwrap(); + let all_cols = oi().used_bytes; + clear_scoped_cache_for_test(); + let _ = load_scoped_page_index_cols(&store, &loc, &fo, &pred_cols, &[2]) + .await + .unwrap(); + assert!( + oi().used_bytes < all_cols, + "col-scoped OI {} < all-col {}", + oi().used_bytes, + all_cols + ); + clear_scoped_cache_for_test(); + } + + /// Cell-keying makes OffsetIndex reuse automatic: an all-columns load caches + /// per-column cells, and a later column-scoped load whose set is covered by + /// those cells hits them — no new entries, no special "collapse to all-columns + /// sentinel" needed (the prior set-keyed design's mechanism). + #[tokio::test] + async fn col_scoping_full_coverage_collapses_to_all_columns_entry() { + let _g = CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + let (bytes, schema) = two_col_parquet(); + let (store, loc) = stage(bytes.clone()).await; + let fo = footer_only(&bytes); + let pred_cols = resolve_predicate_parquet_columns(&schema, &fo, &["price".to_string()]); + + let _ = load_scoped_page_index_cols(&store, &loc, &fo, &pred_cols, &[]) + .await + .unwrap(); + assert_eq!(oi().entries, 2, "all-columns load caches 2 column cells"); + // Project {1}; union {0,1} = both columns, both already cached → 2 hits. + let _ = load_scoped_page_index_cols(&store, &loc, &fo, &pred_cols, &[1]) + .await + .unwrap(); + assert_eq!( + oi().entries, + 2, + "covered columns reuse their cells, no new entries" + ); + assert_eq!(oi().hits, 2); + clear_scoped_cache_for_test(); + } + + /// CI (all RGs) + OI column-scoped: both axes populated in one call. + #[tokio::test] + async fn fully_scoped_load_combines_both_axes() { + let _g = CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + let (bytes, schema) = four_rg_parquet(); + let (store, loc) = stage(bytes.clone()).await; + let fo = footer_only(&bytes); + let pred_cols = resolve_predicate_parquet_columns(&schema, &fo, &["id".to_string()]); + let proj_cols = resolve_predicate_parquet_columns(&schema, &fo, &["val".to_string()]); + + let aug = load_scoped_page_index_cols(&store, &loc, &fo, &pred_cols, &proj_cols) + .await + .unwrap(); + let c = aug.column_index().unwrap(); + // All 4 RGs built for the predicate column (id = col 0). + assert!(!matches!(c[0][0], ColumnIndexMetaData::NONE), "RG0 real CI"); + assert!(!matches!(c[2][0], ColumnIndexMetaData::NONE), "RG2 real CI"); + // CI: 4 RGs × 1 pred col = 4 cells; OI: {id(0), val(1)} × 1 file = 2 cells. + assert_eq!(ci().entries, 4, "4 RGs × 1 pred col"); + assert_eq!(oi().entries, 2, "2 proj cols × 1 file"); + clear_scoped_cache_for_test(); + } + + // ── Eviction on file deletion ───────────────────────────────────────────── + + /// Evicting a file removes ALL its CI and OI cells from the caches — + /// a subsequent load for the same path is a miss, not a stale hit. + #[tokio::test] + async fn evict_file_clears_all_cells_for_that_path() { + let _g = CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + let (bytes, schema) = two_col_parquet(); + let (store, loc) = stage(bytes.clone()).await; + let fo = footer_only(&bytes); + let cols = resolve_predicate_parquet_columns(&schema, &fo, &["price".to_string()]); + + // Warm: 1 CI cell (price,rg0) + 2 OI cells (col0, col1). + let _ = load_scoped_page_index_cols(&store, &loc, &fo, &cols, &[0, 1]) + .await + .unwrap(); + assert_eq!(ci().entries, 1); + assert_eq!(oi().entries, 2); + assert_eq!(ci().misses, 1); + + // Evict the file. + super::super::evict_file_from_scoped_cache(loc.as_ref()); + assert_eq!(ci().entries, 0, "CI cells must be gone after eviction"); + assert_eq!(oi().entries, 0, "OI cells must be gone after eviction"); + + // Reload — must be a miss, not a hit from stale cache. + let _ = load_scoped_page_index_cols(&store, &loc, &fo, &cols, &[0, 1]) + .await + .unwrap(); + assert_eq!( + ci().misses, + 2, + "second load after eviction must be a cache miss" + ); + assert_eq!( + ci().hits, + 0, + "no hits — eviction prevented serving stale data" + ); + + clear_scoped_cache_for_test(); + } + + /// Evicting file A does not remove cells for file B. Cross-file isolation. + #[tokio::test] + async fn evict_file_does_not_affect_other_files() { + let _g = CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + let (bytes, schema) = two_col_parquet(); + let cols = resolve_predicate_parquet_columns( + &schema, + &footer_only(&bytes), + &["price".to_string()], + ); + + // Stage two identical files at different paths. + let store_a: Arc = + Arc::new(object_store::memory::InMemory::new()); + let loc_a = object_store::path::Path::from("file_a.parquet"); + let loc_b = object_store::path::Path::from("file_b.parquet"); + store_a + .put(&loc_a, object_store::PutPayload::from_bytes(bytes.clone())) + .await + .unwrap(); + store_a + .put(&loc_b, object_store::PutPayload::from_bytes(bytes.clone())) + .await + .unwrap(); + + let fo = footer_only(&bytes); + let _ = load_scoped_page_index_cols(&store_a, &loc_a, &fo, &cols, &[0, 1]) + .await + .unwrap(); + let _ = load_scoped_page_index_cols(&store_a, &loc_b, &fo, &cols, &[0, 1]) + .await + .unwrap(); + assert_eq!(ci().entries, 2, "one CI cell per file"); + assert_eq!(oi().entries, 4, "two OI cells per file"); + + // Evict only file_a. + super::super::evict_file_from_scoped_cache(loc_a.as_ref()); + assert_eq!(ci().entries, 1, "only file_a's CI cell removed"); + assert_eq!(oi().entries, 2, "only file_a's OI cells removed"); + + // file_b's cells are still hits. + let hits_before = ci().hits; + let _ = load_scoped_page_index_cols(&store_a, &loc_b, &fo, &cols, &[0, 1]) + .await + .unwrap(); + assert_eq!( + ci().hits, + hits_before + 1, + "file_b CI cell must still be cached" + ); + + clear_scoped_cache_for_test(); + } + + // ── Whole-region fetch (remote/warm stores) ─────────────────────────────── + + /// With whole-region fetch on, the grafted index decodes IDENTICALLY to the + /// narrow path: scoped pruning still matches the full index, and the decode is + /// still scoped (one CI cell per (col, rg), not one per column). Only the bytes + /// fetched widen. `whole_index_region` is a superset of the scoped union, so the + /// shared buffer must contain every scoped column's absolute offsets. + #[tokio::test] + async fn whole_region_fetch_matches_full_index_and_stays_scoped() { + let _g = CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + set_whole_region_fetch_enabled(true); + + let (bytes, schema) = four_rg_parquet(); // id, v — 4 RGs, multi-page + let (store, loc) = stage(bytes.clone()).await; + let fo = footer_only(&bytes); + let cols = resolve_predicate_parquet_columns(&schema, &fo, &["id".to_string()]); + + // Whole CI region is a strict superset of the scoped `id`-only union. + let region = whole_index_region(&fo, ci_extent).unwrap(); + let scoped = union_extent(&[fo.row_group(0).column(0).clone()], ci_extent).unwrap(); + assert!( + region.start <= scoped.start && region.end >= scoped.end, + "region superset of scoped" + ); + + let aug = load_scoped_page_index_cols(&store, &loc, &fo, &cols, &[]) + .await + .unwrap(); + + // Decode stayed scoped: 4 RGs × 1 predicate col = 4 CI cells (not all columns). + assert_eq!( + ci().entries, + 4, + "decode scoped to predicate col despite wide fetch" + ); + let c = aug.column_index().unwrap(); + assert!( + matches!(c[0][1], ColumnIndexMetaData::NONE), + "non-predicate col stays NONE" + ); + + // Pruning identical to the full index. + let full = full_index(&bytes); + let pp = + build_pruning_predicate(&pred("id", 0, Operator::GtEq, 20), schema.clone()).unwrap(); + for rg in 0..4 { + let s = PagePruner::new(&schema, Arc::clone(&aug)).prune_rg(&pp, rg, None); + let f = PagePruner::new(&schema, Arc::clone(&full)).prune_rg(&pp, rg, None); + assert_eq!( + s.as_ref().map(kept), + f.as_ref().map(kept), + "RG{rg} pruning matches full" + ); + } + + set_whole_region_fetch_enabled(false); + clear_scoped_cache_for_test(); + } + + /// The OffsetIndex path under whole-region fetch reads the projected column + /// correctly (same bytes as the full index), proving the shared whole-region + /// buffer resolves every scoped column's absolute offsets. + #[tokio::test] + async fn whole_region_fetch_offset_index_reads_match_full() { + let _g = CACHE_TEST_GUARD.lock().unwrap(); + clear_scoped_cache_for_test(); + set_whole_region_fetch_enabled(true); + + let (bytes, schema) = two_col_parquet(); + let (store, loc) = stage(bytes.clone()).await; + let fo = footer_only(&bytes); + let cols = resolve_predicate_parquet_columns(&schema, &fo, &["price".to_string()]); + let aug = load_scoped_page_index_cols(&store, &loc, &fo, &cols, &[1]) + .await + .unwrap(); + + let selection = RowSelection::from(vec![RowSelector::skip(16), RowSelector::select(16)]); + let scoped_vals = read_selected_column(&bytes, &aug, 1, selection.clone()).unwrap(); + let full_vals = read_selected_column(&bytes, &full_index(&bytes), 1, selection).unwrap(); + assert_eq!(scoped_vals, (116..132).collect::>()); + assert_eq!(scoped_vals, full_vals); + + set_whole_region_fetch_enabled(false); + clear_scoped_cache_for_test(); + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/statistics_cache.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/cache/statistics_cache.rs similarity index 83% rename from sandbox/plugins/analytics-backend-datafusion/rust/src/statistics_cache.rs rename to sandbox/plugins/analytics-backend-datafusion/rust/src/cache/statistics_cache.rs index 2930dee08f3f2..ab9f74b6c04f2 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/statistics_cache.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/cache/statistics_cache.rs @@ -6,23 +6,25 @@ * compatible open source license. */ -use crate::eviction_policy::{ - create_policy, CacheError, CachePolicy, CacheResult, PolicyType, -}; +use crate::eviction_policy::{create_policy, CacheError, CachePolicy, CacheResult, PolicyType}; use arrow_array::Array; +use dashmap::DashMap; use datafusion::common::stats::{ColumnStatistics, Precision}; use datafusion::common::ScalarValue; -use dashmap::DashMap; +use datafusion::common::TableReference; +use datafusion::execution::cache::cache_manager::{ + CachedFileMetadata, FileStatisticsCache, FileStatisticsCacheEntry, +}; use datafusion::execution::cache::CacheAccessor; -use datafusion::execution::cache::cache_manager::{CachedFileMetadata, FileStatisticsCache, FileStatisticsCacheEntry}; use datafusion::execution::cache::TableScopedPath; -use datafusion::common::TableReference; use datafusion::physical_plan::Statistics; use object_store::{path::Path, ObjectMeta}; use std::collections::HashMap; -use std::sync::{Arc, Mutex}; use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use arrow_schema::SchemaRef; +use parquet::file::metadata::ParquetMetaData; use std::fs::File; /// Trait to calculate heap memory size for statistics objects @@ -40,7 +42,7 @@ impl HeapSize for Statistics { } impl HeapSize -for Precision + for Precision { fn heap_size(&self) -> usize { match self { @@ -125,8 +127,10 @@ struct MemoryState { pub struct CustomStatisticsCache { /// The underlying DataFusion statistics cache (DashMap-based, already thread-safe) inner_cache: DashMap, - /// The eviction policy (thread-safe) - policy: Arc>>, + /// Current eviction policy. `Mutex` guards atomic swaps in `set_policy`; + /// hot-path callers clone the `Arc` while holding the lock briefly, then call + /// through the clone without holding the lock. + policy: Mutex>, /// Size limit for the cache in bytes size_limit: AtomicUsize, /// Eviction threshold (0.0 to 1.0) @@ -144,7 +148,9 @@ impl CustomStatisticsCache { pub fn new(policy_type: PolicyType, size_limit: usize, eviction_threshold: f64) -> Self { Self { inner_cache: DashMap::new(), - policy: Arc::new(Mutex::new(create_policy(policy_type))), + policy: Mutex::new( + create_policy(policy_type).expect("statistics cache requires Lru or Lfu"), + ), size_limit: AtomicUsize::new(size_limit), eviction_threshold, memory_state: Arc::new(Mutex::new(MemoryState { @@ -168,7 +174,10 @@ impl CustomStatisticsCache { /// Get total memory consumed by all cached statistics pub fn memory_consumed(&self) -> usize { - self.memory_state.lock().map(|guard| guard.total).unwrap_or(0) + self.memory_state + .lock() + .map(|guard| guard.total) + .unwrap_or(0) } /// Get cache hit count @@ -186,7 +195,11 @@ impl CustomStatisticsCache { let hits = self.hit_count(); let misses = self.miss_count(); let total = hits + misses; - if total == 0 { 0.0 } else { hits as f64 / total as f64 } + if total == 0 { + 0.0 + } else { + hits as f64 / total as f64 + } } /// Reset hit and miss counters @@ -195,17 +208,23 @@ impl CustomStatisticsCache { self.miss_count.store(0, Ordering::Relaxed); } + /// Clone the policy Arc without holding the Mutex. Hot-path callers use this + /// so they don't hold the lock while calling policy methods. + fn policy(&self) -> Arc { + self.policy + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + } + /// Update the cache size limit pub fn update_size_limit(&self, new_limit: usize) -> CacheResult<()> { self.size_limit.store(new_limit, Ordering::Relaxed); let current_size = self.current_size()?; if current_size > new_limit { - let target_eviction = current_size - (new_limit as f64 * self.eviction_threshold) as usize; - let candidates = { - if let Ok(policy_guard) = self.policy.lock() { - policy_guard.select_for_eviction(target_eviction) - } else { vec![] } - }; + let target_eviction = + current_size - (new_limit as f64 * self.eviction_threshold) as usize; + let candidates = self.policy().select_for_eviction(target_eviction); for candidate_key in candidates { if let Ok(path) = self.parse_key_to_path(&candidate_key) { self.remove_internal(&path); @@ -215,31 +234,34 @@ impl CustomStatisticsCache { Ok(()) } - /// Switch to a different eviction policy + /// Switch to a different eviction policy, rebuilding state from current entries. pub fn set_policy(&self, policy_type: PolicyType) -> CacheResult<()> { let entries: Vec<(String, usize)> = { - let state = self.memory_state.lock().map_err(|e| CacheError::PolicyLockError { - reason: format!("Failed to acquire memory_state lock: {}", e), - })?; + let state = self + .memory_state + .lock() + .map_err(|e| CacheError::PolicyLockError { + reason: format!("Failed to acquire memory_state lock: {}", e), + })?; state.tracker.iter().map(|(k, v)| (k.clone(), *v)).collect() }; - let mut policy_guard = self.policy.lock().map_err(|e| CacheError::PolicyLockError { - reason: format!("Failed to acquire policy lock: {}", e), - })?; - let mut new_policy = create_policy(policy_type); + let new_policy = create_policy(policy_type).expect("statistics cache requires Lru or Lfu"); for (key, size) in entries { new_policy.on_insert(&key, size); } - *policy_guard = new_policy; + let mut guard = self + .policy + .lock() + .map_err(|e| CacheError::PolicyLockError { + reason: format!("Failed to acquire policy lock: {}", e), + })?; + *guard = new_policy; Ok(()) } /// Get current policy name pub fn policy_name(&self) -> CacheResult { - let policy_guard = self.policy.lock().map_err(|e| CacheError::PolicyLockError { - reason: format!("Failed to acquire policy lock: {}", e), - })?; - Ok(policy_guard.policy_name().to_string()) + Ok(self.policy().policy_name().to_string()) } /// Get current cache size according to policy (uses actual memory consumption) @@ -252,23 +274,22 @@ impl CustomStatisticsCache { self.size_limit.load(Ordering::Relaxed) } - /// Manually trigger eviction (requires &mut self) + /// Manually trigger eviction. pub fn evict(&mut self, target_size: usize) -> CacheResult { - if target_size == 0 { return Ok(0); } - - let candidates = { - let policy_guard = self.policy.lock().map_err(|e| CacheError::PolicyLockError { - reason: format!("Failed to acquire policy lock: {}", e), - })?; - policy_guard.select_for_eviction(target_size) - }; + if target_size == 0 { + return Ok(0); + } + let candidates = self.policy().select_for_eviction(target_size); let mut freed_size = 0; for key in candidates { let entry_size = { - let state = self.memory_state.lock().map_err(|e| CacheError::PolicyLockError { - reason: format!("Failed to acquire memory_state lock: {}", e), - })?; + let state = self + .memory_state + .lock() + .map_err(|e| CacheError::PolicyLockError { + reason: format!("Failed to acquire memory_state lock: {}", e), + })?; state.tracker.get(&key).copied().unwrap_or(0) }; @@ -279,12 +300,12 @@ impl CustomStatisticsCache { state.tracker.remove(&key); state.total = state.total.saturating_sub(entry_size); } - if let Ok(mut policy_guard) = self.policy.lock() { - policy_guard.on_remove(&key); - } + self.policy().on_remove(&key); freed_size += entry_size; } - if freed_size >= target_size { break; } + if freed_size >= target_size { + break; + } } } } @@ -322,9 +343,7 @@ impl CustomStatisticsCache { state.total = state.total.saturating_sub(old_size); } } - if let Ok(mut policy_guard) = self.policy.lock() { - policy_guard.on_remove(&key); - } + self.policy().on_remove(&key); } result.map(|x| x.1) } @@ -345,11 +364,11 @@ impl CustomStatisticsCache { let key = k.to_string(); let memory_size = { let state = self.memory_state.lock(); - state.map(|s| s.tracker.get(&key).copied().unwrap_or(0)).unwrap_or(0) + state + .map(|s| s.tracker.get(&key).copied().unwrap_or(0)) + .unwrap_or(0) }; - if let Ok(mut policy_guard) = self.policy.lock() { - policy_guard.on_access(&key, memory_size); - } + self.policy().on_access(&key, memory_size); } else { self.miss_count.fetch_add(1, Ordering::Relaxed); } @@ -361,19 +380,18 @@ impl CustomStatisticsCache { let key = k.to_string(); let memory_size = v.statistics.memory_size(); - let current_size = self.memory_state.lock() - .map(|s| s.total) - .unwrap_or(0); + let current_size = self.memory_state.lock().map(|s| s.total).unwrap_or(0); let eviction_candidates = { let size_limit = self.size_limit.load(Ordering::Relaxed); let threshold = (size_limit as f64 * self.eviction_threshold) as usize; if current_size + memory_size > threshold { - let target_eviction = (current_size + memory_size) - (size_limit as f64 * 0.6) as usize; - if let Ok(policy_guard) = self.policy.lock() { - policy_guard.select_for_eviction(target_eviction) - } else { vec![] } - } else { vec![] } + let target_eviction = + (current_size + memory_size) - (size_limit as f64 * 0.6) as usize; + self.policy().select_for_eviction(target_eviction) + } else { + vec![] + } }; for candidate_key in eviction_candidates { @@ -392,10 +410,7 @@ impl CustomStatisticsCache { state.total += memory_size; } - if let Ok(mut policy_guard) = self.policy.lock() { - policy_guard.on_insert(&key, memory_size); - } - + self.policy().on_insert(&key, memory_size); result } @@ -408,9 +423,7 @@ impl CustomStatisticsCache { state.total = state.total.saturating_sub(old_size); } } - if let Ok(mut policy_guard) = self.policy.lock() { - policy_guard.on_remove(&key); - } + self.policy().on_remove(&key); } result.map(|x| x.1) } @@ -420,7 +433,10 @@ impl CustomStatisticsCache { } pub fn len(&self) -> usize { - self.memory_state.lock().map(|s| s.tracker.len()).unwrap_or(0) + self.memory_state + .lock() + .map(|s| s.tracker.len()) + .unwrap_or(0) } pub fn clear(&self) { @@ -429,7 +445,7 @@ impl CustomStatisticsCache { state.tracker.clear(); state.total = 0; } - if let Ok(mut policy_guard) = self.policy.lock() { policy_guard.clear(); } + self.policy().clear(); self.reset_stats(); } @@ -479,11 +495,7 @@ impl FileStatisticsCache for CustomStatisticsCache { self.size_limit.load(Ordering::Relaxed) } - fn update_cache_limit(&self, limit: usize) { - // Best-effort: update_size_limit also triggers eviction; ignore its Result - // since the trait method is infallible. - let _ = self.update_size_limit(limit); - } + fn update_cache_limit(&self, _limit: usize) {} fn list_entries(&self) -> std::collections::HashMap { std::collections::HashMap::new() @@ -505,10 +517,27 @@ impl Default for CustomStatisticsCache { } } +/// Compute statistics from an already-loaded `ParquetMetaData` and schema. +/// +/// Avoids a second file/IO round-trip when the footer has already been fetched +/// (e.g. by `load_parquet_metadata` during metadata cache warming). The caller +/// is responsible for providing the correct Arrow schema derived from the same +/// metadata. +pub fn compute_parquet_statistics_from_metadata( + metadata: &ParquetMetaData, + schema: &SchemaRef, +) -> Result> { + use datafusion::datasource::physical_plan::parquet::metadata::DFParquetMetadata; + let statistics = DFParquetMetadata::statistics_from_parquet_metadata(metadata, schema)?; + Ok(statistics) +} + /// Compute statistics from a parquet file using DataFusion's built-in functionality -pub fn compute_parquet_statistics(file_path: &str) -> Result> { - use datafusion::parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder; +pub fn compute_parquet_statistics( + file_path: &str, +) -> Result> { use datafusion::datasource::physical_plan::parquet::metadata::DFParquetMetadata; + use datafusion::parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder; use object_store::local::LocalFileSystem; use object_store::path::Path; @@ -768,7 +797,23 @@ mod tests { handles.push(handle); } - for handle in handles { handle.join().unwrap(); } + for handle in handles { + handle.join().unwrap(); + } assert!(cache.len() > 0); } + + #[test] + fn test_update_cache_limit_trait_is_noop() { + use datafusion::execution::cache::cache_manager::FileStatisticsCache; + + let cache = CustomStatisticsCache::new(PolicyType::Lru, 50 * 1024 * 1024, 0.8); + assert_eq!(cache.cache_limit(), 50 * 1024 * 1024); + + FileStatisticsCache::update_cache_limit(&cache, 20 * 1024 * 1024); + assert_eq!(cache.cache_limit(), 50 * 1024 * 1024); + + cache.update_size_limit(30 * 1024 * 1024).unwrap(); + assert_eq!(cache.cache_limit(), 30 * 1024 * 1024); + } } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/cross_rt_stream.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/cross_rt_stream.rs index 87d685c22a737..05291c89925ad 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/cross_rt_stream.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/cross_rt_stream.rs @@ -23,6 +23,7 @@ use tokio::sync::mpsc::{channel, Sender}; use tokio::sync::oneshot; use tokio::task::AbortHandle; use tokio_stream::wrappers::ReceiverStream; +use tokio_util::sync::CancellationToken; /// Fires its `oneshot` when dropped. Held inside the spawned task body so the signal is sent on /// every exit path (drain, error, abort-unwind, panic). @@ -72,16 +73,22 @@ impl CrossRtStream { stream: SendableRecordBatchStream, exec: DedicatedExecutor, ) -> Self { - let (cross_rt, _abort_handle, _done_rx) = Self::new_with_df_error_stream_cancellable(stream, exec); + let (cross_rt, _abort_handle, _done_rx) = + Self::new_with_df_error_stream_cancellable(stream, exec, None); cross_rt } /// Like [`new_with_df_error_stream`](Self::new_with_df_error_stream), but also returns an /// [`AbortHandle`] and a `oneshot::Receiver` that fires once the spawned task has fully dropped /// (completion or abort) — the barrier `stream_close` waits on before the allocator closes. + /// + /// When `cancel_token` is supplied, cancel is cooperative: the producer breaks on the token and + /// runs the drop(stream)+drain cleanup, instead of an abort() mid-send that skips it. Callers + /// that pass `None` are unchanged. pub fn new_with_df_error_stream_cancellable( stream: SendableRecordBatchStream, exec: DedicatedExecutor, + cancel_token: Option, ) -> (Self, Option, oneshot::Receiver<()>) { let schema = stream.schema(); let (tx, rx) = channel(1); @@ -90,12 +97,32 @@ impl CrossRtStream { let fut = async move { let _done = DoneGuard(Some(done_tx)); - tokio::pin!(stream); - while let Some(res) = stream.next().await { - if tx_captured.send(res).await.is_err() { - return; + let mut stream = Box::pin(stream); + // Cooperative cancel: select each await against the token so a cancel breaks the loop + // and falls through to the drop(stream)+drain below, rather than an abort() mid-send. + loop { + let next = tokio::select! { + biased; + _ = async { match &cancel_token { Some(t) => t.cancelled().await, None => std::future::pending::<()>().await } } => break, + n = stream.next() => n, + }; + let res = match next { + Some(r) => r, + None => break, + }; + let sent = tokio::select! { + biased; + _ = async { match &cancel_token { Some(t) => t.cancelled().await, None => std::future::pending::<()>().await } } => break, + r = tx_captured.send(res) => r, + }; + if sent.is_err() { + break; } } + // Drop the inner stream while this future is still polled — frees the aggregate's own + // GroupValues and schedules its child producer tasks for abort. The remaining deferred + // child drops are reaped by stream_close (it waits on task_done, then flush_cpu_runtime). + drop(stream); }; let (abort_handle, join_fut) = exec.spawn_with_abort_handle(fut); @@ -106,13 +133,12 @@ impl CrossRtStream { JobError::Panic { msg } => { DataFusionError::Execution(format!("Panic: {}", msg)) } - JobError::WorkerGone => { - DataFusionError::Execution("Worker gone".to_string()) - } + JobError::WorkerGone => DataFusionError::Execution("Worker gone".to_string()), }; tx.send(Err(err)).await.ok(); } - }.boxed(); + } + .boxed(); let cross_rt = Self { driver, @@ -296,12 +322,16 @@ mod tests { stream::iter(vec![Ok(test_batch(&[1, 2, 3]))]), )); - let (cross, _abort, done_rx) = CrossRtStream::new_with_df_error_stream_cancellable(inner, exec.clone()); + let (cross, _abort, done_rx) = + CrossRtStream::new_with_df_error_stream_cancellable(inner, exec.clone(), None); let wrapped = RecordBatchStreamAdapter::new(cross.schema(), cross); tokio::pin!(wrapped); while wrapped.next().await.is_some() {} - assert!(done_rx.await.is_ok(), "done_rx must fire once the spawned task fully drops"); + assert!( + done_rx.await.is_ok(), + "done_rx must fire once the spawned task fully drops" + ); exec.join_blocking(); } @@ -315,14 +345,110 @@ mod tests { stream::pending::>(), )); - let (cross, abort, done_rx) = CrossRtStream::new_with_df_error_stream_cancellable(inner, exec.clone()); + let (cross, abort, done_rx) = + CrossRtStream::new_with_df_error_stream_cancellable(inner, exec.clone(), None); // Hold the stream so the abort, not a drop, is what ends the task. let _wrapped = RecordBatchStreamAdapter::new(cross.schema(), cross); - abort.expect("cancellable constructor yields an abort handle").abort(); + abort + .expect("cancellable constructor yields an abort handle") + .abort(); let fired = tokio::time::timeout(std::time::Duration::from_secs(5), done_rx).await; assert!(fired.is_ok(), "done_rx must fire after the task is aborted"); exec.join_blocking(); } + + // Firing the token breaks the loop and fires done_rx without an abort(), so the producer runs + // its drop+drain cleanup that frees the aggregate's GroupValues. + #[tokio::test] + async fn cancellation_token_breaks_loop_and_fires_done_rx() { + let exec = test_exec(); + let schema = test_schema(); + // Never-ending stream: the only way the task ends is the cooperative cancel. + let inner = Box::pin(RecordBatchStreamAdapter::new( + schema.clone(), + stream::pending::>(), + )); + + let token = CancellationToken::new(); + let (cross, _abort, done_rx) = CrossRtStream::new_with_df_error_stream_cancellable( + inner, + exec.clone(), + Some(token.clone()), + ); + // Hold the stream so a consumer-side drop is NOT what ends the task — the token is. + let _wrapped = RecordBatchStreamAdapter::new(cross.schema(), cross); + + token.cancel(); + + let fired = tokio::time::timeout(std::time::Duration::from_secs(5), done_rx).await; + assert!( + fired.is_ok(), + "cancelling the token must break the loop and fire done_rx" + ); + assert!( + fired.unwrap().is_ok(), + "done_rx must complete, not be dropped" + ); + exec.join_blocking(); + } + + // A token that is never fired must not perturb the normal drain path: the stream completes and + // all its batches are delivered. + #[tokio::test] + async fn uncancelled_token_drains_normally() { + let exec = test_exec(); + let schema = test_schema(); + let batches = vec![Ok(test_batch(&[1, 2, 3])), Ok(test_batch(&[4, 5]))]; + let inner = Box::pin(RecordBatchStreamAdapter::new( + schema.clone(), + stream::iter(batches), + )); + + let token = CancellationToken::new(); // never cancelled + let (cross, _abort, done_rx) = + CrossRtStream::new_with_df_error_stream_cancellable(inner, exec.clone(), Some(token)); + let wrapped = RecordBatchStreamAdapter::new(cross.schema(), cross); + tokio::pin!(wrapped); + + let mut total_rows = 0; + while let Some(batch) = wrapped.next().await { + total_rows += batch.unwrap().num_rows(); + } + assert_eq!( + total_rows, 5, + "all rows delivered when the token is never fired" + ); + assert!( + done_rx.await.is_ok(), + "done_rx fires on normal drain even with a token present" + ); + exec.join_blocking(); + } + + // Cancelling BEFORE the first poll still terminates cleanly (the biased select checks the token + // first), exercising the immediate-cancel race. + #[tokio::test] + async fn cancel_before_first_poll_terminates() { + let exec = test_exec(); + let schema = test_schema(); + let inner = Box::pin(RecordBatchStreamAdapter::new( + schema.clone(), + stream::pending::>(), + )); + + let token = CancellationToken::new(); + token.cancel(); // already cancelled before the task runs + let (cross, _abort, done_rx) = + CrossRtStream::new_with_df_error_stream_cancellable(inner, exec.clone(), Some(token)); + let _wrapped = RecordBatchStreamAdapter::new(cross.schema(), cross); + + let fired = tokio::time::timeout(std::time::Duration::from_secs(5), done_rx).await; + assert!( + fired.is_ok(), + "a pre-cancelled token must still terminate the task" + ); + exec.join_blocking(); + } } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/custom_cache_manager.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/custom_cache_manager.rs deleted file mode 100644 index 097d3657b9e8e..0000000000000 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/custom_cache_manager.rs +++ /dev/null @@ -1,562 +0,0 @@ -/* - * 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. - */ - -use std::sync::Arc; -use datafusion::execution::cache::cache_manager::{FileMetadataCache, FileStatisticsCache, CacheManagerConfig}; -use datafusion::execution::cache::file_statistics_cache::DefaultFileStatisticsCache; -use datafusion::execution::cache::CacheAccessor; -use crate::statistics_cache::compute_parquet_statistics; -use crate::cache::MutexFileMetadataCache; -use crate::statistics_cache::CustomStatisticsCache; -use object_store::path::Path; -use object_store::ObjectMeta; -use datafusion::datasource::physical_plan::parquet::metadata::DFParquetMetadata; -use log::{debug, error}; - -/// Create ObjectMeta from a local file path. -fn create_object_meta_from_file(file_path: &str) -> Result, datafusion::common::DataFusionError> { - use chrono::{DateTime, Utc}; - use datafusion::common::DataFusionError; - - let metadata = std::fs::metadata(file_path) - .map_err(|e| DataFusionError::Execution(format!("Failed to get file metadata for {}: {}", file_path, e)))?; - - let file_size = metadata.len(); - - let modified = metadata.modified() - .map(|t| DateTime::::from(t)) - .unwrap_or_else(|_| Utc::now()); - - let object_meta = ObjectMeta { - location: Path::from(file_path), - last_modified: modified, - size: file_size, - e_tag: None, - version: None, - }; - - Ok(vec![object_meta]) -} - -/// Custom CacheManager that holds cache references directly -pub struct CustomCacheManager { - /// Direct reference to the file metadata cache - file_metadata_cache: Option>, - /// Direct reference to the statistics cache - statistics_cache: Option> -} - -impl CustomCacheManager { - /// Create a new CustomCacheManager - pub fn new() -> Self { - Self { - file_metadata_cache: None, - statistics_cache: None - } - } - - /// Set the file metadata cache - pub fn set_file_metadata_cache(&mut self, cache: Arc) { - self.file_metadata_cache = Some(cache); - debug!("[CACHE INFO] File metadata cache set in CustomCacheManager"); - } - - /// Set the statistics cache - pub fn set_statistics_cache(&mut self, cache: Arc) { - self.statistics_cache = Some(cache); - debug!("[CACHE INFO] Statistics cache set in CustomCacheManager"); - } - - /// Get the statistics cache - pub fn get_statistics_cache(&self) -> Option> { - self.statistics_cache.clone() - } - - /// Get the file metadata cache as Arc for DataFusion - pub fn get_file_metadata_cache_for_datafusion(&self) -> Option> { - self.file_metadata_cache.as_ref().map(|cache| cache.clone() as Arc) - } - - /// Build a CacheManagerConfig from the caches stored in this CustomCacheManager - pub fn build_cache_manager_config(&self) -> CacheManagerConfig { - let mut config = CacheManagerConfig::default(); - - // Add file metadata cache if available - if let Some(cache) = self.get_file_metadata_cache_for_datafusion() { - config = config.with_file_metadata_cache(Some(cache.clone())) - .with_metadata_cache_limit(cache.cache_limit()); - } - - // Add statistics cache if available - use CustomStatisticsCache directly - if let Some(stats_cache) = &self.statistics_cache { - config = config.with_file_statistics_cache(Some(stats_cache.clone() as Arc)); - } else { - // Default statistics cache if none set - let default_stats = Arc::new(DefaultFileStatisticsCache::default()); - config = config.with_file_statistics_cache(Some(default_stats)); - } - - config - } - - /// Add multiple files to all applicable caches - pub fn add_files(&self, file_paths: &[String], rt_handle: &tokio::runtime::Handle) -> Result, String> { - let mut results = Vec::new(); - - for file_path in file_paths { - let mut any_success = false; - let mut errors = Vec::new(); - - // Add to metadata cache - match self.metadata_cache_put(file_path, rt_handle) { - Ok(true) => { - any_success = true; - } - Ok(false) => { - debug!("[CACHE INFO] File not added for metadata cache: {}", file_path); - } - Err(e) => { - errors.push(format!("Metadata cache: {}", e)); - } - } - - // Add to statistics cache - if let Some(_) = &self.statistics_cache { - match self.statistics_cache_compute_and_put(file_path) { - Ok(true) => { - any_success = true; - } - Ok(false) => { - debug!("[CACHE INFO] File not added for statistics cache: {}", file_path); - } - Err(e) => { - errors.push(format!("Statistics cache: {}", e)); - } - } - } - - let success = if !errors.is_empty() && !any_success { - false - } else { - any_success - }; - - results.push((file_path.clone(), success)); - } - - Ok(results) - } - - /// Remove multiple files from all caches - pub fn remove_files(&self, file_paths: &[String]) -> Result, String> { - let mut results = Vec::new(); - - for file_path in file_paths { - let mut any_removed = false; - let mut errors = Vec::new(); - - // Remove from metadata cache - { - let path = Path::from(file_path.clone()); - if let Some(cache) = &self.file_metadata_cache { - match cache.inner.lock() { - Ok(cache_guard) => { - if cache_guard.remove(&path).is_some() { - any_removed = true; - } else { - debug!("[CACHE INFO] File not found in metadata cache: {}", file_path); - } - } - Err(e) => { - errors.push(format!("Metadata cache: Cache remove failed: {}", e)); - } - } - } else { - errors.push("No metadata cache configured".to_string()); - } - } - - // Remove from statistics cache - if let Some(cache) = &self.statistics_cache { - let path = Path::from(file_path.clone()); - if cache.remove(&path).is_some() { - any_removed = true; - } - } - - let removed = if !errors.is_empty() && !any_removed { - false - } else { - any_removed - }; - - results.push((file_path.clone(), removed)); - } - - Ok(results) - } - - /// Check if a file exists in any cache - pub fn contains_file(&self, file_path: &str) -> bool { - let mut found = false; - - // Check metadata cache - { - let path = Path::from(file_path); - if let Some(cache) = &self.file_metadata_cache { - if cache.get(&path).is_some() { - found = true; - } - } - } - - // Check statistics cache - if let Some(cache) = &self.statistics_cache { - let path = Path::from(file_path); - if cache.contains_key(&path) { - found = true; - } - } - - found - } - - /// Check if a file exists in a specific cache type - pub fn contains_file_by_type(&self, file_path: &str, cache_type: &str) -> bool { - match cache_type { - crate::cache::CACHE_TYPE_METADATA => { - let path = Path::from(file_path); - self.file_metadata_cache - .as_ref() - .and_then(|cache| cache.get(&path)) - .is_some() - } - crate::cache::CACHE_TYPE_STATS => { - self.statistics_cache - .as_ref() - .map_or(false, |cache| cache.contains_key(&Path::from(file_path))) - } - _ => false - } - } - - /// Update the file metadata cache size limit - pub fn update_metadata_cache_limit(&self, new_limit: usize) { - if let Some(cache) = &self.file_metadata_cache { - cache.update_cache_limit(new_limit); - } - } - - /// Update the statistics cache size limit - pub fn update_statistics_cache_limit(&self, new_limit: usize) -> Result<(), String> { - if let Some(cache) = &self.statistics_cache { - cache.update_size_limit(new_limit) - .map_err(|e| format!("Failed to update statistics cache limit: {:?}", e)) - } else { - Err("No statistics cache configured".to_string()) - } - } - - /// Get total memory consumed by all caches - pub fn get_total_memory_consumed(&self) -> usize { - let mut total = 0; - - // Add metadata cache memory - if let Some(cache) = &self.file_metadata_cache { - if let Ok(cache_guard) = cache.inner.lock() { - total += cache_guard.memory_used(); - } - } - - // Add statistics cache memory - if let Some(cache) = &self.statistics_cache { - total += cache.memory_consumed(); - } - - total - } - - /// Clear all caches - pub fn clear_all(&self) { - if let Some(cache) = &self.file_metadata_cache { - cache.clear(); - } - if let Some(cache) = &self.statistics_cache { - cache.clear(); - } - } - - /// Clear specific cache type - pub fn clear_cache_type(&self, cache_type: &str) -> Result<(), String> { - match cache_type { - crate::cache::CACHE_TYPE_METADATA => { - if let Some(cache) = &self.file_metadata_cache { - cache.clear(); - Ok(()) - } else { - Err("No metadata cache configured".to_string()) - } - } - crate::cache::CACHE_TYPE_STATS => { - if let Some(cache) = &self.statistics_cache { - cache.clear(); - Ok(()) - } else { - Err("No statistics cache configured".to_string()) - } - } - _ => Err(format!("Unknown cache type: {}", cache_type)) - } - } - - /// Get memory consumed by specific cache type - pub fn get_memory_consumed_by_type(&self, cache_type: &str) -> Result { - match cache_type { - crate::cache::CACHE_TYPE_METADATA => { - if let Some(cache) = &self.file_metadata_cache { - if let Ok(cache_guard) = cache.inner.lock() { - Ok(cache_guard.memory_used()) - } else { - Err("Failed to lock metadata cache".to_string()) - } - } else { - Err("No metadata cache configured".to_string()) - } - } - crate::cache::CACHE_TYPE_STATS => { - if let Some(cache) = &self.statistics_cache { - Ok(cache.memory_consumed()) - } else { - Err("No statistics cache configured".to_string()) - } - } - _ => Err(format!("Unknown cache type: {}", cache_type)) - } - } - - /// Internal method to put metadata into cache - fn metadata_cache_put(&self, file_path: &str, rt_handle: &tokio::runtime::Handle) -> Result { - if !file_path.to_lowercase().ends_with(".parquet") { - return Ok(false); // Skip unsupported formats - } - - let object_metas = create_object_meta_from_file(file_path) - .map_err(|e| format!("Failed to get object metadata: {}", e))?; - - let object_meta = object_metas.first() - .ok_or_else(|| "No object metadata returned".to_string())?; - - let store = Arc::new(object_store::local::LocalFileSystem::new()); - - // Get cache reference for DataFusion metadata loading - let cache_ref = self.file_metadata_cache.as_ref() - .ok_or_else(|| "No file metadata cache configured".to_string())?; - - let metadata_cache = cache_ref.clone() as Arc; - - // Use DataFusion's metadata loading by passing reference to file_metadata_cache to get complete metadata - // IMPORTANT: When a cache is provided to DFParquetMetadata, fetch_metadata() will: - // 1. Enable page index loading (with_page_indexes(true)) - // 2. Load the complete metadata including column and offset indexes - // 3. Automatically put the metadata into the cache (lines 155-160 in datafusion's metadata.rs) - // This ensures we cache exactly what DataFusion would cache during query execution - let _parquet_metadata = rt_handle.block_on(async { - let df_metadata = DFParquetMetadata::new(store.as_ref(), object_meta) - .with_file_metadata_cache(Some(metadata_cache)); - - // fetch_metadata() performs the cache put operation internally - df_metadata.fetch_metadata().await - .map_err(|e| format!("Failed to fetch metadata: {}", e)) - })?; - - // Verify the metadata was cached properly - match cache_ref.inner.lock() { - Ok(cache_guard) => { - let path = Path::from(file_path.to_string()); - if cache_guard.contains_key(&path) { - Ok(true) - } else { - debug!("[CACHE ERROR] Failed to cache metadata for: {}", file_path); - Ok(false) - } - } - Err(e) => Err(format!("Failed to verify cache: {}", e)) - } - } - - /// Compute and put statistics into cache - pub fn statistics_cache_compute_and_put(&self, file_path: &str) -> Result { - let cache = self.statistics_cache.as_ref() - .ok_or_else(|| "No statistics cache configured".to_string())?; - - let path = Path::from(file_path.to_string()); - - // Check if already cached - if cache.contains_key(&path) { - return Ok(true); - } - - // Compute statistics - match compute_parquet_statistics(file_path) { - Ok(stats) => { - let meta = ObjectMeta { - location: path.clone(), - last_modified: chrono::Utc::now(), - size: std::fs::metadata(file_path) - .map(|m| m.len()) - .unwrap_or(0), - e_tag: None, - version: None, - }; - - cache.put_statistics(&path, Arc::new(stats), &meta); - Ok(true) - } - Err(e) => { - Err(format!("Failed to compute statistics for {}: {}", file_path, e)) - } - } - } - - /// Batch compute and cache statistics for multiple files - pub fn statistics_cache_batch_compute_and_put(&self, file_paths: &[String]) -> Result { - let cache = self.statistics_cache.as_ref() - .ok_or_else(|| "No statistics cache configured".to_string())?; - - let mut success_count = 0; - let mut failed_files = Vec::new(); - - for file_path in file_paths { - let path = Path::from(file_path.clone()); - - if cache.contains_key(&path) { - success_count += 1; - continue; - } - - match compute_parquet_statistics(file_path) { - Ok(stats) => { - let meta = ObjectMeta { - location: path.clone(), - last_modified: chrono::Utc::now(), - size: std::fs::metadata(file_path) - .map(|m| m.len()) - .unwrap_or(0), - e_tag: None, - version: None, - }; - - cache.put_statistics(&path, Arc::new(stats), &meta); - success_count += 1; - } - Err(e) => { - debug!("[STATS CACHE ERROR] Failed to compute statistics for {}: {}", file_path, e); - failed_files.push(file_path.clone()); - } - } - } - - if !failed_files.is_empty() { - debug!("[STATS CACHE WARNING] Failed to compute statistics for {} files: {:?}", - failed_files.len(), failed_files); - } - - Ok(success_count) - } - - /// Get or compute statistics - pub fn statistics_cache_get_or_compute(&self, file_path: &str) -> Result { - let cache = self.statistics_cache.as_ref() - .ok_or_else(|| "No statistics cache configured".to_string())?; - - let path = Path::from(file_path.to_string()); - - if cache.get(&path).is_some() { - return Ok(true); - } - - self.statistics_cache_compute_and_put(file_path) - } - - /// Get statistics cache hit count - pub fn statistics_cache_hit_count(&self) -> usize { - self.statistics_cache.as_ref() - .map(|cache| cache.hit_count()) - .unwrap_or(0) - } - - /// Get statistics cache miss count - pub fn statistics_cache_miss_count(&self) -> usize { - self.statistics_cache.as_ref() - .map(|cache| cache.miss_count()) - .unwrap_or(0) - } - - /// Get statistics cache hit rate - pub fn statistics_cache_hit_rate(&self) -> f64 { - self.statistics_cache.as_ref() - .map(|cache| cache.hit_rate()) - .unwrap_or(0.0) - } - - /// Get statistics cache entry count - pub fn statistics_cache_entry_count(&self) -> usize { - self.statistics_cache.as_ref() - .map(|cache| >::len(cache)) - .unwrap_or(0) - } - - /// Get statistics cache size limit in bytes - pub fn statistics_cache_size_limit(&self) -> usize { - self.statistics_cache.as_ref() - .map(|cache| cache.current_size_limit()) - .unwrap_or(0) - } - - /// Reset statistics cache stats - pub fn statistics_cache_reset_stats(&self) { - if let Some(cache) = &self.statistics_cache { - cache.reset_stats(); - } - } - - /// Get metadata cache hit count - pub fn metadata_cache_hit_count(&self) -> usize { - self.file_metadata_cache.as_ref() - .map(|cache| cache.hit_count()) - .unwrap_or(0) - } - - /// Get metadata cache miss count - pub fn metadata_cache_miss_count(&self) -> usize { - self.file_metadata_cache.as_ref() - .map(|cache| cache.miss_count()) - .unwrap_or(0) - } - - /// Get metadata cache entry count - pub fn metadata_cache_entry_count(&self) -> usize { - self.file_metadata_cache.as_ref() - .map(|cache| >::len(cache)) - .unwrap_or(0) - } - - /// Get metadata cache size limit in bytes - pub fn metadata_cache_size_limit(&self) -> usize { - self.file_metadata_cache.as_ref() - .map(|cache| cache.get_cache_limit()) - .unwrap_or(0) - } - - /// Reset metadata cache stats - pub fn metadata_cache_reset_stats(&self) { - if let Some(cache) = &self.file_metadata_cache { - cache.reset_stats(); - } - } -} diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/datafusion_query_config.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/datafusion_query_config.rs index 5d8cdb1df44dd..c68c8ab8a1d65 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/datafusion_query_config.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/datafusion_query_config.rs @@ -5,27 +5,41 @@ //! setup time and copied into hot-path fields — never dereferenced on a //! per-batch or per-row hot path. -use crate::indexed_table::eval::single_collector::CollectorCallStrategy; use crate::indexed_table::stream::FilterStrategy; -/// Selects which execution path computes shard-global row IDs. -/// -/// Selects which execution path computes shard-global row IDs. -/// `None` = no row ID computation (baseline — reads ___row_id as a regular column). +/// Engine-internal point lookup driven through the normal `df_execute_query` +/// entry point. When active, the Substrait `plan_ptr` is ignored and the plan +/// is built natively via the DataFrame API with a single pushed-down filter on +/// a stored reserved column — no Substrait, no planner round-trip. Used by the +/// pluggable-dataformat get-by-id path (`GetService`), not by user search. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum QueryStrategy { - /// No row ID optimizer applied. ___row_id is read as a regular column - /// without any row_base addition. Returns local (per-file) row IDs only. - None, - /// ShardTableProvider + ProjectRowIdOptimizer. - /// Reads ___row_id from parquet, adds row_base via physical optimizer rewrite. - /// Produces shard-global absolute row IDs. - ListingTable, - /// Predicate-only mode in the indexed executor. - /// Uses indexed pipeline (segment partitioning, prefetch, PositionMap). - /// Does NOT read ___row_id from disk — computes from position: - /// global_base + rg.first_row + position_in_rg. Zero column I/O for row ID. - IndexedPredicateOnly, +pub enum InternalSearch { + /// Not an internal lookup — decode `plan_ptr` as Substrait as usual. + Off, + /// Get-by-row-id: `__row_id__ = bound`, single row. `bound` is the physical + /// row position resolved from the secondary (Lucene) index. + ByRowId(i64), + /// Seq-no scan: `_seq_no > bound`, projecting only id/seq/term/version. + /// Used by version-map restore on crash recovery. + SeqNoAbove(i64), +} + +impl InternalSearch { + /// Decodes the FFM wire pair `(mode, bound)`. `mode`: 0 = Off, 1 = ByRowId, + /// 2 = SeqNoAbove. Any other value is treated as Off (forward-compatible). + pub fn from_wire(mode: i64, bound: i64) -> Self { + match mode { + 1 => InternalSearch::ByRowId(bound), + 2 => InternalSearch::SeqNoAbove(bound), + _ => InternalSearch::Off, + } + } + + /// Whether this is an engine-internal point lookup (i.e. not [`InternalSearch::Off`], + /// the normal user-search path). + pub fn is_internal_search(self) -> bool { + !matches!(self, InternalSearch::Off) + } } /// Query-scoped configuration. Owned by value after FFM decode. @@ -35,8 +49,8 @@ pub struct DatafusionQueryConfig { pub batch_size: usize, // Single query concurrency pub target_partitions: usize, - /// DataFusion's own decode-time predicate pushdown on the vanilla path. - pub parquet_pushdown_filters: bool, + /// DataFusion's own decode-time predicate pushdown on the ListingTable path. + pub listing_table_pushdown_filters: bool, // Indexed-only pub min_skip_run_default: usize, @@ -45,40 +59,14 @@ pub struct DatafusionQueryConfig { /// during decode (via `RowFilter` pushdown). Narrow row-granular /// selections benefit; block-granular ones don't. pub indexed_pushdown_filters: bool, + /// Optional override that pins the per-RG `min_skip_run` choice instead of + /// letting selectivity decide. Backed by the `datafusion.indexed.force_strategy` + /// cluster setting: `None` (wire `-1`) lets the selectivity heuristic run, + /// `RowSelection`/`BooleanMask` pin the choice node-wide. See + /// `IndexedStream::pick_min_skip_run`. pub force_strategy: Option, - pub force_pushdown: Option, pub cost_predicate: u32, pub cost_collector: u32, - /// Maximum number of Collector-leaf FFM calls issued in parallel per - /// RG prefetch. 1 = today's fully-sequential behaviour (lowest CPU, - /// fastest short-circuit). `target_partitions × max_collector_parallelism` - /// bounds total concurrent Lucene threads; default is 1 - /// - /// At higher values, short-circuit savings in AND/OR groups are - /// sacrificed (see `BitmapTreeEvaluator::prefetch`): collectors - /// beyond the first may run even if their result is not needed. - pub max_collector_parallelism: usize, - /// How the SingleCollectorEvaluator narrows collector doc ranges - /// relative to page-pruning results. `PageRangeSplit` is the default - /// — only one collector, so multiple FFM calls per RG is acceptable. - pub single_collector_strategy: CollectorCallStrategy, - /// How the bitmap tree evaluator narrows collector doc ranges. - /// `TightenOuterBounds` is the default — multiple collectors in the - /// tree means `PageRangeSplit` would multiply FFM calls. - pub tree_collector_strategy: CollectorCallStrategy, - /// Strategy for row ID emission on the vanilla path. - /// Only consulted when the plan requests row IDs (contains _global_row_id() UDF - /// or projects ___row_id). - pub query_strategy: QueryStrategy, - /// Whether to use bloom filters for row group pruning on the indexed read path. - pub bloom_filter_on_read: bool, - /// Whether to accept and apply runtime dynamic filters (TopK / join) - /// pushed into the indexed scan via physical filter pushdown, pruning row - /// groups whose parquet statistics cannot satisfy the tightening predicate. - /// Off → `QueryShardExec` declines the pushdown (parent keeps its - /// `FilterExec`), so behaviour is identical to before this feature. Exposed - /// as a toggle to A/B the performance impact. - pub indexed_dynamic_filter_pushdown: bool, } /// FFM wire format. Must stay in lockstep with the Java `MemoryLayout`. @@ -94,26 +82,14 @@ pub struct WireDatafusionQueryConfig { pub min_skip_run_default: i64, pub min_skip_run_selectivity_threshold: f64, /// 0 = false, 1 = true - pub parquet_pushdown_filters: i32, + pub listing_table_pushdown_filters: i32, /// 0 = false, 1 = true pub indexed_pushdown_filters: i32, - /// -1 = None, 0 = RowSelection, 1 = BooleanMask + /// -1 = None, 0 = RowSelection, 1 = BooleanMask. + /// Backed by the `datafusion.indexed.force_strategy` cluster setting. pub force_strategy: i32, - /// -1 = None, 0 = false, 1 = true - pub force_pushdown: i32, pub cost_predicate: i32, pub cost_collector: i32, - pub max_collector_parallelism: i32, - /// 0 = FullRange, 1 = TightenOuterBounds, 2 = PageRangeSplit - pub single_collector_strategy: i32, - /// 0 = FullRange, 1 = TightenOuterBounds, 2 = PageRangeSplit - pub tree_collector_strategy: i32, - /// 0 = None (baseline), 1 = ListingTable, 2 = IndexedPredicateOnly - pub query_strategy: i32, - /// 0 = false, 1 = true - pub bloom_filter_on_read: i32, - /// 0 = false, 1 = true - pub indexed_dynamic_filter_pushdown: i32, } impl DatafusionQueryConfig { @@ -125,22 +101,13 @@ impl DatafusionQueryConfig { Self { batch_size: 8192, target_partitions: 4, - parquet_pushdown_filters: false, + listing_table_pushdown_filters: false, min_skip_run_default: 1024, min_skip_run_selectivity_threshold: 0.03, indexed_pushdown_filters: true, force_strategy: None, - force_pushdown: None, cost_predicate: 1, cost_collector: 10, - max_collector_parallelism: 1, - single_collector_strategy: CollectorCallStrategy::PageRangeSplit, - tree_collector_strategy: CollectorCallStrategy::TightenOuterBounds, - query_strategy: QueryStrategy::None, - bloom_filter_on_read: true, - // On by default — matches the Java cluster-setting default - // (`datafusion.indexed.dynamic_filter_pushdown`). Toggle to A/B perf. - indexed_dynamic_filter_pushdown: true, } } @@ -174,45 +141,22 @@ impl DatafusionQueryConfig { } fn from_wire(w: &WireDatafusionQueryConfig) -> Self { - let force_strategy = match w.force_strategy { - 0 => Some(FilterStrategy::RowSelection), - 1 => Some(FilterStrategy::BooleanMask), - _ => None, - }; - let force_pushdown = match w.force_pushdown { - 0 => Some(false), - 1 => Some(true), - _ => None, - }; Self { batch_size: w.batch_size as usize, target_partitions: w.target_partitions as usize, - parquet_pushdown_filters: w.parquet_pushdown_filters != 0, + listing_table_pushdown_filters: w.listing_table_pushdown_filters != 0, min_skip_run_default: w.min_skip_run_default as usize, min_skip_run_selectivity_threshold: w.min_skip_run_selectivity_threshold, indexed_pushdown_filters: w.indexed_pushdown_filters != 0, - force_strategy, - force_pushdown, + // `force_strategy` is backed by a cluster setting; `-1` means None + // (selectivity heuristic decides). + force_strategy: match w.force_strategy { + 0 => Some(FilterStrategy::RowSelection), + 1 => Some(FilterStrategy::BooleanMask), + _ => None, + }, cost_predicate: w.cost_predicate as u32, cost_collector: w.cost_collector as u32, - max_collector_parallelism: (w.max_collector_parallelism as usize).max(1), - single_collector_strategy: match w.single_collector_strategy { - 0 => CollectorCallStrategy::FullRange, - 1 => CollectorCallStrategy::TightenOuterBounds, - _ => CollectorCallStrategy::PageRangeSplit, - }, - tree_collector_strategy: match w.tree_collector_strategy { - 0 => CollectorCallStrategy::FullRange, - 2 => CollectorCallStrategy::PageRangeSplit, - _ => CollectorCallStrategy::TightenOuterBounds, - }, - query_strategy: match w.query_strategy { - 1 => QueryStrategy::ListingTable, - 2 => QueryStrategy::IndexedPredicateOnly, - _ => QueryStrategy::None, - }, - bloom_filter_on_read: w.bloom_filter_on_read != 0, - indexed_dynamic_filter_pushdown: w.indexed_dynamic_filter_pushdown != 0, } } } @@ -233,8 +177,8 @@ impl DatafusionQueryConfigBuilder { self.0.target_partitions = v; self } - pub fn parquet_pushdown_filters(mut self, v: bool) -> Self { - self.0.parquet_pushdown_filters = v; + pub fn listing_table_pushdown_filters(mut self, v: bool) -> Self { + self.0.listing_table_pushdown_filters = v; self } pub fn min_skip_run_default(mut self, v: usize) -> Self { @@ -253,10 +197,6 @@ impl DatafusionQueryConfigBuilder { self.0.force_strategy = v; self } - pub fn force_pushdown(mut self, v: Option) -> Self { - self.0.force_pushdown = v; - self - } pub fn cost_predicate(mut self, v: u32) -> Self { self.0.cost_predicate = v; self @@ -265,26 +205,6 @@ impl DatafusionQueryConfigBuilder { self.0.cost_collector = v; self } - pub fn max_collector_parallelism(mut self, v: usize) -> Self { - self.0.max_collector_parallelism = v; - self - } - pub fn single_collector_strategy(mut self, v: CollectorCallStrategy) -> Self { - self.0.single_collector_strategy = v; - self - } - pub fn tree_collector_strategy(mut self, v: CollectorCallStrategy) -> Self { - self.0.tree_collector_strategy = v; - self - } - pub fn bloom_filter_on_read(mut self, v: bool) -> Self { - self.0.bloom_filter_on_read = v; - self - } - pub fn indexed_dynamic_filter_pushdown(mut self, v: bool) -> Self { - self.0.indexed_dynamic_filter_pushdown = v; - self - } pub fn build(self) -> DatafusionQueryConfig { self.0 } @@ -299,15 +219,13 @@ mod tests { let c = DatafusionQueryConfig::test_default(); assert_eq!(c.batch_size, 8192); assert_eq!(c.target_partitions, 4); - assert!(!c.parquet_pushdown_filters); + assert!(!c.listing_table_pushdown_filters); assert_eq!(c.min_skip_run_default, 1024); assert!((c.min_skip_run_selectivity_threshold - 0.03).abs() < 1e-9); assert!(c.indexed_pushdown_filters); assert_eq!(c.force_strategy, None); - assert_eq!(c.force_pushdown, None); assert_eq!(c.cost_predicate, 1); assert_eq!(c.cost_collector, 10); - assert!(c.indexed_dynamic_filter_pushdown); } #[test] @@ -316,6 +234,24 @@ mod tests { unsafe { DatafusionQueryConfig::from_ffm_ptr(0) }; } + #[test] + fn internal_search_from_wire_decodes_modes() { + assert_eq!(InternalSearch::from_wire(0, 99), InternalSearch::Off); + assert_eq!( + InternalSearch::from_wire(1, 42), + InternalSearch::ByRowId(42) + ); + assert_eq!( + InternalSearch::from_wire(2, 7), + InternalSearch::SeqNoAbove(7) + ); + // Unknown modes are forward-compatible: treated as Off, bound ignored. + assert_eq!(InternalSearch::from_wire(3, 5), InternalSearch::Off); + assert!(!InternalSearch::Off.is_internal_search()); + assert!(InternalSearch::ByRowId(0).is_internal_search()); + assert!(InternalSearch::SeqNoAbove(0).is_internal_search()); + } + #[test] fn wire_decode_round_trips_all_fields() { let wire = WireDatafusionQueryConfig { @@ -323,33 +259,23 @@ mod tests { target_partitions: 8, min_skip_run_default: 512, min_skip_run_selectivity_threshold: 0.07, - parquet_pushdown_filters: 1, + listing_table_pushdown_filters: 1, indexed_pushdown_filters: 0, force_strategy: 1, - force_pushdown: 0, cost_predicate: 3, cost_collector: 17, - max_collector_parallelism: 4, - single_collector_strategy: 2, - tree_collector_strategy: 1, - query_strategy: 1, - bloom_filter_on_read: 1, - indexed_dynamic_filter_pushdown: 1, }; let ptr = &wire as *const _ as i64; let c = unsafe { DatafusionQueryConfig::from_ffm_ptr(ptr) }; assert_eq!(c.batch_size, 16384); - assert!(c.indexed_dynamic_filter_pushdown); assert_eq!(c.target_partitions, 8); assert_eq!(c.min_skip_run_default, 512); assert!((c.min_skip_run_selectivity_threshold - 0.07).abs() < 1e-9); - assert!(c.parquet_pushdown_filters); + assert!(c.listing_table_pushdown_filters); assert!(!c.indexed_pushdown_filters); assert_eq!(c.force_strategy, Some(FilterStrategy::BooleanMask)); - assert_eq!(c.force_pushdown, Some(false)); assert_eq!(c.cost_predicate, 3); assert_eq!(c.cost_collector, 17); - assert_eq!(c.query_strategy, QueryStrategy::ListingTable); } #[test] @@ -359,24 +285,14 @@ mod tests { target_partitions: 4, min_skip_run_default: 1024, min_skip_run_selectivity_threshold: 0.03, - parquet_pushdown_filters: 0, + listing_table_pushdown_filters: 0, indexed_pushdown_filters: 1, force_strategy: -1, - force_pushdown: -1, cost_predicate: 1, cost_collector: 10, - max_collector_parallelism: 2, - single_collector_strategy: 2, - tree_collector_strategy: 1, - query_strategy: 0, - bloom_filter_on_read: 0, - indexed_dynamic_filter_pushdown: 0, }; let ptr = &wire as *const _ as i64; let c = unsafe { DatafusionQueryConfig::from_ffm_ptr(ptr) }; assert_eq!(c.force_strategy, None); - assert!(!c.indexed_dynamic_filter_pushdown); - assert_eq!(c.force_pushdown, None); - assert_eq!(c.query_strategy, QueryStrategy::None); } } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/executor.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/executor.rs index cbc4a9e0ae9a1..d6c8e6f19f6ef 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/executor.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/executor.rs @@ -9,16 +9,17 @@ use futures::{future::BoxFuture, Future, FutureExt, TryFutureExt}; use log::{info, warn}; use parking_lot::RwLock; -use std::sync::Arc; use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::{ runtime::Handle, - sync::{oneshot::error::RecvError, Mutex as AsyncMutex, Notify, Semaphore, OwnedSemaphorePermit}, + sync::{ + oneshot::error::RecvError, Mutex as AsyncMutex, Notify, OwnedSemaphorePermit, Semaphore, + }, task::{AbortHandle, JoinSet}, }; -use crate::io::register_io_runtime; // DedicatedExecutor — runs CPU-bound DataFusion work on its own tokio runtime. // Based on InfluxDB's executor pattern. // https://github.com/apache/datafusion/blob/main/datafusion-examples/examples/query_planning/thread_pools.rs @@ -84,11 +85,13 @@ impl ConcurrencyGate { /// Acquire N permits (partition-weighted). Held for the entire query stream lifetime. pub async fn acquire_many(&self, n: u32) -> OwnedSemaphorePermit { - self.pending_acquire_permits.fetch_add(n as u64, Ordering::Relaxed); + self.pending_acquire_permits + .fetch_add(n as u64, Ordering::Relaxed); self.pending_acquire_batches.fetch_add(1, Ordering::Relaxed); let start = Instant::now(); let result = self.semaphore.clone().acquire_many_owned(n).await; - self.pending_acquire_permits.fetch_sub(n as u64, Ordering::Relaxed); + self.pending_acquire_permits + .fetch_sub(n as u64, Ordering::Relaxed); self.pending_acquire_batches.fetch_sub(1, Ordering::Relaxed); let permit = result.expect("concurrency gate semaphore closed"); let elapsed_ms = start.elapsed().as_millis() as u64; @@ -112,7 +115,9 @@ impl ConcurrencyGate { } pub fn active_permits(&self) -> u32 { - self.max_permits.load(Ordering::Acquire).saturating_sub(self.semaphore.available_permits() as u32) + self.max_permits + .load(Ordering::Acquire) + .saturating_sub(self.semaphore.available_permits() as u32) } pub fn total_wait_ms(&self) -> u64 { @@ -151,7 +156,9 @@ impl ConcurrencyGate { if new_max < 1 || new_max > Semaphore::MAX_PERMITS as u32 { warn!( "[{}] resize rejected: new_max {} out of bounds [1, {}]", - gate_name, new_max, Semaphore::MAX_PERMITS + gate_name, + new_max, + Semaphore::MAX_PERMITS ); return; } @@ -204,7 +211,9 @@ impl ConcurrencyGate { // Need to acquire additional poison permits (one at a time for precise release) let additional_needed = total_poison_needed - existing_poison; for _ in 0..additional_needed { - let permit = self.semaphore.clone() + let permit = self + .semaphore + .clone() .acquire_owned() .await .expect("semaphore closed during resize"); @@ -258,23 +267,22 @@ impl std::fmt::Debug for DedicatedExecutor { } impl DedicatedExecutor { - pub fn new(name: &str, runtime_builder: tokio::runtime::Builder, max_concurrent_queries: usize) -> Self { + pub fn new( + name: &str, + runtime_builder: tokio::runtime::Builder, + max_concurrent_queries: usize, + ) -> Self { let name = name.to_owned(); let notify_shutdown = Arc::new(Notify::new()); let notify_shutdown_captured = Arc::clone(¬ify_shutdown); let (tx_shutdown, rx_shutdown) = tokio::sync::oneshot::channel(); let (tx_handle, rx_handle) = std::sync::mpsc::channel(); - let io_handle = tokio::runtime::Handle::try_current().ok(); let thread = std::thread::Builder::new() .name(format!("{name} driver")) .spawn(move || { - register_io_runtime(io_handle.clone()); let mut runtime_builder = runtime_builder; - let runtime = runtime_builder - .on_thread_start(move || register_io_runtime(io_handle.clone())) - .build() - .expect("Creating tokio runtime"); + let runtime = runtime_builder.build().expect("Creating tokio runtime"); runtime.block_on(async move { let shutdown = notify_shutdown_captured.notified(); @@ -312,13 +320,15 @@ impl DedicatedExecutor { fut } - /// Like [`spawn`](Self::spawn), but also returns an [`AbortHandle`] that /// can be used to cancel the CPU task from outside (e.g. from `cancel_query`). pub fn spawn_with_abort_handle( &self, task: T, - ) -> (Option, impl Future>) + ) -> ( + Option, + impl Future>, + ) where T: Future + Send + 'static, T::Output: Send + 'static, @@ -521,7 +531,7 @@ mod tests { assert_eq!(gate.max_permits(), 4); assert_eq!(poison_permits_held(&gate).await, 4); // 4 individual permits - // Available permits should be 4: original 8 minus 4 acquired as poison + // Available permits should be 4: original 8 minus 4 acquired as poison assert_eq!(gate.semaphore.available_permits(), 4); } @@ -789,9 +799,7 @@ mod tests { // Spawn a task that will block on acquire let gate_clone = Arc::clone(&gate); - let handle = tokio::spawn(async move { - gate_clone.acquire().await - }); + let handle = tokio::spawn(async move { gate_clone.acquire().await }); // Yield to let the spawned task start waiting tokio::task::yield_now().await; diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/ffm.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/ffm.rs index 55e8719813e64..270f094a22010 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/ffm.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/ffm.rs @@ -47,10 +47,12 @@ use crate::api; use crate::api::DataFusionRuntime; use crate::cache; use crate::custom_cache_manager::CustomCacheManager; -use crate::eviction_policy::PolicyType; +use crate::datafusion_query_config::InternalSearch; +use crate::eviction_policy::CacheEvictionPolicy; use crate::runtime_manager::RuntimeManager; use crate::statistics_cache::CustomStatisticsCache; +use crate::cache::page_index; use datafusion::execution::cache::DefaultFilesMetadataCache; static TOKIO_RUNTIME_MANAGER: RwLock>> = RwLock::new(None); @@ -78,11 +80,18 @@ pub(crate) fn try_get_rt_manager() -> Option> { TOKIO_RUNTIME_MANAGER.read().clone() } - #[no_mangle] -pub extern "C" fn df_init_runtime_manager(cpu_threads: i32, datanode_multiplier: f64, coordinator_multiplier: f64) { +pub extern "C" fn df_init_runtime_manager( + cpu_threads: i32, + datanode_multiplier: f64, + coordinator_multiplier: f64, +) { let mut guard = TOKIO_RUNTIME_MANAGER.write(); - *guard = Some(Arc::new(RuntimeManager::new(cpu_threads as usize, datanode_multiplier, coordinator_multiplier))); + *guard = Some(Arc::new(RuntimeManager::new( + cpu_threads as usize, + datanode_multiplier, + coordinator_multiplier, + ))); } #[no_mangle] @@ -147,12 +156,34 @@ pub unsafe extern "C" fn df_create_global_runtime( spill_dir_ptr: *const u8, spill_dir_len: i64, spill_limit: i64, + liquid_cache_enabled: i64, + liquid_cache_size: i64, + liquid_cache_eviction_policy_ptr: *const u8, + liquid_cache_eviction_policy_len: i64, ) -> i64 { crate::memory_guard::set_pool_limit_for_guard(memory_pool_limit); let spill_dir = str_from_raw(spill_dir_ptr, spill_dir_len) .map_err(|e| format!("df_create_global_runtime: {}", e))?; - api::create_global_runtime(memory_pool_limit, cache_manager_ptr, spill_dir, spill_limit) - .map_err(|e| e.to_string()) + let liquid_cache_eviction_policy = str_from_raw( + liquid_cache_eviction_policy_ptr, + liquid_cache_eviction_policy_len, + ) + .map_err(|e| { + format!( + "df_create_global_runtime: liquid_cache_eviction_policy: {}", + e + ) + })?; + api::create_global_runtime( + memory_pool_limit, + cache_manager_ptr, + spill_dir, + spill_limit, + liquid_cache_enabled != 0, + liquid_cache_size, + liquid_cache_eviction_policy, + ) + .map_err(|e| e.to_string()) } #[no_mangle] @@ -160,6 +191,39 @@ pub unsafe extern "C" fn df_close_global_runtime(ptr: i64) { api::close_global_runtime(ptr); } +// ---- Liquid Cache FFM entry points ---- + +#[no_mangle] +pub unsafe extern "C" fn df_clear_liquid_cache(runtime_ptr: i64) { + api::clear_liquid_cache(runtime_ptr); +} + +#[no_mangle] +pub extern "C" fn df_set_liquid_cache_enabled(enabled: i64) { + crate::liquid_cache::LiquidOnlyRuntime::set_enabled_globally(enabled != 0); +} + +#[no_mangle] +pub extern "C" fn df_set_liquid_cache_memory_limit(bytes: i64) { + if bytes >= 0 { + crate::liquid_cache::LiquidOnlyRuntime::set_max_memory_bytes_globally(bytes as usize); + } +} + +#[no_mangle] +pub extern "C" fn df_set_liquid_cache_selectivity_threshold(permille: i64) { + if (0..=1000).contains(&permille) { + crate::liquid_cache::set_lc_selectivity_threshold(permille as f64 / 1000.0); + } +} + +#[no_mangle] +pub extern "C" fn df_set_liquid_cache_max_columns(count: i64) { + if count > 0 { + crate::liquid_cache::set_lc_max_columns(count as usize); + } +} + // ---- Memory pool observability and dynamic limit ---- /// Returns current memory pool usage in bytes. @@ -218,10 +282,23 @@ pub extern "C" fn df_set_reduce_target_partitions(value: i64) { api::set_reduce_target_partitions(value); } +/// Sets the spill-exemption cap in bytes (the total in-flight allocation allowed +/// through the 85% spill gate by spillable consumers so they can finish spilling). +/// Live-tunable; takes effect on the next try_grow. Java: NativeBridge.setSpillExemptCapBytes(long). +#[no_mangle] +pub extern "C" fn df_set_spill_exempt_cap_bytes(value: i64) { + crate::memory_guard::set_spill_exempt_cap_bytes(value.max(0) as u64); +} + /// Sets memory guard thresholds. Values are thresholds multiplied by 1000 /// (e.g., 700 = 0.70, 850 = 0.85, 950 = 0.95). #[no_mangle] -pub extern "C" fn df_set_memory_guard_thresholds(admission_throttle_x1000: i64, admission_reject_x1000: i64, execution_spill_x1000: i64, execution_critical_x1000: i64) { +pub extern "C" fn df_set_memory_guard_thresholds( + admission_throttle_x1000: i64, + admission_reject_x1000: i64, + execution_spill_x1000: i64, + execution_critical_x1000: i64, +) { crate::memory_guard::set_thresholds(crate::memory_guard::MemoryThresholds { admission_throttle: admission_throttle_x1000 as f64 / 1000.0, admission_reject: admission_reject_x1000 as f64 / 1000.0, @@ -312,6 +389,12 @@ pub unsafe extern "C" fn df_execute_query( context_id: i64, // Pointer to a `WireDatafusionQueryConfig` query_config_ptr: i64, + // Engine-internal point lookup. 0 = normal query (decode `plan_ptr` as Substrait); + // 1 = get-by-row-id (`__row_id__ = internal_search_bound`); 2 = seq-no scan + // (`_seq_no > internal_search_bound`). When non-zero, `plan_ptr`/`plan_len` are + // ignored and the plan is built natively via the DataFrame API — no Substrait. + internal_search_mode: i64, + internal_search_bound: i64, ) -> i64 { let mgr = get_rt_manager()?; let table_name = str_from_raw(table_name_ptr, table_name_len) @@ -319,6 +402,7 @@ pub unsafe extern "C" fn df_execute_query( let plan_bytes = slice::from_raw_parts(plan_ptr, plan_len as usize); let query_config = crate::datafusion_query_config::DatafusionQueryConfig::from_ffm_ptr(query_config_ptr); + let internal_search = InternalSearch::from_wire(internal_search_mode, internal_search_bound); // Copy the plan bytes so the spawned future can own them (`cpu_executor.spawn` // requires `'static`). The `shard_view_ptr`, `runtime_ptr` are raw pointers // held live by the caller for the duration of the FFM downcall — safe to @@ -346,6 +430,7 @@ pub unsafe extern "C" fn df_execute_query( &mgr_for_inner, context_id, query_config, + internal_search, ) .await }); @@ -356,7 +441,7 @@ pub unsafe extern "C" fn df_execute_query( ))), } }) - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string()) } /// Fetch specific rows by global row ID — QTF fetch phase. @@ -378,16 +463,39 @@ pub unsafe extern "C" fn df_fetch_by_row_ids( ) -> i64 { // Hard FFM-boundary checks (UB risk if violated): pointers must be non-zero before any deref. // Always-on `assert!` (not debug_assert!) — these protect against use-after-close from Java. - assert!(shard_view_ptr != 0, "df_fetch_by_row_ids: shard_view_ptr is null"); + assert!( + shard_view_ptr != 0, + "df_fetch_by_row_ids: shard_view_ptr is null" + ); assert!(runtime_ptr != 0, "df_fetch_by_row_ids: runtime_ptr is null"); - assert!(row_ids_count >= 0, "df_fetch_by_row_ids: negative row_ids_count {}", row_ids_count); - assert!(col_names_count >= 0, "df_fetch_by_row_ids: negative col_names_count {}", col_names_count); + assert!( + row_ids_count >= 0, + "df_fetch_by_row_ids: negative row_ids_count {}", + row_ids_count + ); + assert!( + col_names_count >= 0, + "df_fetch_by_row_ids: negative col_names_count {}", + col_names_count + ); if row_ids_count > 0 { - assert!(row_ids_ptr != 0, "df_fetch_by_row_ids: row_ids_ptr is null but count={}", row_ids_count); + assert!( + row_ids_ptr != 0, + "df_fetch_by_row_ids: row_ids_ptr is null but count={}", + row_ids_count + ); } if col_names_count > 0 { - assert!(!col_names_ptr.is_null(), "df_fetch_by_row_ids: col_names_ptr is null but count={}", col_names_count); - assert!(!col_names_len_ptr.is_null(), "df_fetch_by_row_ids: col_names_len_ptr is null but count={}", col_names_count); + assert!( + !col_names_ptr.is_null(), + "df_fetch_by_row_ids: col_names_ptr is null but count={}", + col_names_count + ); + assert!( + !col_names_len_ptr.is_null(), + "df_fetch_by_row_ids: col_names_len_ptr is null but count={}", + col_names_count + ); } let mgr = get_rt_manager()?; @@ -395,10 +503,8 @@ pub unsafe extern "C" fn df_fetch_by_row_ids( let runtime = &*(runtime_ptr as *const crate::api::DataFusionRuntime); // Zero-copy read from BigIntVector's direct buffer - let row_ids: Vec = slice::from_raw_parts( - row_ids_ptr as *const i64, - row_ids_count as usize, - ).to_vec(); + let row_ids: Vec = + slice::from_raw_parts(row_ids_ptr as *const i64, row_ids_count as usize).to_vec(); // Parse column names let mut columns: Vec = Vec::with_capacity(col_names_count as usize); @@ -412,12 +518,7 @@ pub unsafe extern "C" fn df_fetch_by_row_ids( mgr.io_runtime .block_on(crate::api::fetch_by_row_ids( - shard_view, - runtime, - &mgr, - row_ids, - columns, - context_id, + shard_view, runtime, &mgr, row_ids, columns, context_id, )) .map_err(|e| e.to_string()) } @@ -432,13 +533,18 @@ pub unsafe extern "C" fn df_stream_get_schema(stream_ptr: i64) -> i64 { #[no_mangle] pub unsafe extern "C" fn df_stream_next(stream_ptr: i64) -> i64 { let mgr = get_rt_manager()?; - timed_block_on(&mgr.io_runtime, "stream_next", crate::task_monitors::stream_next_monitor().instrument(api::stream_next(stream_ptr))) - .map_err(|e| e.to_string()) + timed_block_on( + &mgr.io_runtime, + "stream_next", + crate::task_monitors::stream_next_monitor().instrument(api::stream_next(stream_ptr)), + ) + .map_err(|e| e.to_string()) } #[no_mangle] pub unsafe extern "C" fn df_stream_close(stream_ptr: i64) { api::stream_close(stream_ptr); + crate::liquid_cache::LiquidOnlyRuntime::log_stats_if_initialized(); } /// Returns execution metrics as JSON bytes for the given stream. @@ -446,7 +552,11 @@ pub unsafe extern "C" fn df_stream_close(stream_ptr: i64) { /// Returns 0 on success, non-zero if no metrics are available. /// The caller must free the returned bytes via `df_free_metrics_buf`. #[no_mangle] -pub unsafe extern "C" fn df_stream_get_metrics(stream_ptr: i64, out_ptr: *mut *const u8, out_len_ptr: *mut i64) -> i64 { +pub unsafe extern "C" fn df_stream_get_metrics( + stream_ptr: i64, + out_ptr: *mut *const u8, + out_len_ptr: *mut i64, +) -> i64 { if stream_ptr == 0 { return -1; } @@ -631,8 +741,15 @@ pub unsafe extern "C" fn df_register_partition_stream( .map_err(|e| format!("df_register_partition_stream: input_id: {}", e))?; let partial_plan = slice::from_raw_parts(partial_plan_ptr, partial_plan_len as usize); let (sender_ptr, schema_ipc) = - api::register_partition_stream(session_ptr, input_id, partial_plan).map_err(|e| e.to_string())?; - write_out_buffer(&schema_ipc, out_ptr, out_cap, out_len, "register_partition_stream schema IPC")?; + api::register_partition_stream(session_ptr, input_id, partial_plan) + .map_err(|e| e.to_string())?; + write_out_buffer( + &schema_ipc, + out_ptr, + out_cap, + out_len, + "register_partition_stream schema IPC", + )?; Ok(sender_ptr) } @@ -657,7 +774,10 @@ pub unsafe extern "C" fn df_execute_local_plan( // instead of the IO runtime. Without this, operator hash work runs on IO workers. // The IO runtime still drives the outer block_on (bridging the synchronous FFI // call to the async spawn handle). - timed_block_on(&mgr.io_runtime, "execute_local_plan", crate::task_monitors::coordinator_reduce_monitor().instrument(async move { + timed_block_on( + &mgr.io_runtime, + "execute_local_plan", + crate::task_monitors::coordinator_reduce_monitor().instrument(async move { // No coordinator-gate acquire here. The QTF coordinator-reduce code path runs // synchronously inside the SEARCH-thread FFM call (DatafusionReduceSink.); // gating it would deadlock when the gate is contended because the SEARCH thread @@ -665,8 +785,14 @@ pub unsafe extern "C" fn df_execute_local_plan( // exclusively on the data-node FFM entry points. let inner_fut = async move { unsafe { - api::execute_local_plan(session_ptr, &bytes_vec, &mgr_for_inner, context_id, None) - .await + api::execute_local_plan( + session_ptr, + &bytes_vec, + &mgr_for_inner, + context_id, + None, + ) + .await } }; match mgr_for_spawn.cpu_executor().spawn(inner_fut).await { @@ -675,8 +801,9 @@ pub unsafe extern "C" fn df_execute_local_plan( "execute_local_plan: CPU spawn failed: {e:?}" ))), } - })) - .map_err(|e| e.to_string()) + }), + ) + .map_err(|e| e.to_string()) } #[ffm_safe] @@ -740,10 +867,21 @@ pub unsafe extern "C" fn df_register_memtable( } else { slice::from_raw_parts(schema_ptrs, n) }; - let schema_ipc = - api::register_memtable(session_ptr, input_id, partial_plan, array_slice, schema_slice) - .map_err(|e| e.to_string())?; - write_out_buffer(&schema_ipc, out_ptr, out_cap, out_len, "register_memtable schema IPC")?; + let schema_ipc = api::register_memtable( + session_ptr, + input_id, + partial_plan, + array_slice, + schema_slice, + ) + .map_err(|e| e.to_string())?; + write_out_buffer( + &schema_ipc, + out_ptr, + out_cap, + out_len, + "register_memtable schema IPC", + )?; Ok(0) } @@ -765,9 +903,13 @@ pub unsafe extern "C" fn df_create_cache( let eviction_type = str_from_raw(eviction_type_ptr, eviction_type_len) .map_err(|e| format!("df_create_cache: eviction_type: {}", e))?; - let policy_type = match eviction_type.to_uppercase().as_str() { - "LRU" => PolicyType::Lru, - "LFU" => PolicyType::Lfu, + // Parse the eviction type string into the unified CacheEvictionPolicy enum. + // All four cache types share one enum — the per-cache match below enforces + // which policies are valid for each type. + let policy = match eviction_type.to_uppercase().as_str() { + "LRU" => CacheEvictionPolicy::Lru, + "LFU" => CacheEvictionPolicy::Lfu, + "FIFO" => CacheEvictionPolicy::Fifo, _ => { return Err(format!( "df_create_cache: unsupported eviction type: {}", @@ -781,18 +923,35 @@ pub unsafe extern "C" fn df_create_cache( match cache_type { cache::CACHE_TYPE_METADATA => { + // METADATA uses DefaultFilesMetadataCache (has its own LRU); eviction + // type is accepted but not forwarded. let inner_cache = DefaultFilesMetadataCache::new(size_limit as usize); let metadata_cache = Arc::new(cache::MutexFileMetadataCache::new(inner_cache)); manager.set_file_metadata_cache(metadata_cache); } cache::CACHE_TYPE_STATS => { - let stats_cache = Arc::new(CustomStatisticsCache::new( - policy_type, - size_limit as usize, - 0.8, - )); + if policy == CacheEvictionPolicy::Fifo { + return Err( + "df_create_cache: STATISTICS cache does not support FIFO eviction".to_string(), + ); + } + let stats_cache = + Arc::new(CustomStatisticsCache::new(policy, size_limit as usize, 0.8)); manager.set_statistics_cache(stats_cache); } + cache::CACHE_TYPE_COLUMN_INDEX => { + // CI/OI use BoundedCache; eviction type must be FIFO. + if policy != CacheEvictionPolicy::Fifo { + return Err(format!("df_create_cache: COLUMN_INDEX cache only supports FIFO eviction, got {eviction_type}")); + } + manager.set_column_index_cache(size_limit as usize); + } + cache::CACHE_TYPE_OFFSET_INDEX => { + if policy != CacheEvictionPolicy::Fifo { + return Err(format!("df_create_cache: OFFSET_INDEX cache only supports FIFO eviction, got {eviction_type}")); + } + manager.set_offset_index_cache(size_limit as usize); + } _ => { return Err(format!( "df_create_cache: invalid cache type: {}", @@ -832,15 +991,90 @@ pub unsafe extern "C" fn df_cache_manager_add_files( ); } - let rt_manager = get_rt_manager() - .map_err(|e| format!("df_cache_manager_add_files: {}", e))?; + let rt_manager = get_rt_manager().map_err(|e| format!("df_cache_manager_add_files: {}", e))?; let rt_handle = rt_manager.io_runtime.handle(); - manager.add_files(&file_paths, rt_handle) + manager + .add_files(&file_paths, rt_handle) .map_err(|e| format!("df_cache_manager_add_files: {}", e))?; Ok(0) } +/// Warmup: load footer (lightweight) into heap and promote footer + page/offset +/// index bytes to the metadata Foyer tier (never-evict) through the store. +/// +/// After this call: +/// - file_metadata_cache (heap): lightweight ParquetMetaData (footer only, no page indexes) +/// - data Foyer: raw footer + page index bytes (via get_ranges populate) +/// - metadata Foyer: the same ranges promoted via `MetadataCachingStore::put_metadata` +/// +/// Promotion happens entirely in Rust inside `CustomCacheManager::add_files_with_store`; +/// the Java caller only supplies the file paths. +/// +/// # Safety +/// - `runtime_ptr` must be a valid pointer from `df_create_global_runtime`. +/// - `store_ptr` must be a valid `Box>` pointer (produced by +/// `ts_get_object_store_box_ptr`). +/// - `files_ptr[i]` must point to `files_len_ptr[i]` valid UTF-8 bytes. +#[ffm_safe] +#[no_mangle] +pub unsafe extern "C" fn df_cache_manager_add_files_with_store( + runtime_ptr: i64, + store_ptr: i64, + files_ptr: *const *const u8, + files_len_ptr: *const i64, + files_count: i64, +) -> i64 { + if runtime_ptr == 0 { + return Err("df_cache_manager_add_files_with_store: null runtime pointer".to_string()); + } + if store_ptr == 0 { + return Err("df_cache_manager_add_files_with_store: null store pointer".to_string()); + } + + let runtime = &*(runtime_ptr as *const DataFusionRuntime); + let manager = runtime + .custom_cache_manager + .as_ref() + .ok_or_else(|| "df_cache_manager_add_files_with_store: no cache manager".to_string())?; + + // Pointer type is `Arc`; the manager calls `put_metadata` + // directly via the trait, no downcast needed. + let store_box = &*(store_ptr + as *const std::sync::Arc< + dyn opensearch_tiered_storage::tiered_object_store::MetadataCachingStore, + >); + let store = std::sync::Arc::clone(store_box); + + let mut file_paths = Vec::with_capacity(files_count as usize); + for i in 0..files_count as usize { + let ptr = *files_ptr.add(i); + let len = *files_len_ptr.add(i); + file_paths.push( + str_from_raw(ptr, len) + .map_err(|e| format!("df_cache_manager_add_files_with_store: {}", e))? + .to_string(), + ); + } + + let rt_manager = + get_rt_manager().map_err(|e| format!("df_cache_manager_add_files_with_store: {}", e))?; + let rt_handle = rt_manager.io_runtime.handle(); + + let results = manager + .add_files_with_store(&file_paths, store, rt_handle) + .map_err(|e| format!("df_cache_manager_add_files_with_store: {}", e))?; + + // Log summary + let success_count = results.iter().filter(|(_, ok)| *ok).count(); + native_bridge_common::log_info!( + "df_cache_manager_add_files_with_store: {} files, {} warmed (page-index promoted to metadata Foyer)", + files_count, success_count + ); + + Ok(0) +} + // --------------------------------------------------------------------------- // SessionContext decomposition — instruction-based execution // --------------------------------------------------------------------------- @@ -879,7 +1113,7 @@ pub unsafe extern "C" fn df_create_session_context( has_partial_aggregate != 0, query_config, plan_bytes, - ) + ), )) .map_err(|e| e.to_string()) } @@ -928,7 +1162,7 @@ pub unsafe extern "C" fn df_create_session_context_indexed( has_partial_aggregate != 0, query_config, plan_bytes, - ) + ), )) .map_err(|e| e.to_string()) } @@ -1067,6 +1301,47 @@ pub unsafe extern "C" fn df_cache_manager_contains_by_type( }) } +#[ffm_safe] +#[no_mangle] +pub unsafe extern "C" fn df_cache_manager_update_size_limit( + runtime_ptr: i64, + cache_type_ptr: *const u8, + cache_type_len: i64, + new_limit: i64, +) -> i64 { + if runtime_ptr == 0 { + return Err("df_cache_manager_update_size_limit: null runtime pointer".to_string()); + } + if new_limit < 0 { + return Err(format!( + "df_cache_manager_update_size_limit: negative limit {}", + new_limit + )); + } + let cache_type = str_from_raw(cache_type_ptr, cache_type_len) + .map_err(|e| format!("df_cache_manager_update_size_limit: {}", e))?; + let runtime = &*(runtime_ptr as *const DataFusionRuntime); + let manager = runtime.custom_cache_manager.as_ref().ok_or_else(|| { + "df_cache_manager_update_size_limit: no cache manager configured".to_string() + })?; + match cache_type { + cache::CACHE_TYPE_METADATA => { + manager.update_metadata_cache_limit(new_limit as usize); + Ok(0) + } + cache::CACHE_TYPE_STATS => { + manager + .update_statistics_cache_limit(new_limit as usize) + .map_err(|e| format!("df_cache_manager_update_size_limit: {}", e))?; + Ok(0) + } + _ => Err(format!( + "df_cache_manager_update_size_limit: unsupported cache type: {}", + cache_type + )), + } +} + #[no_mangle] pub unsafe extern "C" fn df_close_session_context(ptr: i64) { crate::session_context::close_session_context(ptr); @@ -1079,7 +1354,8 @@ pub unsafe extern "C" fn df_execute_with_context( plan_ptr: *const u8, plan_len: i64, ) -> i64 { - let session_handle = *Box::from_raw(session_ctx_ptr as *mut crate::session_context::SessionContextHandle); + let session_handle = + *Box::from_raw(session_ctx_ptr as *mut crate::session_context::SessionContextHandle); let mgr = get_rt_manager()?; let plan_bytes = slice::from_raw_parts(plan_ptr, plan_len as usize); @@ -1094,14 +1370,12 @@ pub unsafe extern "C" fn df_execute_with_context( let mgr_for_spawn = Arc::clone(&mgr); // Route based on whether the session was configured for indexed execution, - // or if the plan projects __row_id__ (QTF query phase) under a non-ListingTable - // fetch strategy. + // or if the plan projects __row_id__ (QTF query phase). QTF row-id computation + // always runs in the indexed executor. let has_row_id = plan_bytes .windows(crate::ROW_ID_COLUMN_NAME.len()) .any(|w| w == crate::ROW_ID_COLUMN_NAME.as_bytes()); - let query_strategy = session_handle.query_config.query_strategy; - let use_indexed = session_handle.indexed_config.is_some() - || (has_row_id && query_strategy != crate::datafusion_query_config::QueryStrategy::ListingTable); + let use_indexed = session_handle.indexed_config.is_some() || has_row_id; if use_indexed { // Extract target_partitions BEFORE boxing into raw pointer (session_handle is consumed). let partition_weight = session_handle.query_config.target_partitions.max(1) as u32; @@ -1116,14 +1390,16 @@ pub unsafe extern "C" fn df_execute_with_context( let max_p = gate.max_permits(); let permit = gate.acquire_many(partition_weight.min(max_p)).await; - let inner_fut = crate::task_monitors::query_execution_monitor().instrument(async move { - crate::indexed_executor::execute_indexed_with_context( - ptr, - plan_vec, - cpu_for_cross, - permit, - ).await - }); + let inner_fut = + crate::task_monitors::query_execution_monitor().instrument(async move { + crate::indexed_executor::execute_indexed_with_context( + ptr, + plan_vec, + cpu_for_cross, + permit, + ) + .await + }); match mgr_for_spawn.cpu_executor().spawn(inner_fut).await { Ok(inner) => inner, Err(e) => Err(datafusion::error::DataFusionError::Execution(format!( @@ -1144,15 +1420,16 @@ pub unsafe extern "C" fn df_execute_with_context( let max_p = gate.max_permits(); let permit = gate.acquire_many(partition_weight.min(max_p)).await; - let inner_fut = crate::task_monitors::query_execution_monitor().instrument(async move { - crate::query_executor::execute_with_context( - session_handle, - &plan_vec, - cpu_for_cross, - permit, - ) - .await - }); + let inner_fut = + crate::task_monitors::query_execution_monitor().instrument(async move { + crate::query_executor::execute_with_context( + session_handle, + &plan_vec, + cpu_for_cross, + permit, + ) + .await + }); match mgr_for_spawn.cpu_executor().spawn(inner_fut).await { Ok(inner) => inner, Err(e) => Err(datafusion::error::DataFusionError::Execution(format!( @@ -1164,7 +1441,6 @@ pub unsafe extern "C" fn df_execute_with_context( } } - // ---- Stats collection ---- /// Collects all native executor metrics into a caller-provided byte buffer. @@ -1178,18 +1454,19 @@ pub unsafe extern "C" fn df_execute_with_context( #[no_mangle] pub unsafe extern "C" fn df_stats(runtime_ptr: i64, out_ptr: *mut u8, out_cap: i64) -> i64 { use crate::stats::{ - layout, pack_cache_stats, pack_partition_gate, pack_runtime_metrics, pack_task_monitor, - pack_adaptive_budget, CacheStatsRepr, DfStatsBuffer, RuntimeMetricsRepr, + layout, pack_adaptive_budget, pack_cache_stats, pack_partition_gate, pack_runtime_metrics, + pack_task_monitor, CacheStatsRepr, DfStatsBuffer, RuntimeMetricsRepr, }; use crate::task_monitors::{ - coordinator_reduce_monitor, query_execution_monitor, - stream_next_monitor, plan_setup_monitor, + coordinator_reduce_monitor, plan_setup_monitor, query_execution_monitor, + stream_next_monitor, }; if out_cap < 0 || (out_cap as usize) < layout::BUFFER_BYTE_SIZE { return Err(format!( "stats buffer too small: need {} but got {}", - layout::BUFFER_BYTE_SIZE, out_cap + layout::BUFFER_BYTE_SIZE, + out_cap )); } @@ -1269,9 +1546,10 @@ pub unsafe extern "C" fn df_prepare_partial_plan( let bytes = slice::from_raw_parts(bytes_ptr, bytes_len); let mgr = get_rt_manager()?; mgr.io_runtime - .block_on(crate::task_monitors::plan_setup_monitor().instrument( - crate::session_context::prepare_partial_plan(handle, bytes) - )) + .block_on( + crate::task_monitors::plan_setup_monitor() + .instrument(crate::session_context::prepare_partial_plan(handle, bytes)), + ) .map_err(|e| e.to_string())?; Ok(0) } @@ -1298,9 +1576,10 @@ pub unsafe extern "C" fn df_prepare_final_plan( let bytes = slice::from_raw_parts(bytes_ptr, bytes_len); let mgr = get_rt_manager()?; mgr.io_runtime - .block_on(crate::task_monitors::plan_setup_monitor().instrument( - session.prepare_final_plan(bytes) - )) + .block_on( + crate::task_monitors::plan_setup_monitor() + .instrument(session.prepare_final_plan(bytes)), + ) .map_err(|e| e.to_string())?; Ok(0) } @@ -1315,10 +1594,7 @@ pub unsafe extern "C" fn df_prepare_final_plan( /// with a plan already prepared via `df_prepare_final_plan`. #[ffm_safe] #[no_mangle] -pub unsafe extern "C" fn df_execute_local_prepared_plan( - session_ptr: i64, - context_id: i64, -) -> i64 { +pub unsafe extern "C" fn df_execute_local_prepared_plan(session_ptr: i64, context_id: i64) -> i64 { let mgr = get_rt_manager()?; // No coordinator-gate acquire here — see df_execute_local_plan for the rationale // (the QTF coordinator-reduce path runs synchronously inside the SEARCH-thread FFM @@ -1326,6 +1602,63 @@ pub unsafe extern "C" fn df_execute_local_prepared_plan( api::execute_local_prepared_plan(session_ptr, &mgr, context_id, None).map_err(|e| e.to_string()) } +// ── Scoped page-index cache limit setters ──────────────────────────────────── +// +// These are wired from Java at startup (CacheUtils.createCacheConfig) and on +// dynamic setting changes (DataFusionPlugin settings consumers). They forward +// to the process-global caches in crate::cache::page_index. +// +// NOTE: On main these are stubs that do nothing — the cache module from PR 1 +// is not yet present. Once PR 1 merges the bodies replace these no-ops. + +/// Set the byte budget of the process-global scoped ColumnIndex cache. +/// Zero is ignored; negative returns an error. +#[ffm_safe] +#[no_mangle] +pub extern "C" fn df_set_column_index_cache_limit(size_limit: i64) -> i64 { + if size_limit < 0 { + return Err(format!( + "df_set_column_index_cache_limit: negative limit {}", + size_limit + )); + } + page_index::set_column_index_cache_limit(size_limit as usize); + Ok(0) +} + +/// Set the byte budget of the process-global scoped OffsetIndex cache. +/// Zero is ignored; negative returns an error. +#[ffm_safe] +#[no_mangle] +pub extern "C" fn df_set_offset_index_cache_limit(size_limit: i64) -> i64 { + if size_limit < 0 { + return Err(format!( + "df_set_offset_index_cache_limit: negative limit {}", + size_limit + )); + } + page_index::set_offset_index_cache_limit(size_limit as usize); + Ok(0) +} + +/// Clear the process-global scoped page-index caches (drop entries + reset counters). +#[ffm_safe] +#[no_mangle] +pub extern "C" fn df_clear_scoped_page_index_cache() -> i64 { + page_index::clear_scoped_cache(); + Ok(0) +} + +/// Enable or disable the scoped page-index feature. +/// When disabled: metadata cache retains full page index (fallback mode). +/// When enabled (default): metadata cache strips page index; scoped caches handle it. +#[ffm_safe] +#[no_mangle] +pub extern "C" fn df_set_scoped_page_index_enabled(enabled: i64) -> i64 { + page_index::set_scoped_page_index_enabled(enabled != 0); + Ok(0) +} + #[cfg(test)] mod tests { use super::*; @@ -1340,7 +1673,10 @@ mod tests { fn send_outcome_maps_receiver_dropped_to_sentinel() { // Must surface the sentinel, not collapse to 0 like a normal send, or Java never latches // isConsumerDone(). - assert_eq!(send_outcome_to_code(SendOutcome::ReceiverDropped), SENDER_SEND_RECEIVER_DROPPED); + assert_eq!( + send_outcome_to_code(SendOutcome::ReceiverDropped), + SENDER_SEND_RECEIVER_DROPPED + ); assert_eq!(SENDER_SEND_RECEIVER_DROPPED, 1); } @@ -1359,11 +1695,7 @@ mod tests { /// Helper: call df_update_concurrency_gate with a Rust string. /// Returns the i64 result (0 = success for the outer call). unsafe fn call_update_gate(gate_name: &str, new_max: u32) -> i64 { - df_update_concurrency_gate( - gate_name.as_ptr(), - gate_name.len() as i64, - new_max, - ) + df_update_concurrency_gate(gate_name.as_ptr(), gate_name.len() as i64, new_max) } /// Validates: Requirements 2.2, 2.4, 2.6 @@ -1381,7 +1713,10 @@ mod tests { // ── Test 1: update before runtime init returns success (Req 2.6) ── shutdown_test_runtime(); // ensure clean state let result = unsafe { call_update_gate("fragment_executor", 10) }; - assert_eq!(result, 0, "FFI call should return success even before runtime init"); + assert_eq!( + result, 0, + "FFI call should return success even before runtime init" + ); // ── Initialize runtime for remaining tests ── init_test_runtime(); @@ -1394,7 +1729,10 @@ mod tests { let new_max = initial_max + 4; let result = unsafe { call_update_gate("fragment_executor", new_max) }; - assert_eq!(result, 0, "FFI call should return success for 'fragment_executor'"); + assert_eq!( + result, 0, + "FFI call should return success for 'fragment_executor'" + ); // The resize is spawned on the IO runtime asynchronously. // Wait briefly for it to complete. @@ -1415,7 +1753,10 @@ mod tests { let fragment_executor_max_before = fragment_executor_gate.max_permits(); let result = unsafe { call_update_gate("unknown_gate", 99) }; - assert_eq!(result, 0, "FFI call should return success even for unknown gate"); + assert_eq!( + result, 0, + "FFI call should return success even for unknown gate" + ); // Wait briefly to ensure no async resize was spawned std::thread::sleep(std::time::Duration::from_millis(100)); diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/helper.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/helper.rs new file mode 100644 index 0000000000000..66bee6b67c35f --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/helper.rs @@ -0,0 +1,201 @@ +/* + * 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. + */ + +//! Small shared helpers for the query execution paths. + +use std::sync::Arc; + +use datafusion::common::DataFusionError; +use datafusion::datasource::file_format::parquet::ParquetFormat; +use datafusion::datasource::listing::{ + ListingOptions, ListingTable, ListingTableConfig, ListingTableUrl, +}; +use datafusion::execution::context::SessionContext; +use datafusion::execution::memory_pool::MemoryPool; +use datafusion::execution::runtime_env::{RuntimeEnv, RuntimeEnvBuilder}; +use datafusion::execution::session_state::SessionStateBuilder; +use datafusion::prelude::SessionConfig; +use log::error; +use object_store::{ObjectMeta, ObjectStore}; + +use crate::agg_mode::physical_optimizer_rules_without_combine; +use crate::api::DataFusionRuntime; +use crate::datafusion_query_config::DatafusionQueryConfig; +use crate::indexed_table::substrait_to_tree::{ + create_delegation_possible_udf, create_index_filter_udf, +}; +use crate::query_executor::build_query_runtime_env; +use crate::query_tracker::{QueryTrackingContext, QueryType}; +use crate::schema_coerce::coerce_inferred_schema; +use crate::session_context::build_file_sort_order; +use crate::udaf; +use crate::udf; + +/// Creates a per-query [`QueryTrackingContext`] (auto-registered in the global +/// registry) and extracts its per-query memory pool as a `dyn MemoryPool`. +/// +/// The returned pool is `Some` only when tracking is active; pass it to the +/// executor so the query's allocations are charged against a pool that wraps +/// `global_pool` (global limits stay enforced). Returns the context too so the +/// caller can attach phantom reservations/correctors before execution. +pub fn new_query_tracking_context( + context_id: i64, + global_pool: Arc, + query_type: QueryType, +) -> (QueryTrackingContext, Option>) { + let query_context = QueryTrackingContext::new(context_id, global_pool, query_type); + let query_memory_pool = query_context + .memory_pool() + .map(|p| p as Arc); + (query_context, query_memory_pool) +} + +/// Builds the per-query `RuntimeEnv` and registers the shard-specific object store. +/// +/// Combines the three steps every query-execution entry point shares: +/// 1. Build a per-query `RuntimeEnv` with the list-files cache pre-populated for +/// `table_path` / `object_metas` (via [`build_query_runtime_env`]). +/// 2. If `query_memory_pool` is `Some`, rebuild the env with that pool overlaid. +/// The per-query pool wraps the global pool, so global limits stay enforced. +/// 3. Register `shard_store` on the `file://` scheme so this query's reads route +/// through `TieredObjectStore` (local + remote) or the default `LocalFileSystem`. +pub fn build_query_runtime_env_with_store( + runtime: &DataFusionRuntime, + table_path: &ListingTableUrl, + object_metas: &[ObjectMeta], + shard_store: Arc, + query_memory_pool: Option>, +) -> Result, DataFusionError> { + // Build per-query RuntimeEnv with list-files cache pre-populated. + let runtime_env = build_query_runtime_env(runtime, table_path, object_metas)?; + + // If a per-query memory pool is provided, rebuild with it overlaid. + // The per-query pool wraps the global pool, so global limits are still enforced. + let runtime_env = if let Some(pool) = query_memory_pool { + Arc::from( + RuntimeEnvBuilder::from_runtime_env(&runtime_env) + .with_memory_pool(pool) + .build() + .map_err(|e| { + error!("Failed to build runtime env with query pool: {}", e); + e + })?, + ) + } else { + runtime_env + }; + + // Register shard-specific object store on file:// scheme for this query. + // Routes reads through TieredObjectStore (local + remote) or default LocalFileSystem. + runtime_env.register_object_store(&url::Url::parse("file://").unwrap(), shard_store); + + Ok(runtime_env) +} + +/// Registers a standard DataFusion `ListingTable` for `table_path` under +/// `table_name` so the substrait consumer can resolve the table. +/// +/// Shared by the vanilla and indexed execution paths. Infers + coerces the +/// schema, and — when `sort_fields`/`sort_orders` describe an `index.sort.field` +/// — declares the per-file sort order to DataFusion (see +/// [`build_file_sort_order`] for what that buys and its case/nulls caveats). +/// Pass empty slices to skip the sort-order declaration. +pub async fn register_listing_table( + ctx: &SessionContext, + table_name: &str, + table_path: ListingTableUrl, + sort_fields: &[String], + sort_orders: &[String], +) -> Result<(), DataFusionError> { + let mut listing_options = ListingOptions::new(Arc::new(ParquetFormat::new())) + .with_file_extension(".parquet") + .with_collect_stat(true); + if let Some(sort_exprs) = build_file_sort_order(sort_fields, sort_orders) { + listing_options = listing_options.with_file_sort_order(vec![sort_exprs]); + } + + let resolved_schema = listing_options + .infer_schema(&ctx.state(), &table_path) + .await + .map_err(|e| { + error!("Failed to infer schema: {}", e); + e + })?; + let resolved_schema = coerce_inferred_schema(resolved_schema); + + let table_config = ListingTableConfig::new(table_path) + .with_listing_options(listing_options) + .with_schema(resolved_schema); + let provider = Arc::new(ListingTable::try_new(table_config).map_err(|e| { + error!("Failed to create listing table: {}", e); + e + })?); + ctx.register_table(table_name, provider).map_err(|e| { + error!("Failed to register listing table: {}", e); + e + })?; + Ok(()) +} + +/// Builds a per-query [`SessionContext`] shared by the vanilla and indexed paths. +/// +/// Sets the common execution options (parquet pushdown, target partitions, batch +/// size) from `query_config`, installs `runtime_env`, and registers the base +/// scalar/aggregate UDFs. +/// +/// `target_partitions` is passed explicitly because the paths differ: the vanilla +/// path uses `query_config.target_partitions`, while the indexed path fans out via +/// its own IndexedExec partitions and only needs a sane value here for any +/// post-scan operators. +/// +/// When `indexed_path` is true, the context is built for the indexed executor: +/// the physical optimizer drops the combine-partial-final pass, and the indexed +/// `index_filter` / `delegation_possible` UDFs are registered on top of the base +/// UDFs. When false, the vanilla path's defaults are used. +pub fn build_query_session_context( + query_config: &DatafusionQueryConfig, + runtime_env: Arc, + target_partitions: usize, + indexed_path: bool, +) -> SessionContext { + let mut config = SessionConfig::new(); + config.options_mut().execution.parquet.pushdown_filters = + query_config.listing_table_pushdown_filters; + config.options_mut().execution.target_partitions = target_partitions.max(1); + config.options_mut().execution.batch_size = query_config.batch_size; + + let mut builder = SessionStateBuilder::new() + .with_config(config) + .with_runtime_env(runtime_env) + .with_default_features(); + if indexed_path { + // Indexed executor drives partial/final aggregation itself; drop the + // combine-partial-final physical optimizer pass. + builder = builder.with_physical_optimizer_rules(physical_optimizer_rules_without_combine()); + } + // Only apply the physical optimizer (LocalModeLiquidCacheOptimizer) — it + // wraps ParquetSource with LiquidParquetSource for filter pushdown + + // decoded-batch caching. The LineageOptimizer (logical) is excluded because + // it adds planning overhead that causes regression. + if crate::liquid_cache::LiquidOnlyRuntime::is_enabled_globally() { + if let Some(optimizer) = crate::liquid_cache::LiquidOnlyRuntime::optimizer_globally() { + builder = builder.with_physical_optimizer_rule(optimizer); + } + } + let state = builder.build(); + + let ctx = SessionContext::new_with_state(state); + udf::register_all(&ctx); + udaf::register_all(&ctx); + if indexed_path { + // Indexed-path-only UDFs, on top of the base UDFs above. + ctx.register_udf(create_index_filter_udf()); + ctx.register_udf(create_delegation_possible_udf()); + } + ctx +} diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_executor.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_executor.rs index faf1615d0b3b0..b3484fbe2c2b9 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_executor.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_executor.rs @@ -21,36 +21,34 @@ use std::sync::Arc; -use native_bridge_common::log_debug; use datafusion::{ - physical_plan::displayable, - physical_plan::execute_stream, - execution::SessionStateBuilder, - execution::context::SessionContext, - common::DataFusionError, - prelude::*, arrow::datatypes::SchemaRef, catalog::Session, common::tree_node::{TreeNode, TreeNodeRecursion}, + common::DataFusionError, datasource::{TableProvider, TableType}, - execution::cache::cache_manager::CachedFileList, - execution::cache::{CacheAccessor, DefaultListFilesCache, TableScopedPath}, execution::memory_pool::MemoryPool, execution::object_store::ObjectStoreUrl, logical_expr::Expr, physical_expr::expressions::Column, physical_expr::PhysicalExpr, physical_optimizer::pruning::PruningPredicate, + physical_plan::displayable, + physical_plan::execute_stream, physical_plan::stream::RecordBatchStreamAdapter, - physical_plan::ExecutionPlan + physical_plan::ExecutionPlan, }; use datafusion_substrait::logical_plan::consumer::from_substrait_plan; +use native_bridge_common::log_debug; use prost::Message; use substrait::proto::Plan; use crate::api::DataFusionRuntime; use crate::cross_rt_stream::CrossRtStream; use crate::executor::DedicatedExecutor; +use crate::helper::{ + build_query_runtime_env_with_store, build_query_session_context, register_listing_table, +}; use crate::indexed_table::bool_tree::BoolNode; use crate::indexed_table::eval::bitmap_tree::{BitmapTreeEvaluator, CollectorLeafBitmaps}; use crate::indexed_table::eval::single_collector::SingleCollectorEvaluator; @@ -60,22 +58,26 @@ use crate::indexed_table::index::RowGroupDocsCollector; use crate::indexed_table::page_pruner::PagePruner; use crate::indexed_table::segment_info::build_segments; use crate::indexed_table::substrait_to_tree::{ - classify_filter, create_index_filter_udf, expr_to_bool_tree, - extract_filter_expr, ExtractionResult, FilterClass, + classify_filter, expr_to_bool_tree, extract_filter_expr, ExtractionResult, FilterClass, }; use crate::indexed_table::table_provider::{ EvaluatorFactory, IndexedTableConfig, IndexedTableProvider, SegmentFileInfo, }; -use std::collections::{BTreeSet, HashMap}; +use std::collections::{HashMap, HashSet}; use std::fmt; use crate::api::ShardView; +use crate::cache::page_index; use crate::datafusion_query_config::DatafusionQueryConfig; use crate::indexed_table::bool_tree::residual_bool_to_physical_expr; use crate::indexed_table::metrics::StreamMetrics; -use crate::indexed_table::page_pruner::{build_pruning_predicate, PagePruneMetrics, StatsPruneTree}; - +use crate::indexed_table::page_pruner::{ + build_pruning_predicate, PagePruneMetrics, StatsPruneTree, +}; +use crate::parquet_page_cache::{ + load_scoped_page_index_cols, resolve_predicate_parquet_columns_pair, +}; /// Execute an indexed query. /// @@ -98,63 +100,29 @@ pub async fn execute_indexed_query( context_id: i64, ) -> Result { let num_partitions = query_config.target_partitions.max(1); - // Share caches with the global runtime (same as vanilla path): list-files - // pre-populated with the reader's object_metas, file-metadata and - // file-statistics inherited from the global runtime for cross-query reuse. - let list_file_cache = Arc::new(DefaultListFilesCache::default()); - let table_scoped_path = TableScopedPath { - table: None, - path: shard_view.table_path.prefix().clone(), - }; - list_file_cache.put(&table_scoped_path, CachedFileList::new(shard_view.object_metas.as_ref().clone())); - - let mut runtime_env_builder = crate::query_executor::query_runtime_env_builder(runtime, list_file_cache); - if let Some(pool) = query_memory_pool { - runtime_env_builder = runtime_env_builder.with_memory_pool(pool); - } - let runtime_env = runtime_env_builder - .build() - .map_err(|e| DataFusionError::Execution(format!("runtime env: {}", e)))?; - - // Register shard-specific object store on file:// scheme for this query. - runtime_env.register_object_store( - &url::Url::parse("file://").unwrap(), + // Build the per-query RuntimeEnv (list-files cache pre-populated, optional + // per-query pool overlay) and register the shard object store — shared with + // the vanilla path. File-metadata and file-statistics caches are inherited + // from the global runtime for cross-query reuse. + let runtime_env = build_query_runtime_env_with_store( + runtime, + &shard_view.table_path, + shard_view.object_metas.as_ref(), Arc::clone(&shard_view.store), - ); + query_memory_pool, + )?; + + // Build a fresh session context per query. The indexed path fans out via + // IndexedExec partitions (derived from num_partitions), not DataFusion's, but + // DF still wants a sane value for any post-scan operators it may add. The + // `indexed_path` flag also drops the combine-partial-final optimizer pass and + // registers the indexed-only index_filter / delegation_possible UDFs. + let ctx = build_query_session_context(&query_config, runtime_env, num_partitions, true); - let mut config = SessionConfig::new(); - config.options_mut().execution.parquet.pushdown_filters = query_config.parquet_pushdown_filters; - // Indexed path fans out via IndexedExec partitions (derived from - // num_partitions), not DataFusion's. But DF wants a sane value here - // for any post-scan operators it may add. - config.options_mut().execution.target_partitions = num_partitions.max(1); - config.options_mut().execution.batch_size = query_config.batch_size; - let state = SessionStateBuilder::new() - .with_config(config) - .with_runtime_env(Arc::from(runtime_env)) - .with_default_features() - .with_physical_optimizer_rules(crate::agg_mode::physical_optimizer_rules_without_combine()) - .build(); - let ctx = SessionContext::new_with_state(state); - ctx.register_udf(create_index_filter_udf()); - ctx.register_udf(crate::indexed_table::substrait_to_tree::create_delegation_possible_udf()); - crate::udf::register_all(&ctx); - crate::udaf::register_all(&ctx); - - // Register default ListingTable so substrait consumer can resolve the table - let listing_options = datafusion::datasource::listing::ListingOptions::new( - Arc::new(datafusion::datasource::file_format::parquet::ParquetFormat::new())) - .with_file_extension(".parquet") - .with_collect_stat(true); - let resolved_schema = listing_options - .infer_schema(&ctx.state(), &shard_view.table_path) - .await?; - let resolved_schema = crate::schema_coerce::coerce_inferred_schema(resolved_schema); - let table_config = datafusion::datasource::listing::ListingTableConfig::new(shard_view.table_path.clone()) - .with_listing_options(listing_options) - .with_schema(resolved_schema); - let provider = Arc::new(datafusion::datasource::listing::ListingTable::try_new(table_config)?); - ctx.register_table(&table_name, provider)?; + // Register default ListingTable so substrait consumer can resolve the table. + // No sort-order declaration on the indexed path (empty slices) — the indexed + // executor drives ordering itself via IndexedExec. + register_listing_table(&ctx, &table_name, shard_view.table_path.clone(), &[], &[]).await?; // Build SessionContextHandle and delegate to execute_indexed_with_context let handle = crate::session_context::SessionContextHandle { @@ -164,12 +132,17 @@ pub async fn execute_indexed_query( writer_generations: shard_view.writer_generations.clone(), sort_fields: shard_view.sort_fields.clone(), sort_orders: shard_view.sort_orders.clone(), - query_context: crate::query_tracker::QueryTrackingContext::new(context_id, runtime.runtime_env.memory_pool.clone(), crate::query_tracker::QueryType::Shard), + query_context: crate::query_tracker::QueryTrackingContext::new( + context_id, + runtime.runtime_env.memory_pool.clone(), + crate::query_tracker::QueryType::Shard, + ), table_name: table_name.clone(), indexed_config: None, // derive classification from tree query_config: Arc::unwrap_or_clone(query_config), io_handle: tokio::runtime::Handle::current(), aggregate_mode: crate::agg_mode::Mode::Default, + has_topk: false, prepared_plan: None, phantom_reservation: None, }; @@ -251,8 +224,12 @@ pub(crate) fn should_reverse_segments( sort_orders: &[String], ) -> bool { let Some(top) = top_sort else { return false }; - let Some(catalog_field) = sort_fields.first() else { return false }; - let Some(catalog_order) = sort_orders.first() else { return false }; + let Some(catalog_field) = sort_fields.first() else { + return false; + }; + let Some(catalog_order) = sort_orders.first() else { + return false; + }; if top.column != *catalog_field { return false; } @@ -300,12 +277,24 @@ fn collect_predicate_exprs(tree: &BoolNode, out: &mut Vec> } } +/// Collect leaf predicate exprs from the extraction tree in a single traversal. +fn collect_leaf_exprs(extraction: Option<&ExtractionResult>) -> Vec> { + let Some(e) = extraction else { return vec![] }; + let mut exprs = Vec::new(); + collect_predicate_exprs(&e.tree, &mut exprs); + exprs +} + fn collect_predicate_column_indices(extraction: Option<&ExtractionResult>) -> Vec { let Some(e) = extraction else { return vec![] }; let mut exprs = Vec::new(); collect_predicate_exprs(&e.tree, &mut exprs); - let mut indices = BTreeSet::new(); - for expr in &exprs { + collect_predicate_column_indices_from_exprs(&exprs) +} + +fn collect_predicate_column_indices_from_exprs(exprs: &[Arc]) -> Vec { + let mut indices = HashSet::new(); + for expr in exprs { let _ = expr.apply(|node| { if let Some(col) = node.downcast_ref::() { indices.insert(col.index()); @@ -315,6 +304,84 @@ fn collect_predicate_column_indices(extraction: Option<&ExtractionResult>) -> Ve } indices.into_iter().collect() } + +fn collect_predicate_column_names( + extraction: Option<&ExtractionResult>, + schema: &SchemaRef, +) -> Vec { + let Some(e) = extraction else { return vec![] }; + let mut exprs = Vec::new(); + collect_predicate_exprs(&e.tree, &mut exprs); + let mut names = HashSet::new(); + for expr in &exprs { + let _ = expr.apply(|node| { + if let Some(col) = node.downcast_ref::() { + if let Some(field) = schema.fields().get(col.index()) { + names.insert(field.name().to_string()); + } + } + Ok(TreeNodeRecursion::Continue) + }); + } + names.into_iter().collect() +} + +fn collect_plan_column_names(plan: &datafusion::logical_expr::LogicalPlan) -> Vec { + let mut names = HashSet::new(); + let _ = plan.apply(|node| { + // Output-schema columns of every node: this is what each node actually + // emits / reads. Critically this captures `SELECT *` (and any projection + // pushed into the scan), where no Projection expression lists the columns + // but every column is still read. Expression-only collection misses them, + // and a read column that gets only a placeholder OffsetIndex (instead of + // its real multi-page one) corrupts the page read: arrow decodes the whole + // column chunk as a single page → "output too small for decompressed data" + // (or, upstream, the (0,0)-page byte-range subtract underflow). + for field in node.schema().fields() { + names.insert(field.name().to_string()); + } + let _ = node.apply_expressions(|expr| { + let _ = expr.apply(|e| { + if let Expr::Column(col) = e { + names.insert(col.name().to_string()); + } + Ok(TreeNodeRecursion::Continue) + }); + Ok(TreeNodeRecursion::Continue) + }); + Ok(TreeNodeRecursion::Continue) + }); + names.into_iter().collect() +} + +/// Build the `prune_tree_config` tuple from a BoolNode tree and schema. +/// Builds per-leaf PruningPredicates from pre-collected leaf exprs. +fn build_prune_tree_config( + tree: &Arc, + schema: &SchemaRef, + leaf_exprs: &[Arc], +) -> Option<( + Arc, + Arc>>, + SchemaRef, +)> { + let leaf_predicates: HashMap> = leaf_exprs + .iter() + .filter_map(|expr| { + build_pruning_predicate(expr, Arc::clone(schema)) + .map(|pp| (Arc::as_ptr(expr) as *const () as usize, pp)) + }) + .collect(); + if leaf_predicates.is_empty() { + return None; + } + Some(( + Arc::clone(tree), + Arc::new(leaf_predicates), + Arc::clone(schema), + )) +} + /// For a tree classified as `SingleCollector`, walk it to find the single /// Collector leaf and return its query bytes. fn single_collector_id(tree: &BoolNode) -> Option { @@ -404,9 +471,7 @@ mod tests { use std::sync::Arc; fn collector(id: i32) -> BoolNode { - BoolNode::Collector { - annotation_id: id, - } + BoolNode::Collector { annotation_id: id } } fn pred() -> BoolNode { @@ -457,10 +522,7 @@ mod tests { let p2 = pred(); let tree = BoolNode::And(vec![ p1, - BoolNode::And(vec![ - collector(0), - BoolNode::And(vec![collector(1), p2]), - ]), + BoolNode::And(vec![collector(0), BoolNode::And(vec![collector(1), p2])]), ]); let r = extract_single_collector_residual(&tree).unwrap(); match r { @@ -477,10 +539,7 @@ mod tests { // AND(C, AND(P, OR(P, P))) → AND(P, OR(P, P)) let tree = BoolNode::And(vec![ collector(10), - BoolNode::And(vec![ - pred(), - BoolNode::Or(vec![pred(), pred()]), - ]), + BoolNode::And(vec![pred(), BoolNode::Or(vec![pred(), pred()])]), ]); let r = extract_single_collector_residual(&tree).unwrap(); match r { @@ -506,8 +565,8 @@ mod tests { // ── analyze_top_sort / should_reverse_segments ──────────────────── fn build_logical_plan(sql: &str) -> datafusion::logical_expr::LogicalPlan { - use datafusion::execution::SessionStateBuilder; use datafusion::execution::context::SessionContext; + use datafusion::execution::SessionStateBuilder; let state = SessionStateBuilder::new().with_default_features().build(); let ctx = SessionContext::new_with_state(state); let schema = Arc::new(Schema::new(vec![ @@ -558,6 +617,43 @@ mod tests { assert!(analyze_top_sort(&plan).is_none()); } + // ── collect_plan_column_names ───────────────────────────────────── + + /// Regression: `SELECT *` (and any scan that reads columns no Projection + /// expression names) must yield ALL output columns. The scoped page-index + /// load gives only these columns a REAL multi-page OffsetIndex; columns left + /// out get a single-page placeholder, which is fine for pruning but CORRUPTS + /// a real read — arrow decodes the whole chunk as one page ("output too small + /// for decompressed data"), or underflows the read byte range. The + /// `match() | sort | head` shape (q23) hit this: it reads every column but no + /// expression lists them. Collecting each plan node's OUTPUT SCHEMA fixes it. + #[test] + fn collect_plan_column_names_includes_select_star_columns() { + // No projection expressions name id/ts/v, but `SELECT *` reads all three. + let plan = build_logical_plan("SELECT * FROM t WHERE v = 0 ORDER BY ts"); + let mut names = collect_plan_column_names(&plan); + names.sort(); + assert_eq!( + names, + vec!["id".to_string(), "ts".to_string(), "v".to_string()], + "every read column (full output schema) must be collected, not just expression columns" + ); + } + + /// A narrow projection still collects exactly the columns the query touches + /// (projected `v` + filter `id` + sort `ts`) — the scoping benefit is retained. + #[test] + fn collect_plan_column_names_collects_projected_and_referenced() { + let plan = build_logical_plan("SELECT v FROM t WHERE id = 1 ORDER BY ts"); + let names = collect_plan_column_names(&plan); + for expected in ["v", "id", "ts"] { + assert!( + names.iter().any(|n| n == expected), + "expected column `{expected}` in collected names {names:?}" + ); + } + } + #[test] fn analyze_top_sort_returns_none_for_function_sort_key() { // `ORDER BY abs(v)` — leading sort key isn't a plain column. Catalog monotonicity @@ -568,7 +664,10 @@ mod tests { #[test] fn should_reverse_segments_matches_leading_field_opposite_direction() { - let top = TopSort { column: "id".to_string(), descending: true }; + let top = TopSort { + column: "id".to_string(), + descending: true, + }; let fields = vec!["id".to_string()]; let orders = vec!["asc".to_string()]; assert!(should_reverse_segments(Some(&top), &fields, &orders)); @@ -576,7 +675,10 @@ mod tests { #[test] fn should_reverse_segments_matches_leading_field_same_direction() { - let top = TopSort { column: "id".to_string(), descending: false }; + let top = TopSort { + column: "id".to_string(), + descending: false, + }; let fields = vec!["id".to_string()]; let orders = vec!["asc".to_string()]; assert!(!should_reverse_segments(Some(&top), &fields, &orders)); @@ -584,7 +686,10 @@ mod tests { #[test] fn should_reverse_segments_catalog_desc_query_asc() { - let top = TopSort { column: "id".to_string(), descending: false }; + let top = TopSort { + column: "id".to_string(), + descending: false, + }; let fields = vec!["id".to_string()]; let orders = vec!["desc".to_string()]; assert!(should_reverse_segments(Some(&top), &fields, &orders)); @@ -599,7 +704,10 @@ mod tests { #[test] fn should_reverse_segments_no_catalog_sort() { - let top = TopSort { column: "id".to_string(), descending: true }; + let top = TopSort { + column: "id".to_string(), + descending: true, + }; assert!(!should_reverse_segments(Some(&top), &[], &[])); } @@ -607,7 +715,10 @@ mod tests { fn should_reverse_segments_query_sort_on_non_leading_catalog_field() { // Catalog: [a ASC, b ASC]; query: ORDER BY b DESC. Segments are monotonic on `a` // (the leading key), not `b`. Reversing won't help — decline. - let top = TopSort { column: "b".to_string(), descending: true }; + let top = TopSort { + column: "b".to_string(), + descending: true, + }; let fields = vec!["a".to_string(), "b".to_string()]; let orders = vec!["asc".to_string(), "asc".to_string()]; assert!(!should_reverse_segments(Some(&top), &fields, &orders)); @@ -617,7 +728,10 @@ mod tests { fn should_reverse_segments_field_name_case_sensitive() { // Match PR #22041 — `Column::from_name` is case-sensitive. If casing differs, // we don't claim the catalog ordering applies. Safe default: no reversal. - let top = TopSort { column: "ID".to_string(), descending: true }; + let top = TopSort { + column: "ID".to_string(), + descending: true, + }; let fields = vec!["id".to_string()]; let orders = vec!["asc".to_string()]; assert!(!should_reverse_segments(Some(&top), &fields, &orders)); @@ -629,13 +743,11 @@ mod tests { use datafusion::parquet::file::metadata::{FileMetaData, ParquetMetaData}; // Build a minimal ParquetMetaData. We never read it back in these tests. let schema = std::sync::Arc::new( - datafusion::parquet::schema::types::SchemaDescriptor::new( - std::sync::Arc::new( - datafusion::parquet::schema::types::Type::group_type_builder("schema") - .build() - .unwrap(), - ), - ), + datafusion::parquet::schema::types::SchemaDescriptor::new(std::sync::Arc::new( + datafusion::parquet::schema::types::Type::group_type_builder("schema") + .build() + .unwrap(), + )), ); let file_meta = FileMetaData::new(0, 0, None, None, schema, None); let pq_meta = ParquetMetaData::new(file_meta, vec![]); @@ -671,7 +783,7 @@ mod tests { assert_eq!(segs[1].max_doc, 20); assert_eq!(segs[1].global_base, 10); // B's catalog base, unchanged. assert_eq!(segs[2].max_doc, 10); - assert_eq!(segs[2].global_base, 0); // A's catalog base, unchanged. + assert_eq!(segs[2].global_base, 0); // A's catalog base, unchanged. } #[test] @@ -704,11 +816,13 @@ pub async unsafe fn execute_indexed_with_context( cpu_executor: DedicatedExecutor, permit: tokio::sync::OwnedSemaphorePermit, ) -> Result { - let handle = *Box::from_raw(session_ctx_ptr as *mut crate::session_context::SessionContextHandle); + let handle = + *Box::from_raw(session_ctx_ptr as *mut crate::session_context::SessionContextHandle); let context_id = handle.query_context.context_id(); let token = crate::query_tracker::get_cancellation_token(context_id); - let query_future = execute_indexed_with_context_inner(handle, substrait_bytes, cpu_executor, permit); + let query_future = + execute_indexed_with_context_inner(handle, substrait_bytes, cpu_executor, permit); crate::cancellation::cancellable(token.as_ref(), context_id, query_future) .await .map_err(DataFusionError::Execution) @@ -720,7 +834,6 @@ async unsafe fn execute_indexed_with_context_inner( cpu_executor: DedicatedExecutor, permit: tokio::sync::OwnedSemaphorePermit, ) -> Result { - // Permit was acquired by the caller (ffm.rs) on the IO runtime before // spawning on the CPU runtime, so the Java search thread blocks at the // gate when it is full — creating backpressure at the Java threadpool level. @@ -733,22 +846,30 @@ async unsafe fn execute_indexed_with_context_inner( let context_id_early = handle.query_context.context_id(); // engine-native-merge: borrow the partial-state schema from the prepared plan so the // empty stream matches the populated shards' wire shape (e.g. Binary HLL for dc()). - let plan_schema: arrow::datatypes::SchemaRef = if let Some(prepared) = handle.prepared_plan.as_ref() { - Arc::new(prepared.schema().as_ref().clone()) - } else { - let plan = Plan::decode(substrait_bytes.as_slice()) - .map_err(|e| DataFusionError::Execution(format!("decode substrait: {}", e)))?; - let logical_plan = from_substrait_plan(&handle.ctx.state(), &plan).await?; - Arc::new(logical_plan.schema().as_arrow().clone()) - }; + let plan_schema: arrow::datatypes::SchemaRef = + if let Some(prepared) = handle.prepared_plan.as_ref() { + Arc::new(prepared.schema().as_ref().clone()) + } else { + let plan = Plan::decode(substrait_bytes.as_slice()) + .map_err(|e| DataFusionError::Execution(format!("decode substrait: {}", e)))?; + let logical_plan = from_substrait_plan(&handle.ctx.state(), &plan).await?; + Arc::new(logical_plan.schema().as_arrow().clone()) + }; let plan_schema = crate::schema_coerce::coerce_inferred_schema(plan_schema); let empty_exec = EmptyExec::new(Arc::clone(&plan_schema)); let df_stream = empty_exec.execute(0, handle.ctx.task_ctx())?; let (cross_rt_stream, abort_handle, _task_done) = - CrossRtStream::new_with_df_error_stream_cancellable(df_stream, cpu_executor); + CrossRtStream::new_with_df_error_stream_cancellable( + df_stream, + cpu_executor.clone(), + None, + ); if let Some(h) = abort_handle { crate::query_tracker::set_abort_handle(context_id_early, h); } + if let Some(rt) = cpu_executor.handle() { + crate::query_tracker::set_cpu_runtime_handle(context_id_early, rt); + } let wrapped = datafusion::physical_plan::stream::RecordBatchStreamAdapter::new( cross_rt_stream.schema(), cross_rt_stream, @@ -763,7 +884,10 @@ async unsafe fn execute_indexed_with_context_inner( } // Java-side QTF signal: scan must emit __row_id__. Captured before consuming indexed_config below. - let requests_row_ids = handle.indexed_config.as_ref().is_some_and(|c| c.requests_row_ids); + let requests_row_ids = handle + .indexed_config + .as_ref() + .is_some_and(|c| c.requests_row_ids); let classification_override = handle.indexed_config.map(|config| { // FilterTreeShape: 1 = CONJUNCTIVE → SingleCollector, 2 = INTERLEAVED → Tree. match (config.tree_shape, config.delegated_predicate_count) { @@ -804,10 +928,7 @@ async unsafe fn execute_indexed_with_context_inner( // with IndexedTableProvider after plan decoding. ctx.deregister_table(®ister_name)?; - let store = ctx - .state() - .runtime_env() - .object_store(&table_path)?; + let store = ctx.state().runtime_env().object_store(&table_path)?; let state = ctx.state(); let metadata_cache = state.runtime_env().cache_manager.get_file_metadata_cache(); @@ -824,7 +945,12 @@ async unsafe fn execute_indexed_with_context_inner( .map_err(DataFusionError::Execution)?; let schema = crate::schema_coerce::coerce_inferred_schema(schema); // Widen to the plan's base_schema so columns absent from this shard's parquet (cross-shard drift) are null-filled at read time. - let schema = crate::session_context::widen_schema_from_plan(&ctx, &substrait_bytes, ®ister_name, &schema); + let schema = crate::session_context::widen_schema_from_plan( + &ctx, + &substrait_bytes, + ®ister_name, + &schema, + ); let placeholder: Arc = Arc::new(PlaceholderProvider { schema: schema.clone(), @@ -845,7 +971,11 @@ async unsafe fn execute_indexed_with_context_inner( // interpretable by `api::fetch_by_row_ids` (which builds its own segments from // `ShardView.object_metas` in catalog order). let mut segments = segments; - if should_reverse_segments(analyze_top_sort(&logical_plan).as_ref(), &sort_fields, &sort_orders) { + if should_reverse_segments( + analyze_top_sort(&logical_plan).as_ref(), + &sort_fields, + &sort_orders, + ) { log_debug!( "indexed_executor: reversing segment iteration (catalog leading sort={:?} {:?}, query opposite)", sort_fields.first(), @@ -896,32 +1026,51 @@ async unsafe fn execute_indexed_with_context_inner( FilterClass::None => { // Predicate-only: push the whole tree (may be an unfoldable constant); // None = no filter = full scan. - extraction.as_ref().and_then(|e| { - residual_bool_to_physical_expr(&e.tree) - }) + extraction + .as_ref() + .and_then(|e| residual_bool_to_physical_expr(&e.tree)) } FilterClass::Tree => None, }; - let prune_tree_config = extraction.as_ref().and_then(|e| { - let mut leaf_exprs: Vec> = Vec::new(); - collect_predicate_exprs(&e.tree, &mut leaf_exprs); - let leaf_predicates: HashMap> = leaf_exprs - .iter() - .filter_map(|expr| { - build_pruning_predicate(expr, schema.clone()) - .map(|pp| (Arc::as_ptr(expr) as *const () as usize, pp)) - }) - .collect(); - if leaf_predicates.is_empty() { - return None; + let leaf_exprs = collect_leaf_exprs(extraction.as_ref()); + + let predicate_columns = collect_predicate_column_indices_from_exprs(&leaf_exprs); + + // Augment each segment's footer-only metadata with a scoped page index so + // the indexed PagePruner can page-prune. Both predicate (→ ColumnIndex) and + // projection (→ OffsetIndex) are wired — a match()-only query still needs a + // scoped OffsetIndex so the reader fetches only matched pages. + if page_index::is_scoped_page_index_enabled() { + let predicate_column_names = collect_predicate_column_names(extraction.as_ref(), &schema); + let projection_column_names = collect_plan_column_names(&logical_plan); + if !predicate_column_names.is_empty() || !projection_column_names.is_empty() { + for segment in segments.iter_mut() { + let (parquet_cols, offset_cols) = resolve_predicate_parquet_columns_pair( + &schema, + &segment.metadata, + &predicate_column_names, + &projection_column_names, + ); + if parquet_cols.is_empty() && offset_cols.is_empty() { + continue; + } + if let Some(augmented) = load_scoped_page_index_cols( + &store, + &segment.object_path, + &segment.metadata, + &parquet_cols, + &offset_cols, + ) + .await + { + segment.metadata = augmented; + } + } } - Some((e.tree.clone(), Arc::new(leaf_predicates), schema.clone())) - }); - - let predicate_columns = collect_predicate_column_indices(extraction.as_ref()); + } - let factory: EvaluatorFactory = match classification { + let (factory, prune_tree_config): (EvaluatorFactory, _) = match classification { FilterClass::None => { // Predicate-only scan: page-pruned universe, residual applied in // on_batch_mask. Also covers an unfoldable constant (e.g. mktime('...') > @@ -929,29 +1078,45 @@ async unsafe fn execute_indexed_with_context_inner( // residual (pushdown is Exact, so DataFusion drops the FilterExec). // Previously errored here when emit_row_ids was false (indexed path only). let schema_for_pruner = schema.clone(); - let residual_expr: Option> = extraction.as_ref().and_then(|e| { - residual_bool_to_physical_expr(&e.tree) - }); + let prune_tree_config = extraction + .as_ref() + .and_then(|e| build_prune_tree_config(&e.tree, &schema_for_pruner, &leaf_exprs)); + let residual_expr: Option> = extraction + .as_ref() + .and_then(|e| residual_bool_to_physical_expr(&e.tree)); let residual_pruning_predicate: Option> = residual_expr .as_ref() .and_then(|expr| build_pruning_predicate(expr, Arc::clone(&schema_for_pruner))); - Arc::new( - move |segment: &SegmentFileInfo, _chunk, stream_metrics: &StreamMetrics, stats_prune_tree: Option<&StatsPruneTree>| { - let pruner = Arc::new(PagePruner::new( - &schema_for_pruner, - Arc::clone(&segment.metadata), - )); - let eval: Arc = + ( + Arc::new( + move |segment: &SegmentFileInfo, + chunk, + stream_metrics: &StreamMetrics, + stats_prune_tree: Option<&Arc>| { + let pruner = Arc::new(PagePruner::new( + &schema_for_pruner, + Arc::clone(&segment.metadata), + )); + let rg_index_to_pos: HashMap = chunk + .row_group_indices + .iter() + .enumerate() + .map(|(pos, &idx)| (idx, pos)) + .collect(); + let eval: Arc = Arc::new(crate::indexed_table::eval::predicate_evaluator::PredicateOnlyEvaluator::new( pruner, residual_pruning_predicate.clone(), residual_expr.clone(), Some(PagePruneMetrics::from_stream_metrics(stream_metrics)), stats_prune_tree.cloned(), + rg_index_to_pos, )); - Ok(eval) - }, + Ok(eval) + }, + ), + prune_tree_config, ) } FilterClass::SingleCollector => { @@ -961,6 +1126,8 @@ async unsafe fn execute_indexed_with_context_inner( ) })?; let schema_for_pruner = schema.clone(); + let prune_tree_config = + build_prune_tree_config(&extraction.tree, &schema_for_pruner, &leaf_exprs); // Correctness-delegated provider (eager). `None` when the query has only // performance-delegated leaves and no Collector at all. @@ -1009,15 +1176,19 @@ async unsafe fn execute_indexed_with_context_inner( .as_ref() .and_then(|expr| build_pruning_predicate(expr, Arc::clone(&schema_for_pruner))); - let call_strategy = query_config.single_collector_strategy; + let call_strategy = CollectorCallStrategy::PageRangeSplit; let bloom_store = Arc::clone(&store); let bloom_schema = schema.clone(); - let bloom_on_read = query_config.bloom_filter_on_read; - Arc::new( - move |segment: &SegmentFileInfo, chunk, stream_metrics: &StreamMetrics, stats_prune_tree: Option<&StatsPruneTree>| { - let collector_opt: Option> = match &correctness_provider { - Some(provider) => { - let collector = FfmSegmentCollector::create( + ( + Arc::new( + move |segment: &SegmentFileInfo, + chunk, + stream_metrics: &StreamMetrics, + stats_prune_tree: Option<&Arc>| { + let collector_opt: Option> = + match &correctness_provider { + Some(provider) => { + let collector = FfmSegmentCollector::create( context_id, provider.key(), segment.writer_generation, @@ -1035,28 +1206,28 @@ async unsafe fn execute_indexed_with_context_inner( e ) })?; - Some(Arc::new(collector) as Arc) - } - None => None, - }; - let pruner = Arc::new(PagePruner::new( - &schema_for_pruner, - Arc::clone(&segment.metadata), - )); - let bloom_config = if bloom_on_read { - Some(crate::indexed_table::eval::single_collector::BloomConfig { - store: Arc::clone(&bloom_store), - object_path: segment.object_path.clone(), - metadata: Arc::clone(&segment.metadata), - arrow_schema: Arc::clone(&bloom_schema), - io_handle: io_handle.clone(), - rg_bloom_pruned: stream_metrics.rg_bloom_pruned.clone(), - bloom_filter_eval_time: stream_metrics.bloom_filter_eval_time.clone(), - }) - } else { - None - }; - let eval: Arc = + Some(Arc::new(collector) as Arc) + } + None => None, + }; + let pruner = Arc::new(PagePruner::new( + &schema_for_pruner, + Arc::clone(&segment.metadata), + )); + // Bloom-filter row-group pruning is always enabled on the indexed read path. + let bloom_config = + Some(crate::indexed_table::eval::single_collector::BloomConfig { + store: Arc::clone(&bloom_store), + object_path: segment.object_path.clone(), + metadata: Arc::clone(&segment.metadata), + arrow_schema: Arc::clone(&bloom_schema), + io_handle: io_handle.clone(), + rg_bloom_pruned: stream_metrics.rg_bloom_pruned.clone(), + bloom_filter_eval_time: stream_metrics + .bloom_filter_eval_time + .clone(), + }); + let eval: Arc = Arc::new(SingleCollectorEvaluator::new( collector_opt, pruner, @@ -1071,9 +1242,12 @@ async unsafe fn execute_indexed_with_context_inner( context_id, bloom_config, stats_prune_tree.cloned(), + chunk.row_group_indices.iter().enumerate().map(|(pos, &idx)| (idx, pos)).collect(), )); - Ok(eval) - }, + Ok(eval) + }, + ), + prune_tree_config, ) } FilterClass::Tree => { @@ -1086,7 +1260,10 @@ async unsafe fn execute_indexed_with_context_inner( // same-kind connectives. Flatten after push_not_down so the // connective changes from De Morgan (e.g. NOT(AND(...)) -> OR(NOT...)) // get absorbed into the surrounding Or if applicable. - let tree = extraction.tree.push_not_down().flatten(); + let tree = Arc::try_unwrap(extraction.tree) + .unwrap() + .push_not_down() + .flatten(); // One provider per Collector leaf (DFS order). let leaf_ids = tree.collector_leaves(); let mut providers: Vec> = Vec::with_capacity(leaf_ids.len()); @@ -1100,8 +1277,8 @@ async unsafe fn execute_indexed_with_context_inner( let schema_for_pruner = schema.clone(); let cost_predicate = query_config.cost_predicate; let cost_collector = query_config.cost_collector; - let max_collector_parallelism = query_config.max_collector_parallelism; - let collector_strategy = query_config.tree_collector_strategy; + let max_collector_parallelism = 1; + let collector_strategy = CollectorCallStrategy::TightenOuterBounds; // Build one `PruningPredicate` per unique `Predicate` leaf // in the tree. Key = `Arc::as_ptr(expr) as usize` — the @@ -1122,55 +1299,83 @@ async unsafe fn execute_indexed_with_context_inner( .collect(), ); - Arc::new( - move |segment: &SegmentFileInfo, chunk, stream_metrics: &StreamMetrics, stats_prune_tree: Option<&StatsPruneTree>| { - // Build one collector per Collector leaf for this chunk. - let mut per_leaf: Vec<(i32, Arc)> = - Vec::with_capacity(providers.len()); - for (idx, provider) in providers.iter().enumerate() { - let collector = FfmSegmentCollector::create( - context_id, - provider.key(), - segment.writer_generation, - chunk.doc_min, - chunk.doc_max, - ) + // Build prune_tree_config from the normalized tree. This ensures + // StatsPruneTree children indices align with ResolvedNode children + // (same push_not_down + flatten normalization applied above). + let prune_tree_config = if pruning_predicates.is_empty() { + None + } else { + Some(( + Arc::clone(&tree), + Arc::clone(&pruning_predicates), + schema_for_pruner.clone(), + )) + }; + + ( + Arc::new( + move |segment: &SegmentFileInfo, + chunk, + stream_metrics: &StreamMetrics, + stats_prune_tree: Option<&Arc>| { + // Build one collector per Collector leaf for this chunk. + let mut per_leaf: Vec<(i32, Arc)> = + Vec::with_capacity(providers.len()); + for (idx, provider) in providers.iter().enumerate() { + let collector = FfmSegmentCollector::create( + context_id, + provider.key(), + segment.writer_generation, + chunk.doc_min, + chunk.doc_max, + ) .map_err(|e| format!("leaf {} collector: {}", idx, e))?; - per_leaf.push(( - provider.key(), - Arc::new(collector) as Arc, + per_leaf.push(( + provider.key(), + Arc::new(collector) as Arc, + )); + } + + let resolved = tree.resolve(&per_leaf).map_err(|e| { + format!( + "tree.resolve for segment gen={}: {}", + segment.writer_generation, e + ) + })?; + let resolved = Arc::new(resolved); + + let pruner = Arc::new(PagePruner::new( + &schema_for_pruner, + Arc::clone(&segment.metadata), )); - } - - let resolved = tree.resolve(&per_leaf).map_err(|e| { - format!("tree.resolve for segment gen={}: {}", segment.writer_generation, e) - })?; - let resolved = Arc::new(resolved); - - let pruner = Arc::new(PagePruner::new( - &schema_for_pruner, - Arc::clone(&segment.metadata), - )); - - let eval: Arc = Arc::new(TreeBitsetSource { - tree: resolved, - evaluator: Arc::new(BitmapTreeEvaluator), - leaves: Arc::new(CollectorLeafBitmaps { - ffm_collector_calls: stream_metrics.ffm_collector_calls.clone(), - }), - page_pruner: pruner, - cost_predicate, - cost_collector, - max_collector_parallelism, - pruning_predicates: Arc::clone(&pruning_predicates), - page_prune_metrics: Some(PagePruneMetrics::from_stream_metrics( - stream_metrics, - )), - collector_strategy, - stats_prune_tree: stats_prune_tree.cloned(), - }); - Ok(eval) - }, + + let eval: Arc = Arc::new(TreeBitsetSource { + tree: resolved, + evaluator: Arc::new(BitmapTreeEvaluator), + leaves: Arc::new(CollectorLeafBitmaps { + ffm_collector_calls: stream_metrics.ffm_collector_calls.clone(), + }), + page_pruner: pruner, + cost_predicate, + cost_collector, + max_collector_parallelism, + pruning_predicates: Arc::clone(&pruning_predicates), + page_prune_metrics: Some(PagePruneMetrics::from_stream_metrics( + stream_metrics, + )), + collector_strategy, + stats_prune_tree: stats_prune_tree.cloned(), + rg_index_to_pos: chunk + .row_group_indices + .iter() + .enumerate() + .map(|(pos, &idx)| (idx, pos)) + .collect(), + }); + Ok(eval) + }, + ), + prune_tree_config, ) } }; @@ -1198,11 +1403,15 @@ async unsafe fn execute_indexed_with_context_inner( prune_tree_config, sort_fields: sort_fields.clone(), sort_orders: sort_orders.clone(), + cancellation_token: crate::query_tracker::get_cancellation_token(context_id), })); ctx.register_table(®ister_name, provider)?; let logical_plan = from_substrait_plan(&ctx.state(), &plan).await?; - log_debug!("DataFusion logical plan:\n{}", logical_plan.display_indent()); + log_debug!( + "DataFusion logical plan:\n{}", + logical_plan.display_indent() + ); let dataframe = ctx.execute_logical_plan(logical_plan).await?; let physical_plan = dataframe.create_physical_plan().await?; // Retag bit-compatible Int↔UInt output mismatches to match the substrait-declared @@ -1212,25 +1421,37 @@ async unsafe fn execute_indexed_with_context_inner( // Apply aggregate mode stripping when prepare_partial_plan was called (engine-native-merge). // This makes the indexed executor produce Binary HLL state (Partial) instead of Int64 (Final). let physical_plan = if aggregate_mode != crate::agg_mode::Mode::Default { - crate::agg_mode::apply_aggregate_mode(physical_plan, aggregate_mode)? + crate::agg_mode::apply_aggregate_mode(physical_plan, aggregate_mode, handle.has_topk)? } else { physical_plan }; let target_schema = crate::schema_coerce::coerce_inferred_schema(physical_plan.schema()); let physical_plan = crate::relabel_exec::wrap_if_relabel_needed(physical_plan, target_schema)?; - log_debug!("DataFusion physical plan:\n{}", displayable(physical_plan.as_ref()).indent(true)); + log_debug!( + "DataFusion physical plan:\n{}", + displayable(physical_plan.as_ref()).indent(true) + ); let df_stream = execute_stream(physical_plan.clone(), ctx.task_ctx()) .map_err(|e| DataFusionError::Execution(format!("execute_stream: {}", e)))?; let (cross_rt_stream, abort_handle, _task_done) = - CrossRtStream::new_with_df_error_stream_cancellable(df_stream, cpu_executor); + CrossRtStream::new_with_df_error_stream_cancellable(df_stream, cpu_executor.clone(), None); if let Some(h) = abort_handle { crate::query_tracker::set_abort_handle(context_id, h); } + if let Some(rt) = cpu_executor.handle() { + crate::query_tracker::set_cpu_runtime_handle(context_id, rt); + } let schema = cross_rt_stream.schema(); let wrapped = RecordBatchStreamAdapter::new(schema, cross_rt_stream); - let stream_handle = crate::api::QueryStreamHandle::with_physical_plan(wrapped, query_context, ctx, Some(permit), physical_plan); + let stream_handle = crate::api::QueryStreamHandle::with_physical_plan( + wrapped, + query_context, + ctx, + Some(permit), + physical_plan, + ); Ok(Box::into_raw(Box::new(stream_handle)) as i64) } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/bloom_pruner.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/bloom_pruner.rs index 1f36b505f7e52..1175d3ef93a80 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/bloom_pruner.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/bloom_pruner.rs @@ -42,9 +42,7 @@ impl BloomFilterStatistics { /// definitely absent. fn check_scalar(sbbf: &Sbbf, value: &ScalarValue) -> bool { match value { - ScalarValue::Utf8(Some(s)) | ScalarValue::LargeUtf8(Some(s)) => { - sbbf.check(s.as_str()) - } + ScalarValue::Utf8(Some(s)) | ScalarValue::LargeUtf8(Some(s)) => sbbf.check(s.as_str()), ScalarValue::Binary(Some(b)) | ScalarValue::LargeBinary(Some(b)) => { sbbf.check(b.as_slice()) } @@ -94,11 +92,7 @@ impl PruningStatistics for BloomFilterStatistics { fn row_counts(&self) -> Option { None } - fn contained( - &self, - column: &Column, - values: &HashSet, - ) -> Option { + fn contained(&self, column: &Column, values: &HashSet) -> Option { let sbbf = self.blooms.get(column.name())?; // For the single-container case (1 row group), we return a // single-element BooleanArray. `true` means the bloom filter says @@ -185,12 +179,10 @@ async fn bloom_prune_rg_inner( // Read the bloom filter bytes from the object store. let opts = GetOptions { - range: Some(GetRange::Bounded( - std::ops::Range { - start: bf_offset, - end: bf_offset + bf_length, - }, - )), + range: Some(GetRange::Bounded(std::ops::Range { + start: bf_offset, + end: bf_offset + bf_length, + })), ..Default::default() }; @@ -206,9 +198,7 @@ async fn bloom_prune_rg_inner( return Ok(false); // no bloom filters available → cannot prune } - let stats = BloomFilterStatistics { - blooms, - }; + let stats = BloomFilterStatistics { blooms }; // Call the pruning predicate with our bloom-filter-backed statistics. // Result is a Vec with one entry per container (we have 1 container). diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/bool_tree.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/bool_tree.rs index 9f59f377f2636..5ff047c8de8d8 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/bool_tree.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/bool_tree.rs @@ -109,9 +109,10 @@ impl BoolNode { /// yet (Phase 7 fast-follow). pub fn delegation_possible_leaf_count(&self) -> usize { match self { - BoolNode::And(children) | BoolNode::Or(children) => { - children.iter().map(|c| c.delegation_possible_leaf_count()).sum() - } + BoolNode::And(children) | BoolNode::Or(children) => children + .iter() + .map(|c| c.delegation_possible_leaf_count()) + .sum(), BoolNode::Not(child) => child.delegation_possible_leaf_count(), BoolNode::DelegationPossible { .. } => 1, BoolNode::Collector { .. } => 0, @@ -195,14 +196,22 @@ impl BoolNode { /// the Tree-path evaluator. pub fn demote_delegation_possible(self) -> BoolNode { match self { - BoolNode::And(children) => { - BoolNode::And(children.into_iter().map(|c| c.demote_delegation_possible()).collect()) - } - BoolNode::Or(children) => { - BoolNode::Or(children.into_iter().map(|c| c.demote_delegation_possible()).collect()) - } + BoolNode::And(children) => BoolNode::And( + children + .into_iter() + .map(|c| c.demote_delegation_possible()) + .collect(), + ), + BoolNode::Or(children) => BoolNode::Or( + children + .into_iter() + .map(|c| c.demote_delegation_possible()) + .collect(), + ), BoolNode::Not(child) => BoolNode::Not(Box::new(child.demote_delegation_possible())), - BoolNode::DelegationPossible { original_expr, .. } => BoolNode::Predicate(original_expr), + BoolNode::DelegationPossible { original_expr, .. } => { + BoolNode::Predicate(original_expr) + } leaf @ (BoolNode::Collector { .. } | BoolNode::Predicate(_)) => leaf, } } @@ -470,9 +479,7 @@ mod tests { } fn collector(id: i32) -> BoolNode { - BoolNode::Collector { - annotation_id: id, - } + BoolNode::Collector { annotation_id: id } } fn predicate(col: &str, op: Operator, v: i32) -> BoolNode { @@ -519,10 +526,7 @@ mod tests { #[test] fn de_morgan_not_and_to_or() { - let tree = BoolNode::Not(Box::new(BoolNode::And(vec![ - collector(0), - collector(1), - ]))); + let tree = BoolNode::Not(Box::new(BoolNode::And(vec![collector(0), collector(1)]))); match tree.push_not_down() { BoolNode::Or(children) => { assert_eq!(children.len(), 2); @@ -772,7 +776,11 @@ mod tests { BoolNode::Or(children) => { assert_eq!(children.len(), 2); for c in &children { - assert!(matches!(c, BoolNode::Predicate(_)), "expected Predicate, got {:?}", c); + assert!( + matches!(c, BoolNode::Predicate(_)), + "expected Predicate, got {:?}", + c + ); } } other => panic!("expected Or with two Predicates, got {:?}", other), @@ -807,7 +815,10 @@ mod tests { match demoted { BoolNode::Or(children) => { assert_eq!(children.len(), 3); - assert!(matches!(&children[0], BoolNode::Collector { annotation_id: 7 })); + assert!(matches!( + &children[0], + BoolNode::Collector { annotation_id: 7 } + )); assert!(matches!(&children[1], BoolNode::Predicate(_))); assert!(matches!(&children[2], BoolNode::Predicate(_))); } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/dynamic_filter.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/dynamic_filter.rs index 2a1912e0bf89c..427ddd9060bde 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/dynamic_filter.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/dynamic_filter.rs @@ -49,7 +49,10 @@ struct SingleRowGroupStatistics<'a> { } impl<'a> SingleRowGroupStatistics<'a> { - fn converter<'b>(&'a self, column: &'b Column) -> datafusion::common::Result> { + fn converter<'b>( + &'a self, + column: &'b Column, + ) -> datafusion::common::Result> { Ok(StatisticsConverter::try_new( &column.name, self.arrow_schema, @@ -121,10 +124,7 @@ pub struct DynamicRgPruner { impl DynamicRgPruner { /// Build a pruner over `filter`. Returns `None` if `filter` is `None` /// (no dynamic filter was accepted), so the hot path can skip all work. - pub fn new( - filter: Option>, - full_schema: SchemaRef, - ) -> Option { + pub fn new(filter: Option>, full_schema: SchemaRef) -> Option { filter.map(|filter| Self { filter, full_schema, @@ -204,10 +204,19 @@ impl RgPruningContext { let Some(rg_meta) = metadata.row_groups().get(rg_idx) else { return false; }; + // Resolve stats against the segment's OWN parquet schema, not the full table + // schema: StatisticsConverter maps name→parquet-column positionally, so the full + // schema reads the wrong/no column under dynamic-mapping schema drift. + let descr = metadata.file_metadata().schema_descr(); + let seg_schema = datafusion::parquet::arrow::parquet_to_arrow_schema( + descr, + metadata.file_metadata().key_value_metadata(), + ) + .unwrap_or_else(|_| self.schema.as_ref().clone()); let stats = SingleRowGroupStatistics { - parquet_schema: metadata.file_metadata().schema_descr(), + parquet_schema: descr, rg_meta, - arrow_schema: self.schema.as_ref(), + arrow_schema: &seg_schema, }; // `prune` returns one bool per container (we have exactly one). `false` // means "provably cannot match" → safe to skip. Any error => keep. @@ -217,3 +226,68 @@ impl RgPruningContext { } } } + +#[cfg(test)] +mod tests { + use super::*; + use datafusion::arrow::array::{Int32Array, RecordBatch}; + use datafusion::arrow::datatypes::{DataType, Field}; + use datafusion::logical_expr::Operator; + use datafusion::parquet::arrow::arrow_reader::{ArrowReaderMetadata, ArrowReaderOptions}; + use datafusion::parquet::arrow::ArrowWriter; + use datafusion::physical_expr::expressions::{BinaryExpr, Column as PhysColumn, Literal}; + use tempfile::NamedTempFile; + + // Schema drift: the table schema orders columns differently from the segment's own + // parquet file. StatisticsConverter maps a column name to a parquet index positionally, + // so resolving `severity` against the table schema lands on a DIFFERENT real file column + // (here `neg`, all-negative). The always-true dynamic filter `severity >= 0` then reads + // neg's stats (max < 0) and wrongly prunes the whole RG. Resolving against the segment's + // own schema reads the real `severity` stats and keeps the RG. + #[test] + fn dynamic_rg_prune_resolves_stats_against_segment_schema_under_drift() { + // File order: name, neg, severity. + let file_schema = Arc::new(Schema::new(vec![ + Field::new("name", DataType::Int32, false), + Field::new("neg", DataType::Int32, false), + Field::new("severity", DataType::Int32, false), + ])); + // Table order puts `severity` at the position the file holds `neg`. + let table_schema = Arc::new(Schema::new(vec![ + Field::new("name", DataType::Int32, false), + Field::new("severity", DataType::Int32, false), + Field::new("neg", DataType::Int32, false), + ])); + let batch = RecordBatch::try_new( + file_schema.clone(), + vec![ + Arc::new(Int32Array::from(vec![1, 2, 3, 4])), + Arc::new(Int32Array::from(vec![-9, -8, -7, -6])), + Arc::new(Int32Array::from(vec![0, 5, 10, 17])), + ], + ) + .unwrap(); + let tmp = NamedTempFile::new().unwrap(); + let mut w = ArrowWriter::try_new(tmp.reopen().unwrap(), file_schema, None).unwrap(); + w.write(&batch).unwrap(); + w.close().unwrap(); + let md = ArrowReaderMetadata::load(&tmp.reopen().unwrap(), ArrowReaderOptions::new()) + .unwrap() + .metadata() + .clone(); + + let sev: Arc = Arc::new(PhysColumn::new("severity", 1)); + let zero: Arc = Arc::new(Literal::new(ScalarValue::Int32(Some(0)))); + let expr: Arc = Arc::new(BinaryExpr::new(sev, Operator::GtEq, zero)); + let predicate = Arc::new(PruningPredicate::try_new(expr, table_schema.clone()).unwrap()); + let ctx = RgPruningContext { + predicate, + schema: table_schema, + }; + + assert!( + !ctx.rg_provably_excluded(&md, 0), + "severity >= 0 is always true for this segment; it must not be pruned under schema drift" + ); + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/dynamic_filter_probe.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/dynamic_filter_probe.rs index c76459ff3ea9f..969591d289573 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/dynamic_filter_probe.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/dynamic_filter_probe.rs @@ -27,6 +27,7 @@ use datafusion::common::tree_node::{TreeNode, TreeNodeRecursion}; use datafusion::common::Result; use datafusion::execution::{SendableRecordBatchStream, TaskContext}; use datafusion::physical_expr::expressions::{col, DynamicFilterPhysicalExpr}; +use datafusion::physical_expr::PhysicalExpr; use datafusion::physical_expr::{EquivalenceProperties, LexOrdering, PhysicalSortExpr}; use datafusion::physical_optimizer::filter_pushdown::FilterPushdown; use datafusion::physical_optimizer::PhysicalOptimizerRule; @@ -36,9 +37,8 @@ use datafusion::physical_plan::filter_pushdown::{ }; use datafusion::physical_plan::sorts::sort::SortExec; use datafusion::physical_plan::{ - DisplayAs, DisplayFormatType, ExecutionPlan, PlanProperties, Partitioning, + DisplayAs, DisplayFormatType, ExecutionPlan, Partitioning, PlanProperties, }; -use datafusion::physical_expr::PhysicalExpr; /// What a leaf observed in `handle_child_pushdown_result`. Shared out-of-band /// (the optimizer clones/rebuilds nodes, so we can't read it back off the plan). @@ -68,7 +68,11 @@ impl RecordingLeaf { EmissionType::Incremental, Boundedness::Bounded, )); - Self { schema, props, observed } + Self { + schema, + props, + observed, + } } } @@ -137,7 +141,9 @@ impl ExecutionPlan for RecordingLeaf { obs.saw_dynamic |= is_dynamic; statuses.push(PushedDown::Yes); } - Ok(FilterPushdownPropagation::with_parent_pushdown_result(statuses)) + Ok(FilterPushdownPropagation::with_parent_pushdown_result( + statuses, + )) } } @@ -189,7 +195,11 @@ fn dynamic_filter_reaches_leaf_under_topk() { // `ts > ` value materializes only at runtime via snapshot()). // This is *why* acceptance keys on the referenced columns, not the printed // value: we must downcast + walk the children to find the sort column. - assert_eq!(obs.arrived.len(), 1, "expected exactly the TopK self-filter"); + assert_eq!( + obs.arrived.len(), + 1, + "expected exactly the TopK self-filter" + ); assert!( obs.arrived[0].contains("DynamicFilter"), "arrived filter should be the dynamic placeholder; got {:?}", diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/eval/bitmap_tree.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/eval/bitmap_tree.rs index ce241b62dbcc8..471d2d8790012 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/eval/bitmap_tree.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/eval/bitmap_tree.rs @@ -89,6 +89,7 @@ impl TreeEvaluator for BitmapTreeEvaluator { pruning_predicates: &HashMap>, page_prune_metrics: Option<&PagePruneMetrics>, stats_prune_tree: Option<&StatsPruneTree>, + rg_index_to_pos: &HashMap, ) -> Result { let mut per_leaf = Vec::new(); let mut dfs_counter = 0usize; @@ -106,6 +107,7 @@ impl TreeEvaluator for BitmapTreeEvaluator { &mut per_leaf, /* under_all_and_path */ true, stats_prune_tree, + rg_index_to_pos, )?; Ok(TreePrefetch { candidates, @@ -184,30 +186,34 @@ fn prefetch_node( out: &mut Vec<(usize, RoaringBitmap)>, under_all_and_path: bool, stats_prune_tree: Option<&StatsPruneTree>, + rg_index_to_pos: &HashMap, ) -> Result { // RG-level subtree pruning: if this subtree provably can't match // the current RG, skip the entire tree walk. Since collectors are // always-true in the resolution, a false here means a Predicate // under AND proved no match — collector bitmaps are irrelevant. if let Some(spt) = stats_prune_tree { - if let Some(&false) = spt.rg_can_match.get(ctx.rg_idx) { - native_bridge_common::log_debug!( - "BitmapTree: skipping subtree for RG {} — pruned by RG-level stats", - ctx.rg_idx - ); - if under_all_and_path { - skip_dfs(node, dfs); - } else { - skip_dfs_with_empty_bitmaps(node, dfs, out); + if let Some(&pos) = rg_index_to_pos.get(&ctx.rg_idx) { + if let Some(&false) = spt.rg_can_match.get(pos) { + native_bridge_common::log_debug!( + "BitmapTree: skipping subtree for RG {} — pruned by RG-level stats", + ctx.rg_idx + ); + if under_all_and_path { + skip_dfs(node, dfs); + } else { + skip_dfs_with_empty_bitmaps(node, dfs, out); + } + return Ok(RoaringBitmap::new()); } - return Ok(RoaringBitmap::new()); } } match node { ResolvedNode::And(children) => { let mut indices: Vec = (0..children.len()).collect(); - indices.sort_by_key(|&i| subtree_cost(&children[i], ctx, page_pruner, pruning_predicates)); + indices + .sort_by_key(|&i| subtree_cost(&children[i], ctx, page_pruner, pruning_predicates)); let mut result_bitmap: Option = None; let mut ranges: Option> = ctx.collector_call_ranges.clone(); @@ -231,6 +237,7 @@ fn prefetch_node( out, under_all_and_path, stats_prune_tree.and_then(|spt| spt.children.get(i)), + rg_index_to_pos, )?; result_bitmap = Some(match result_bitmap { None => child_bitmap, @@ -278,7 +285,8 @@ fn prefetch_node( let mut indices: Vec = (0..children.len()).collect(); // sort the children by cost to prune children better - indices.sort_by_key(|&i| subtree_cost(&children[i], ctx, page_pruner, pruning_predicates)); + indices + .sort_by_key(|&i| subtree_cost(&children[i], ctx, page_pruner, pruning_predicates)); let total_docs = (ctx.max_doc - ctx.min_doc) as u64; let mut result_bitmap = RoaringBitmap::new(); @@ -295,6 +303,7 @@ fn prefetch_node( // OR breaks all-AND propagation for its subtree. false, stats_prune_tree.and_then(|spt| spt.children.get(val)), + rg_index_to_pos, )?; result_bitmap |= &filtered_bitmap; @@ -329,6 +338,7 @@ fn prefetch_node( out, /* under_all_and_path */ false, stats_prune_tree.and_then(|spt| spt.children.first()), + rg_index_to_pos, )?; // Candidate-stage is a superset. Inverting a superset does // NOT yield a superset of the true NOT — it yields a subset @@ -566,14 +576,12 @@ fn ranges_from_bitmap(bm: &RoaringBitmap, ctx: &RgEvalContext) -> Vec<(i32, i32) use super::CollectorCallStrategy; match ctx.collector_strategy { CollectorCallStrategy::FullRange => vec![(ctx.min_doc, ctx.max_doc)], - CollectorCallStrategy::TightenOuterBounds => { - match (bm.min(), bm.max()) { - (Some(lo), Some(hi)) => { - vec![(ctx.min_doc + lo as i32, ctx.min_doc + hi as i32 + 1)] - } - _ => vec![(ctx.min_doc, ctx.max_doc)], + CollectorCallStrategy::TightenOuterBounds => match (bm.min(), bm.max()) { + (Some(lo), Some(hi)) => { + vec![(ctx.min_doc + lo as i32, ctx.min_doc + hi as i32 + 1)] } - } + _ => vec![(ctx.min_doc, ctx.max_doc)], + }, CollectorCallStrategy::PageRangeSplit => { // Extract contiguous runs of set bits as absolute doc ranges. let mut ranges = Vec::new(); @@ -1210,7 +1218,16 @@ mod tests { }; let pruner = empty_pruner(); let result = BitmapTreeEvaluator - .prefetch(&tree, &test_ctx(), &leaves, &pruner, &HashMap::new(), None, None) + .prefetch( + &tree, + &test_ctx(), + &leaves, + &pruner, + &HashMap::new(), + None, + None, + &HashMap::new(), + ) .unwrap(); assert_eq!(result.candidates, bm(&[3, 4])); assert_eq!(result.per_leaf.len(), 2); @@ -1224,7 +1241,16 @@ mod tests { }; let pruner = empty_pruner(); let result = BitmapTreeEvaluator - .prefetch(&tree, &test_ctx(), &leaves, &pruner, &HashMap::new(), None, None) + .prefetch( + &tree, + &test_ctx(), + &leaves, + &pruner, + &HashMap::new(), + None, + None, + &HashMap::new(), + ) .unwrap(); assert_eq!(result.candidates, bm(&[1, 2, 3])); } @@ -1237,7 +1263,16 @@ mod tests { }; let pruner = empty_pruner(); let result = BitmapTreeEvaluator - .prefetch(&tree, &test_ctx(), &leaves, &pruner, &HashMap::new(), None, None) + .prefetch( + &tree, + &test_ctx(), + &leaves, + &pruner, + &HashMap::new(), + None, + None, + &HashMap::new(), + ) .unwrap(); // Universe is [0, 16). Minus {0,1,2} = {3..15} let expected: RoaringBitmap = (3u32..16).collect(); @@ -1252,7 +1287,16 @@ mod tests { }; let pruner = empty_pruner(); let state = BitmapTreeEvaluator - .prefetch(&tree, &test_ctx(), &leaves, &pruner, &HashMap::new(), None, None) + .prefetch( + &tree, + &test_ctx(), + &leaves, + &pruner, + &HashMap::new(), + None, + None, + &HashMap::new(), + ) .unwrap(); let schema = Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])); @@ -1512,7 +1556,16 @@ mod tests { let pruner = empty_pruner(); let result = BitmapTreeEvaluator - .prefetch(&tree, &test_ctx(), &leaves, &pruner, &HashMap::new(), None, None) + .prefetch( + &tree, + &test_ctx(), + &leaves, + &pruner, + &HashMap::new(), + None, + None, + &HashMap::new(), + ) .unwrap(); assert!(result.candidates.is_empty()); } @@ -1552,7 +1605,16 @@ mod tests { let pruner = empty_pruner(); let result = BitmapTreeEvaluator - .prefetch(&tree, &test_ctx(), &leaves, &pruner, &HashMap::new(), None, None) + .prefetch( + &tree, + &test_ctx(), + &leaves, + &pruner, + &HashMap::new(), + None, + None, + &HashMap::new(), + ) .unwrap(); // OR contributes {5} from standalone_leaf → non-empty candidates. assert!(!result.candidates.is_empty()); @@ -1590,7 +1652,16 @@ mod tests { let pruner = empty_pruner(); let result = BitmapTreeEvaluator - .prefetch(&tree, &test_ctx(), &leaves, &pruner, &HashMap::new(), None, None) + .prefetch( + &tree, + &test_ctx(), + &leaves, + &pruner, + &HashMap::new(), + None, + None, + &HashMap::new(), + ) .unwrap(); // NOT inverts empty AND → universe. assert_eq!(result.candidates.len(), 16); @@ -1622,7 +1693,8 @@ mod tests { ctx.cost_predicate * COST_SCALE ); assert_eq!( - subtree_cost(&collector_leaf(0), &ctx, &pruner, &pp), ctx.cost_collector * COST_SCALE + subtree_cost(&collector_leaf(0), &ctx, &pruner, &pp), + ctx.cost_collector * COST_SCALE ); } @@ -1633,7 +1705,8 @@ mod tests { let pp = HashMap::new(); let wrapped = ResolvedNode::Not(Box::new(test_predicate_node())); assert_eq!( - subtree_cost(&wrapped, &ctx, &pruner, &pp), ctx.cost_predicate * COST_SCALE + subtree_cost(&wrapped, &ctx, &pruner, &pp), + ctx.cost_predicate * COST_SCALE ); } @@ -1665,7 +1738,8 @@ mod tests { let pruner = empty_pruner(); let pp = HashMap::new(); assert!( - subtree_cost(&nested, &ctx, &pruner, &pp) < subtree_cost(&single_collector, &ctx, &pruner, &pp), + subtree_cost(&nested, &ctx, &pruner, &pp) + < subtree_cost(&single_collector, &ctx, &pruner, &pp), ); } @@ -1676,7 +1750,10 @@ mod tests { let ctx = test_ctx(); let pruner = empty_pruner(); let pp = HashMap::new(); - assert!(subtree_cost(&nested, &ctx, &pruner, &pp) > subtree_cost(&single_collector, &ctx, &pruner, &pp)); + assert!( + subtree_cost(&nested, &ctx, &pruner, &pp) + > subtree_cost(&single_collector, &ctx, &pruner, &pp) + ); } // ── intersect_range_lists unit tests ──────────────────────────── @@ -1763,10 +1840,10 @@ mod tests { let mut ctx = test_ctx(); ctx.collector_strategy = super::super::CollectorCallStrategy::PageRangeSplit; let mut bm = RoaringBitmap::new(); - bm.insert_range(2..5); // bits 2,3,4 + bm.insert_range(2..5); // bits 2,3,4 bm.insert_range(8..11); // bits 8,9,10 - bm.insert(14); // bit 14 - // Three contiguous runs → three ranges + bm.insert(14); // bit 14 + // Three contiguous runs → three ranges assert_eq!( ranges_from_bitmap(&bm, &ctx), vec![(2, 5), (8, 11), (14, 15)] @@ -1790,9 +1867,7 @@ mod tests { } } - fn prune_tree_and( - children: Vec, - ) -> StatsPruneTree { + fn prune_tree_and(children: Vec) -> StatsPruneTree { let mut rg_can_match = vec![true; children[0].rg_can_match.len()]; for c in &children { for (r, v) in rg_can_match.iter_mut().zip(c.rg_can_match.iter()) { @@ -1805,9 +1880,7 @@ mod tests { } } - fn prune_tree_or( - children: Vec, - ) -> StatsPruneTree { + fn prune_tree_or(children: Vec) -> StatsPruneTree { let mut rg_can_match = vec![false; children[0].rg_can_match.len()]; for c in &children { for (r, v) in rg_can_match.iter_mut().zip(c.rg_can_match.iter()) { @@ -1832,7 +1905,16 @@ mod tests { prune_tree_leaf(vec![true]), ]); let result = BitmapTreeEvaluator - .prefetch(&tree, &test_ctx(), &leaves, &pruner, &HashMap::new(), None, Some(&spt)) + .prefetch( + &tree, + &test_ctx(), + &leaves, + &pruner, + &HashMap::new(), + None, + Some(&spt), + &HashMap::from([(0, 0)]), + ) .unwrap(); assert!(result.candidates.is_empty()); } @@ -1849,7 +1931,16 @@ mod tests { prune_tree_leaf(vec![true]), ]); let result = BitmapTreeEvaluator - .prefetch(&tree, &test_ctx(), &leaves, &pruner, &HashMap::new(), None, Some(&spt)) + .prefetch( + &tree, + &test_ctx(), + &leaves, + &pruner, + &HashMap::new(), + None, + Some(&spt), + &HashMap::from([(0, 0)]), + ) .unwrap(); assert_eq!(result.candidates, bm(&[3, 4])); } @@ -1866,7 +1957,16 @@ mod tests { prune_tree_leaf(vec![false]), ]); let result = BitmapTreeEvaluator - .prefetch(&tree, &test_ctx(), &leaves, &pruner, &HashMap::new(), None, Some(&spt)) + .prefetch( + &tree, + &test_ctx(), + &leaves, + &pruner, + &HashMap::new(), + None, + Some(&spt), + &HashMap::from([(0, 0)]), + ) .unwrap(); assert!(result.candidates.is_empty()); } @@ -1890,7 +1990,16 @@ mod tests { ]); let spt = prune_tree_and(vec![or_spt, prune_tree_leaf(vec![true])]); let result = BitmapTreeEvaluator - .prefetch(&tree, &test_ctx(), &leaves, &pruner, &HashMap::new(), None, Some(&spt)) + .prefetch( + &tree, + &test_ctx(), + &leaves, + &pruner, + &HashMap::new(), + None, + Some(&spt), + &HashMap::from([(0, 0)]), + ) .unwrap(); // collector2 (dfs=0) → {3,4,5}; OR-child1 (dfs=2) → {3,4,5,6}; OR = {3,4,5,6} // AND = {3,4,5} ∩ {3,4,5,6} = {3,4,5} @@ -1913,7 +2022,16 @@ mod tests { ]); let spt = prune_tree_and(vec![or_spt, prune_tree_leaf(vec![true])]); let result = BitmapTreeEvaluator - .prefetch(&tree, &test_ctx(), &leaves, &pruner, &HashMap::new(), None, Some(&spt)) + .prefetch( + &tree, + &test_ctx(), + &leaves, + &pruner, + &HashMap::new(), + None, + Some(&spt), + &HashMap::from([(0, 0)]), + ) .unwrap(); assert!(result.candidates.is_empty()); } @@ -1926,8 +2044,276 @@ mod tests { }; let pruner = empty_pruner(); let result = BitmapTreeEvaluator - .prefetch(&tree, &test_ctx(), &leaves, &pruner, &HashMap::new(), None, None) + .prefetch( + &tree, + &test_ctx(), + &leaves, + &pruner, + &HashMap::new(), + None, + None, + &HashMap::new(), + ) .unwrap(); assert_eq!(result.candidates, bm(&[3, 4])); } + + /// When `rg_idx` is an absolute index not present in the reverse map, + /// the subtree must NOT be pruned (conservative: can-match). This + /// exercises the offset RG scenario where a chunk doesn't start at 0. + #[test] + fn stats_prune_tree_offset_rg_idx_not_in_map_does_not_prune() { + // Tree: AND(collector0, collector1) with spt root saying position 0 = false. + // But rg_idx=5 is NOT in the reverse map → should NOT prune. + let tree = ResolvedNode::And(vec![collector_leaf(0), collector_leaf(1)]); + let leaves = FixedLeafBitmaps { + bitmaps: vec![bm(&[1, 2, 3]), bm(&[2, 3, 4])], + }; + let pruner = empty_pruner(); + let spt = prune_tree_and(vec![ + prune_tree_leaf(vec![false]), + prune_tree_leaf(vec![true]), + ]); + // Map has {0→0} but ctx.rg_idx=5 → not in map → no pruning. + let rg_map = HashMap::from([(0usize, 0usize)]); + let mut ctx = test_ctx(); + ctx.rg_idx = 5; + let result = BitmapTreeEvaluator + .prefetch( + &tree, + &ctx, + &leaves, + &pruner, + &HashMap::new(), + None, + Some(&spt), + &rg_map, + ) + .unwrap(); + // Not pruned — both collectors contribute. + assert_eq!(result.candidates, bm(&[2, 3])); + } + + /// When `rg_idx` IS in the reverse map and maps to a position where + /// `rg_can_match[pos] == false`, the subtree is correctly pruned. + #[test] + fn stats_prune_tree_offset_rg_idx_in_map_prunes_correctly() { + let tree = ResolvedNode::And(vec![collector_leaf(0), collector_leaf(1)]); + let leaves = FixedLeafBitmaps { + bitmaps: vec![bm(&[1, 2, 3]), bm(&[2, 3, 4])], + }; + let pruner = empty_pruner(); + // rg_can_match has 3 positions: [true, false, true] + // RG indices [2,3,4] → map: {2→0, 3→1, 4→2} + let spt = prune_tree_and(vec![ + prune_tree_leaf(vec![true, false, true]), + prune_tree_leaf(vec![true, true, true]), + ]); + let rg_map = HashMap::from([(2usize, 0usize), (3, 1), (4, 2)]); + // rg_idx=3 → pos=1 → root rg_can_match[1] = false (from AND) → pruned + let mut ctx = test_ctx(); + ctx.rg_idx = 3; + let result = BitmapTreeEvaluator + .prefetch( + &tree, + &ctx, + &leaves, + &pruner, + &HashMap::new(), + None, + Some(&spt), + &rg_map, + ) + .unwrap(); + assert!(result.candidates.is_empty()); + } + + /// Regression: when an AND subtree under an OR is stats-pruned because + /// a native Predicate leaf proves no-match via column stats, all + /// Collector leaves inside the same subtree must still get empty bitmap + /// entries in `per_leaf`. Otherwise on_batch refinement panics with + /// "leaf bitmap missing for key". + /// + /// Realistic tree shape (mirrors a PPL query like + /// `WHERE (GoodEvent=1 AND (match(Title,'x') OR Age>18)) OR match(URL,'y')`): + /// ```text + /// OR + /// / \ + /// AND coll2 + /// / \ + /// Predicate OR(coll0, coll1) + /// ``` + /// Stats prune: AND subtree → false (Predicate child proves no-match). + /// coll0 and coll1 inside the pruned AND must still get empty per_leaf + /// entries since coll2 survives → candidates non-empty → refinement runs. + #[test] + fn stats_prune_under_or_materialises_empty_bitmaps_for_collectors() { + use datafusion::common::ScalarValue; + use datafusion::physical_expr::expressions::Literal; + let always_true_expr: Arc = + Arc::new(Literal::new(ScalarValue::Boolean(Some(true)))); + + // OR(AND(Predicate, OR(coll0, coll1)), coll2) + let tree = ResolvedNode::Or(vec![ + ResolvedNode::And(vec![ + ResolvedNode::Predicate(always_true_expr), + ResolvedNode::Or(vec![collector_leaf(0), collector_leaf(1)]), + ]), + collector_leaf(2), + ]); + // DFS order by cost-sorted evaluation: + // OR sorts: coll2 (cost=10) before AND(Pred+OR) (cost=1+20=21) + // So: coll2 dfs=0, then AND subtree (pruned): Predicate dfs=1, coll0 dfs=2, coll1 dfs=3 + let leaves = FixedLeafBitmaps { + bitmaps: vec![bm(&[5, 6, 7]), bm(&[99]), bm(&[99]), bm(&[99])], + }; + let pruner = empty_pruner(); + // Stats: AND subtree = false (Predicate child stats=false), coll2 = true + let and_spt = prune_tree_and(vec![ + prune_tree_leaf(vec![false]), // Predicate + prune_tree_or(vec![ + // OR(coll0, coll1) + prune_tree_leaf(vec![true]), + prune_tree_leaf(vec![true]), + ]), + ]); + let spt = prune_tree_or(vec![and_spt, prune_tree_leaf(vec![true])]); + let result = BitmapTreeEvaluator + .prefetch( + &tree, + &test_ctx(), + &leaves, + &pruner, + &HashMap::new(), + None, + Some(&spt), + &HashMap::from([(0, 0)]), + ) + .unwrap(); + // AND subtree pruned → empty; coll2 → {5,6,7}; OR = {5,6,7} + assert_eq!(result.candidates, bm(&[5, 6, 7])); + // Critical: both collector leaves from pruned subtree must have + // per_leaf entries (empty bitmaps) so refinement doesn't panic. + // coll2 (dfs=0) → real bitmap; coll0 (dfs=2), coll1 (dfs=3) → empty. + assert_eq!( + result.per_leaf.len(), + 3, + "stats-pruned collectors under OR must still have per_leaf entries; got {}", + result.per_leaf.len() + ); + // coll2 (dfs=0) gets real bitmap + assert!( + !result.per_leaf[0].1.is_empty(), + "coll2 should have non-empty bitmap" + ); + // coll0 (dfs=2) and coll1 (dfs=3) get empty bitmaps from pruned subtree + assert!( + result.per_leaf[1].1.is_empty(), + "pruned coll0 should have empty bitmap" + ); + assert!( + result.per_leaf[2].1.is_empty(), + "pruned coll1 should have empty bitmap" + ); + } + + /// Same scenario but deeper — mirrors a query like: + /// `WHERE (GoodEvent=1 AND Income>0 AND (match(T,'x') OR Age>18)) OR (match(S,'buy') AND CounterID>100)` + /// + /// ```text + /// AND (root) + /// / \ + /// coll3 OR + /// / \ + /// AND coll2 + /// / \ + /// Predicate OR(coll0, coll1) + /// ``` + /// The inner AND subtree is stats-pruned (Predicate proves no-match). + /// coll0 and coll1 must still get empty per_leaf entries. + #[test] + fn stats_prune_deep_or_under_and_materialises_all_collector_bitmaps() { + use datafusion::common::ScalarValue; + use datafusion::physical_expr::expressions::Literal; + let pred_expr: Arc = + Arc::new(Literal::new(ScalarValue::Boolean(Some(true)))); + + // AND(coll3, OR(AND(Predicate, OR(coll0, coll1)), coll2)) + let tree = ResolvedNode::And(vec![ + collector_leaf(3), + ResolvedNode::Or(vec![ + ResolvedNode::And(vec![ + ResolvedNode::Predicate(pred_expr), + ResolvedNode::Or(vec![collector_leaf(0), collector_leaf(1)]), + ]), + collector_leaf(2), + ]), + ]); + // Cost sort at root AND: coll3(10) before OR(10+1+20=31) + // Cost sort at OR: coll2(10) before AND(Pred+OR=1+20=21) + // DFS: coll3=0, coll2=1, Predicate=2, coll0=3, coll1=4 + let leaves = FixedLeafBitmaps { + bitmaps: vec![ + bm(&[5, 6, 7, 8]), + bm(&[6, 7, 8]), + bm(&[99]), + bm(&[99]), + bm(&[99]), + ], + }; + let pruner = empty_pruner(); + // Stats: outer AND[coll3=true, OR=true] + // OR[inner AND=false (pruned), coll2=true] + // inner AND[Predicate=false, OR(coll0=true, coll1=true)] + let inner_and_spt = prune_tree_and(vec![ + prune_tree_leaf(vec![false]), // Predicate stats=false + prune_tree_or(vec![ + prune_tree_leaf(vec![true]), + prune_tree_leaf(vec![true]), + ]), + ]); + let or_spt = prune_tree_or(vec![inner_and_spt, prune_tree_leaf(vec![true])]); + let spt = prune_tree_and(vec![prune_tree_leaf(vec![true]), or_spt]); + let result = BitmapTreeEvaluator + .prefetch( + &tree, + &test_ctx(), + &leaves, + &pruner, + &HashMap::new(), + None, + Some(&spt), + &HashMap::from([(0, 0)]), + ) + .unwrap(); + // coll3={5,6,7,8}; OR: inner AND pruned→empty, coll2={6,7,8}; OR={6,7,8} + // Final AND = {5,6,7,8} ∩ {6,7,8} = {6,7,8} + assert_eq!(result.candidates, bm(&[6, 7, 8])); + // All 4 collector leaves must have per_leaf entries (coll3, coll2 real; + // coll0, coll1 empty from skip_dfs_with_empty_bitmaps). + assert_eq!( + result.per_leaf.len(), + 4, + "expected 4 per_leaf entries; got {}", + result.per_leaf.len() + ); + // coll3 (dfs=0) and coll2 (dfs=1) are real + assert!( + !result.per_leaf[0].1.is_empty(), + "coll3 should have non-empty bitmap" + ); + assert!( + !result.per_leaf[1].1.is_empty(), + "coll2 should have non-empty bitmap" + ); + // coll0 (dfs=3) and coll1 (dfs=4) are empty from pruned subtree + assert!( + result.per_leaf[2].1.is_empty(), + "pruned coll0 should have empty bitmap" + ); + assert!( + result.per_leaf[3].1.is_empty(), + "pruned coll1 should have empty bitmap" + ); + } } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/eval/eval_helpers.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/eval/eval_helpers.rs index 91ea43e77cba2..92a1940464ed3 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/eval/eval_helpers.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/eval/eval_helpers.rs @@ -111,8 +111,7 @@ pub fn remap_expr_to_batch( use datafusion::common::tree_node::Transformed; if let Some(col) = node.downcast_ref::() { if let Ok(idx) = batch_schema.index_of(col.name()) { - let new_col: Arc = - Arc::new(Column::new(col.name(), idx)); + let new_col: Arc = Arc::new(Column::new(col.name(), idx)); return Ok(Transformed::yes(new_col)); } } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/eval/mod.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/eval/mod.rs index e00764887b9e1..65d0efd22bf2b 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/eval/mod.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/eval/mod.rs @@ -274,6 +274,7 @@ pub trait TreeEvaluator: Send + Sync { pruning_predicates: &HashMap>, page_prune_metrics: Option<&PagePruneMetrics>, stats_prune_tree: Option<&StatsPruneTree>, + rg_index_to_pos: &HashMap, ) -> Result; /// Refinement stage: produce the exact per-row `BooleanArray` for one @@ -351,7 +352,9 @@ pub struct TreeBitsetSource { /// be expensive in multi-collector trees. pub collector_strategy: CollectorCallStrategy, /// Precomputed per-subtree RG match vectors. Built once at construction. - pub stats_prune_tree: Option, + pub stats_prune_tree: Option>, + /// Reverse map: absolute RG index → position in `rg_can_match` vectors. + pub rg_index_to_pos: HashMap, } impl RowGroupBitsetSource for TreeBitsetSource { @@ -365,12 +368,14 @@ impl RowGroupBitsetSource for TreeBitsetSource { // RG-level early-exit: precomputed from column stats at construction. if let Some(ref ann) = self.stats_prune_tree { - if let Some(&false) = ann.rg_can_match.get(rg.index) { - native_bridge_common::log_debug!( - "BitmapTree: skipping RG {} — pruned by RG-level stats", - rg.index - ); - return Ok(None); + if let Some(&pos) = self.rg_index_to_pos.get(&rg.index) { + if let Some(&false) = ann.rg_can_match.get(pos) { + native_bridge_common::log_debug!( + "BitmapTree: skipping RG {} — pruned by RG-level stats", + rg.index + ); + return Ok(None); + } } } @@ -421,7 +426,8 @@ impl RowGroupBitsetSource for TreeBitsetSource { // inflate counts. We compute final page-level metrics below // after the bitmap tree is fully resolved. None, - self.stats_prune_tree.as_ref(), + self.stats_prune_tree.as_deref(), + &self.rg_index_to_pos, ) .map_err(|e| format!("TreeBitsetSource::prefetch_rg(rg={}): {}", rg.index, e))?; if prefetch.candidates.is_empty() { @@ -773,6 +779,7 @@ mod tests { _pruning_predicates: &HashMap>, _page_prune_metrics: Option<&PagePruneMetrics>, _stats_prune_tree: Option<&StatsPruneTree>, + _rg_index_to_pos: &HashMap, ) -> Result { Ok(TreePrefetch { candidates: roaring::RoaringBitmap::new(), @@ -822,6 +829,7 @@ mod tests { page_prune_metrics: None, collector_strategy: CollectorCallStrategy::TightenOuterBounds, stats_prune_tree: None, + rg_index_to_pos: HashMap::new(), }; assert!(!source.needs_row_mask()); } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/eval/predicate_evaluator.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/eval/predicate_evaluator.rs index ef76ece7c69b8..420cf71fa0d67 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/eval/predicate_evaluator.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/eval/predicate_evaluator.rs @@ -13,6 +13,7 @@ //! Candidates default to the page-pruned universe; `on_batch_mask` evaluates //! only the residual predicate. +use std::collections::HashMap; use std::sync::Arc; use std::time::Instant; @@ -21,7 +22,9 @@ use datafusion::arrow::record_batch::RecordBatch; use datafusion::physical_optimizer::pruning::PruningPredicate; use roaring::RoaringBitmap; -use super::eval_helpers::{compute_page_ranges, evaluate_residual, universe_bitmap_from_page_ranges}; +use super::eval_helpers::{ + compute_page_ranges, evaluate_residual, universe_bitmap_from_page_ranges, +}; use super::{PrefetchedRg, RowGroupBitsetSource}; use crate::indexed_table::page_pruner::{PagePruneMetrics, PagePruner, StatsPruneTree}; use crate::indexed_table::row_selection::{bitmap_to_packed_bits, PositionMap}; @@ -35,7 +38,9 @@ pub struct PredicateOnlyEvaluator { pruning_predicate: Option>, residual_expr: Option>, page_prune_metrics: Option, - stats_prune_tree: Option, + stats_prune_tree: Option>, + /// Reverse map: absolute RG index → position in `rg_can_match` vectors. + rg_index_to_pos: HashMap, } impl PredicateOnlyEvaluator { @@ -44,7 +49,8 @@ impl PredicateOnlyEvaluator { pruning_predicate: Option>, residual_expr: Option>, page_prune_metrics: Option, - stats_prune_tree: Option, + stats_prune_tree: Option>, + rg_index_to_pos: HashMap, ) -> Self { Self { page_pruner, @@ -52,6 +58,7 @@ impl PredicateOnlyEvaluator { residual_expr, page_prune_metrics, stats_prune_tree, + rg_index_to_pos, } } } @@ -67,12 +74,14 @@ impl RowGroupBitsetSource for PredicateOnlyEvaluator { // RG-level early-exit: precomputed from column stats at construction. if let Some(ref spt) = self.stats_prune_tree { - if let Some(&false) = spt.rg_can_match.get(rg.index) { - native_bridge_common::log_debug!( - "PredicateOnly: skipping RG {} — pruned by RG-level stats", - rg.index - ); - return Ok(None); + if let Some(&pos) = self.rg_index_to_pos.get(&rg.index) { + if let Some(&false) = spt.rg_can_match.get(pos) { + native_bridge_common::log_debug!( + "PredicateOnly: skipping RG {} — pruned by RG-level stats", + rg.index + ); + return Ok(None); + } } } @@ -152,8 +161,19 @@ mod tests { rg_can_match: vec![false], children: vec![], }; - let eval = PredicateOnlyEvaluator::new(pruner, None, None, None, Some(spt)); - let rg = RowGroupInfo { index: 0, first_row: 0, num_rows: 8 }; + let eval = PredicateOnlyEvaluator::new( + pruner, + None, + None, + None, + Some(Arc::new(spt)), + HashMap::from([(0, 0)]), + ); + let rg = RowGroupInfo { + index: 0, + first_row: 0, + num_rows: 8, + }; assert!(eval.prefetch_rg(&rg, 0, 8).unwrap().is_none()); } @@ -164,18 +184,39 @@ mod tests { rg_can_match: vec![true], children: vec![], }; - let eval = PredicateOnlyEvaluator::new(pruner, None, None, None, Some(spt)); - let rg = RowGroupInfo { index: 0, first_row: 0, num_rows: 8 }; - let prefetched = eval.prefetch_rg(&rg, 0, 8).unwrap().expect("should have candidates"); + let eval = PredicateOnlyEvaluator::new( + pruner, + None, + None, + None, + Some(Arc::new(spt)), + HashMap::from([(0, 0)]), + ); + let rg = RowGroupInfo { + index: 0, + first_row: 0, + num_rows: 8, + }; + let prefetched = eval + .prefetch_rg(&rg, 0, 8) + .unwrap() + .expect("should have candidates"); assert_eq!(prefetched.candidates.len(), 8); } #[test] fn stats_prune_tree_none_does_not_prune() { let pruner = minimal_page_pruner(); - let eval = PredicateOnlyEvaluator::new(pruner, None, None, None, None); - let rg = RowGroupInfo { index: 0, first_row: 0, num_rows: 8 }; - let prefetched = eval.prefetch_rg(&rg, 0, 8).unwrap().expect("should have candidates"); + let eval = PredicateOnlyEvaluator::new(pruner, None, None, None, None, HashMap::new()); + let rg = RowGroupInfo { + index: 0, + first_row: 0, + num_rows: 8, + }; + let prefetched = eval + .prefetch_rg(&rg, 0, 8) + .unwrap() + .expect("should have candidates"); assert_eq!(prefetched.candidates.len(), 8); } } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/eval/single_collector.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/eval/single_collector.rs index 5a573dd4db9f6..62e908517a2c8 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/eval/single_collector.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/eval/single_collector.rs @@ -89,7 +89,13 @@ impl DelegatedBackendCollectorFactory for FfmDelegatedBackendCollectorFactory { doc_min: i32, doc_max: i32, ) -> Result, String> { - let collector = FfmSegmentCollector::create(context_id, provider_key, writer_generation, doc_min, doc_max)?; + let collector = FfmSegmentCollector::create( + context_id, + provider_key, + writer_generation, + doc_min, + doc_max, + )?; Ok(Arc::new(collector) as Arc) } } @@ -177,7 +183,9 @@ pub struct SingleCollectorEvaluator { /// Bloom filter pruning config. None = disabled. bloom_config: Option, /// Precomputed per-RG/subtree match status from RG-level column stats. - stats_prune_tree: Option, + stats_prune_tree: Option>, + /// Reverse map: absolute RG index → position in `rg_can_match` vectors. + rg_index_to_pos: HashMap, } /// Resources needed for per-RG bloom filter pruning. @@ -205,7 +213,8 @@ impl SingleCollectorEvaluator { delegated_backend_collector_factory: Arc, context_id: i64, bloom_config: Option, - stats_prune_tree: Option, + stats_prune_tree: Option>, + rg_index_to_pos: HashMap, ) -> Self { Self { collector, @@ -221,6 +230,7 @@ impl SingleCollectorEvaluator { context_id, bloom_config, stats_prune_tree, + rg_index_to_pos, } } } @@ -263,12 +273,14 @@ impl RowGroupBitsetSource for SingleCollectorEvaluator { // RG-level early-exit: precomputed from column stats at construction. if let Some(ref spt) = self.stats_prune_tree { - if let Some(&false) = spt.rg_can_match.get(rg.index) { - native_bridge_common::log_debug!( - "SingleCollector: skipping RG {} — pruned by RG-level stats", - rg.index - ); - return Ok(None); + if let Some(&pos) = self.rg_index_to_pos.get(&rg.index) { + if let Some(&false) = spt.rg_can_match.get(pos) { + native_bridge_common::log_debug!( + "SingleCollector: skipping RG {} — pruned by RG-level stats", + rg.index + ); + return Ok(None); + } } } @@ -305,16 +317,17 @@ impl RowGroupBitsetSource for SingleCollectorEvaluator { // the RuntimeManager to drive the async object-store read. if let (Some(bloom), Some(pp)) = (&self.bloom_config, &self.pruning_predicate) { let _timer = bloom.bloom_filter_eval_time.as_ref().map(|t| t.timer()); - let pruned = bloom.io_handle.block_on( - crate::indexed_table::bloom_pruner::bloom_prune_rg( - &*bloom.store, - &bloom.object_path, - &bloom.metadata, - &bloom.arrow_schema, - rg.index, - pp.as_ref(), - ) - ); + let pruned = + bloom + .io_handle + .block_on(crate::indexed_table::bloom_pruner::bloom_prune_rg( + &*bloom.store, + &bloom.object_path, + &bloom.metadata, + &bloom.arrow_schema, + rg.index, + pp.as_ref(), + )); if pruned { if let Some(ref c) = bloom.rg_bloom_pruned { c.add(1); @@ -440,7 +453,8 @@ impl RowGroupBitsetSource for SingleCollectorEvaluator { let mut just_initialized = false; let provider = lock.get_or_init(|| { just_initialized = true; - create_provider(context_id, annotation_id).expect("create_provider FFM upcall failed") + create_provider(context_id, annotation_id) + .expect("create_provider FFM upcall failed") }); if just_initialized { log_debug!( @@ -704,7 +718,22 @@ mod tests { docs: vec![0, 3, 7], }) as Arc; let pruner = minimal_page_pruner(); - let eval = SingleCollectorEvaluator::new(Some(collector), pruner, None, None, None, None, CollectorCallStrategy::FullRange, Arc::new(HashMap::new()), 0, Arc::new(FfmDelegatedBackendCollectorFactory), 0, None, None); + let eval = SingleCollectorEvaluator::new( + Some(collector), + pruner, + None, + None, + None, + None, + CollectorCallStrategy::FullRange, + Arc::new(HashMap::new()), + 0, + Arc::new(FfmDelegatedBackendCollectorFactory), + 0, + None, + None, + HashMap::new(), + ); let rg = RowGroupInfo { index: 0, @@ -720,7 +749,22 @@ mod tests { fn on_batch_mask_returns_none_for_path_b() { let collector = Arc::new(StubCollector { docs: vec![0] }) as Arc; let pruner = minimal_page_pruner(); - let eval = SingleCollectorEvaluator::new(Some(collector), pruner, None, None, None, None, CollectorCallStrategy::FullRange, Arc::new(HashMap::new()), 0, Arc::new(FfmDelegatedBackendCollectorFactory), 0, None, None); + let eval = SingleCollectorEvaluator::new( + Some(collector), + pruner, + None, + None, + None, + None, + CollectorCallStrategy::FullRange, + Arc::new(HashMap::new()), + 0, + Arc::new(FfmDelegatedBackendCollectorFactory), + 0, + None, + None, + HashMap::new(), + ); let schema = Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])); let batch = datafusion::arrow::record_batch::RecordBatch::try_new( schema, @@ -748,7 +792,22 @@ mod tests { // (it's the only post-decode filter we have on this path). let collector = Arc::new(StubCollector { docs: vec![0] }) as Arc; let pruner = minimal_page_pruner(); - let eval = SingleCollectorEvaluator::new(Some(collector), pruner, None, None, None, None, CollectorCallStrategy::FullRange, Arc::new(HashMap::new()), 0, Arc::new(FfmDelegatedBackendCollectorFactory), 0, None, None); + let eval = SingleCollectorEvaluator::new( + Some(collector), + pruner, + None, + None, + None, + None, + CollectorCallStrategy::FullRange, + Arc::new(HashMap::new()), + 0, + Arc::new(FfmDelegatedBackendCollectorFactory), + 0, + None, + None, + HashMap::new(), + ); assert!(eval.needs_row_mask()); } @@ -756,7 +815,22 @@ mod tests { fn empty_match_returns_none() { let collector = Arc::new(StubCollector { docs: vec![] }) as Arc; let pruner = minimal_page_pruner(); - let eval = SingleCollectorEvaluator::new(Some(collector), pruner, None, None, None, None, CollectorCallStrategy::FullRange, Arc::new(HashMap::new()), 0, Arc::new(FfmDelegatedBackendCollectorFactory), 0, None, None); + let eval = SingleCollectorEvaluator::new( + Some(collector), + pruner, + None, + None, + None, + None, + CollectorCallStrategy::FullRange, + Arc::new(HashMap::new()), + 0, + Arc::new(FfmDelegatedBackendCollectorFactory), + 0, + None, + None, + HashMap::new(), + ); let rg = RowGroupInfo { index: 0, first_row: 0, @@ -776,7 +850,22 @@ mod tests { docs: vec![0, 3, 7], }) as Arc; let pruner = minimal_page_pruner(); - let eval = SingleCollectorEvaluator::new(Some(collector), pruner, None, None, None, None, CollectorCallStrategy::FullRange, Arc::new(HashMap::new()), 0, Arc::new(FfmDelegatedBackendCollectorFactory), 0, None, None); + let eval = SingleCollectorEvaluator::new( + Some(collector), + pruner, + None, + None, + None, + None, + CollectorCallStrategy::FullRange, + Arc::new(HashMap::new()), + 0, + Arc::new(FfmDelegatedBackendCollectorFactory), + 0, + None, + None, + HashMap::new(), + ); let rg = RowGroupInfo { index: 0, @@ -798,7 +887,22 @@ mod tests { rg_can_match: vec![false], children: vec![], }; - let eval = SingleCollectorEvaluator::new(Some(collector), pruner, None, None, None, None, CollectorCallStrategy::FullRange, Arc::new(HashMap::new()), 0, Arc::new(FfmDelegatedBackendCollectorFactory), 0, None, Some(spt)); + let eval = SingleCollectorEvaluator::new( + Some(collector), + pruner, + None, + None, + None, + None, + CollectorCallStrategy::FullRange, + Arc::new(HashMap::new()), + 0, + Arc::new(FfmDelegatedBackendCollectorFactory), + 0, + None, + Some(Arc::new(spt)), + HashMap::from([(0, 0)]), + ); let rg = RowGroupInfo { index: 0, first_row: 0, @@ -817,30 +921,65 @@ mod tests { rg_can_match: vec![true], children: vec![], }; - let eval = SingleCollectorEvaluator::new(Some(collector), pruner, None, None, None, None, CollectorCallStrategy::FullRange, Arc::new(HashMap::new()), 0, Arc::new(FfmDelegatedBackendCollectorFactory), 0, None, Some(spt)); + let eval = SingleCollectorEvaluator::new( + Some(collector), + pruner, + None, + None, + None, + None, + CollectorCallStrategy::FullRange, + Arc::new(HashMap::new()), + 0, + Arc::new(FfmDelegatedBackendCollectorFactory), + 0, + None, + Some(Arc::new(spt)), + HashMap::from([(0, 0)]), + ); let rg = RowGroupInfo { index: 0, first_row: 0, num_rows: 8, }; - let prefetched = eval.prefetch_rg(&rg, 0, 8).unwrap().expect("should have matches"); + let prefetched = eval + .prefetch_rg(&rg, 0, 8) + .unwrap() + .expect("should have matches"); let got: Vec = prefetched.candidates.iter().collect(); assert_eq!(got, vec![0u32, 3, 7]); } #[test] fn stats_prune_tree_none_does_not_prune() { - let collector = Arc::new(StubCollector { - docs: vec![1, 5], - }) as Arc; + let collector = + Arc::new(StubCollector { docs: vec![1, 5] }) as Arc; let pruner = minimal_page_pruner(); - let eval = SingleCollectorEvaluator::new(Some(collector), pruner, None, None, None, None, CollectorCallStrategy::FullRange, Arc::new(HashMap::new()), 0, Arc::new(FfmDelegatedBackendCollectorFactory), 0, None, None); + let eval = SingleCollectorEvaluator::new( + Some(collector), + pruner, + None, + None, + None, + None, + CollectorCallStrategy::FullRange, + Arc::new(HashMap::new()), + 0, + Arc::new(FfmDelegatedBackendCollectorFactory), + 0, + None, + None, + HashMap::new(), + ); let rg = RowGroupInfo { index: 0, first_row: 0, num_rows: 8, }; - let prefetched = eval.prefetch_rg(&rg, 0, 8).unwrap().expect("should have matches"); + let prefetched = eval + .prefetch_rg(&rg, 0, 8) + .unwrap() + .expect("should have matches"); let got: Vec = prefetched.candidates.iter().collect(); assert_eq!(got, vec![1u32, 5]); } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/ffm_callbacks.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/ffm_callbacks.rs index 7810b35054745..d661c593f60be 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/ffm_callbacks.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/ffm_callbacks.rs @@ -161,9 +161,7 @@ pub fn create_provider(context_id: i64, annotation_id: i32) -> Result {}", - context_id, - annotation_id, - key + context_id, annotation_id, key )); } Ok(ProviderHandle { context_id, key }) @@ -191,7 +189,15 @@ impl FfmSegmentCollector { doc_max: i32, ) -> Result { let create = load_create_collector()?; - let key = unsafe { create(context_id, provider_key, writer_generation, doc_min, doc_max) }; + let key = unsafe { + create( + context_id, + provider_key, + writer_generation, + doc_min, + doc_max, + ) + }; if key < 0 { return Err(format!( "createCollector(context_id={}, provider={}, writer_generation={}) failed: {}", diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/metrics.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/metrics.rs index 72834911d7057..a5aed1626fbc1 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/metrics.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/metrics.rs @@ -228,7 +228,8 @@ impl PartitionMetrics { row_groups_processed: counter("row_groups_processed"), row_groups_skipped: counter("row_groups_skipped"), rg_bloom_pruned: counter("rg_bloom_pruned"), - bloom_filter_eval_time: MetricBuilder::new(metrics).subset_time("bloom_filter_eval_time", partition), + bloom_filter_eval_time: MetricBuilder::new(metrics) + .subset_time("bloom_filter_eval_time", partition), pages_pruned: counter("pages_pruned"), pages_total: counter("pages_total"), page_pruning_unavailable: counter("page_pruning_unavailable"), @@ -253,8 +254,7 @@ impl PartitionMetrics { .subset_time("projection_fixup_time", partition), parquet_poll_time: MetricBuilder::new(metrics) .subset_time("parquet_poll_time", partition), - inter_poll_gap: MetricBuilder::new(metrics) - .subset_time("inter_poll_gap", partition), + inter_poll_gap: MetricBuilder::new(metrics).subset_time("inter_poll_gap", partition), poll_count: counter("poll_count"), init_prefetch_time: MetricBuilder::new(metrics) .subset_time("init_prefetch_time", partition), diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/mod.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/mod.rs index a2c5c5eab6ade..94777ad0bfd19 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/mod.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/mod.rs @@ -58,13 +58,13 @@ pub mod bloom_pruner; pub mod bool_tree; pub mod dynamic_filter; pub mod eval; -pub mod row_id_injection; pub mod ffm_callbacks; pub mod index; pub mod metrics; pub mod page_pruner; pub mod parquet_bridge; pub mod partitioning; +pub mod row_id_injection; pub mod row_selection; pub mod segment_info; pub mod stream; diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/page_pruner.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/page_pruner.rs index 19f8ea7037baa..19c5567d7b6f2 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/page_pruner.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/page_pruner.rs @@ -49,8 +49,8 @@ use datafusion::parquet::arrow::arrow_reader::{RowSelection, RowSelector}; use datafusion::parquet::file::metadata::ParquetMetaData; #[cfg(test)] use datafusion::physical_expr::expressions::{BinaryExpr, Column as PhysColumn, Literal}; -use datafusion::physical_expr::PhysicalExpr; use datafusion::physical_expr::utils::collect_columns; +use datafusion::physical_expr::PhysicalExpr; use datafusion::physical_optimizer::pruning::{PruningPredicate, PruningStatistics}; /// Per-row-group page pruner. Owns schema + metadata references; the @@ -84,8 +84,7 @@ impl PagePruner { rg_idx: usize, metrics: Option<&PagePruneMetrics>, ) -> Option { - let columns = - collect_columns(pruning_predicate.orig_expr()); + let columns = collect_columns(pruning_predicate.orig_expr()); if columns.is_empty() { return None; } @@ -116,12 +115,21 @@ impl PagePruner { Option<(StatisticsConverter<'_>, usize)>, )> = Vec::new(); + // Resolve against the segment's own schema (see eval_leaf): the full table + // schema misaligns StatisticsConverter's positional column lookup under + // dynamic-mapping schema drift. + let descr = self.metadata.file_metadata().schema_descr(); + let seg_arrow_schema = match datafusion::parquet::arrow::parquet_to_arrow_schema( + descr, + self.metadata.file_metadata().key_value_metadata(), + ) { + Ok(s) => Arc::new(s), + Err(_) => Arc::clone(&self.schema), + }; + for col in &columns { - let converter = match StatisticsConverter::try_new( - col.name(), - &self.schema, - self.metadata.file_metadata().schema_descr(), - ) { + let converter = match StatisticsConverter::try_new(col.name(), &seg_arrow_schema, descr) + { Ok(c) => c, Err(_) => { // Column not in Arrow schema either — nothing we can @@ -273,12 +281,19 @@ pub fn build_pruning_predicate( let pruning_predicate = match PruningPredicate::try_new(Arc::clone(expr), schema) { Ok(pp) => pp, Err(e) => { - native_bridge_common::log_debug!("PruningPredicate::try_new failed for {:?}: {}", expr, e); + native_bridge_common::log_debug!( + "PruningPredicate::try_new failed for {:?}: {}", + expr, + e + ); return None; } }; if pruning_predicate.always_true() { - native_bridge_common::log_debug!("PruningPredicate collapsed to always_true for {:?}", expr); + native_bridge_common::log_debug!( + "PruningPredicate collapsed to always_true for {:?}", + expr + ); return None; } Some(Arc::new(pruning_predicate)) @@ -525,7 +540,20 @@ fn eval_leaf( if columns.is_empty() { return vec![true; num]; } - let arrow_schema = schema.as_ref(); + // Resolve stats against the segment's OWN parquet schema, not the full table + // schema. StatisticsConverter maps a column name to a parquet index positionally + // (parquet crate `parquet_column`), so passing the full schema reads the wrong / + // no column when the segment's schema is narrower or reordered (dynamic-mapping + // schema drift) — yielding null stats that prune RGs that actually match. + let descr = metadata.file_metadata().schema_descr(); + let seg_arrow_schema = match datafusion::parquet::arrow::parquet_to_arrow_schema( + descr, + metadata.file_metadata().key_value_metadata(), + ) { + Ok(s) => Arc::new(s), + Err(_) => Arc::clone(schema), + }; + let arrow_schema = seg_arrow_schema.as_ref(); let rg_metas: Vec<_> = rg_indices .iter() .filter_map(|&idx| metadata.row_groups().get(idx)) @@ -538,9 +566,7 @@ fn eval_leaf( if arrow_schema.index_of(col.name()).is_err() { continue; } - let converter = match StatisticsConverter::try_new( - col.name(), arrow_schema, metadata.file_metadata().schema_descr(), - ) { + let converter = match StatisticsConverter::try_new(col.name(), arrow_schema, descr) { Ok(c) => c, Err(_) => continue, }; @@ -580,16 +606,22 @@ struct RgLevelStats { impl PruningStatistics for RgLevelStats { fn min_values(&self, column: &Column) -> Option { - self.col_stats.get(column.name()).map(|(m, _, _)| Arc::clone(m)) + self.col_stats + .get(column.name()) + .map(|(m, _, _)| Arc::clone(m)) } fn max_values(&self, column: &Column) -> Option { - self.col_stats.get(column.name()).map(|(_, m, _)| Arc::clone(m)) + self.col_stats + .get(column.name()) + .map(|(_, m, _)| Arc::clone(m)) } fn num_containers(&self) -> usize { self.num_rgs } fn null_counts(&self, column: &Column) -> Option { - self.col_stats.get(column.name()).and_then(|(_, _, n)| n.clone()) + self.col_stats + .get(column.name()) + .and_then(|(_, _, n)| n.clone()) } fn row_counts(&self) -> Option { let arr = Int64Array::from_iter_values(self.row_counts.iter().copied()); @@ -660,7 +692,9 @@ impl StatsPruneTree { BoolNode::And(children) => { let annotated_children: Vec<_> = children .iter() - .map(|c| Self::build_from_bool_node(c, leaf_predicates, metadata, schema, rg_indices)) + .map(|c| { + Self::build_from_bool_node(c, leaf_predicates, metadata, schema, rg_indices) + }) .collect(); let mut rg_can_match = vec![true; num]; for child in &annotated_children { @@ -668,12 +702,17 @@ impl StatsPruneTree { *r &= c; } } - Self { rg_can_match, children: annotated_children } + Self { + rg_can_match, + children: annotated_children, + } } BoolNode::Or(children) => { let annotated_children: Vec<_> = children .iter() - .map(|c| Self::build_from_bool_node(c, leaf_predicates, metadata, schema, rg_indices)) + .map(|c| { + Self::build_from_bool_node(c, leaf_predicates, metadata, schema, rg_indices) + }) .collect(); let mut rg_can_match = vec![false; num]; for child in &annotated_children { @@ -681,14 +720,26 @@ impl StatsPruneTree { *r |= c; } } - Self { rg_can_match, children: annotated_children } + Self { + rg_can_match, + children: annotated_children, + } } BoolNode::Not(child) => { - let annotated_child = Self::build_from_bool_node(child, leaf_predicates, metadata, schema, rg_indices); + let annotated_child = Self::build_from_bool_node( + child, + leaf_predicates, + metadata, + schema, + rg_indices, + ); // NOT is conservatively all-true: negating stats-based pruning is unsound // because stats give a superset, and inverting a superset is a subset. native_bridge_common::log_debug!("StatsPruneTree: NOT node → all-true (conservative, cannot prune through negation)"); - Self { rg_can_match: vec![true; num], children: vec![annotated_child] } + Self { + rg_can_match: vec![true; num], + children: vec![annotated_child], + } } BoolNode::Predicate(expr) => { let key = Arc::as_ptr(expr) as *const () as usize; @@ -696,7 +747,10 @@ impl StatsPruneTree { Some(pp) => eval_leaf(pp, metadata, schema, rg_indices), None => vec![true; num], }; - Self { rg_can_match, children: vec![] } + Self { + rg_can_match, + children: vec![], + } } BoolNode::DelegationPossible { original_expr, .. } => { let key = Arc::as_ptr(original_expr) as *const () as usize; @@ -704,12 +758,20 @@ impl StatsPruneTree { Some(pp) => eval_leaf(pp, metadata, schema, rg_indices), None => vec![true; num], }; - Self { rg_can_match, children: vec![] } + Self { + rg_can_match, + children: vec![], + } } BoolNode::Collector { .. } => { // Collectors have no column stats — always all-true. - native_bridge_common::log_debug!("StatsPruneTree: Collector node → all-true (no column stats available)"); - Self { rg_can_match: vec![true; num], children: vec![] } + native_bridge_common::log_debug!( + "StatsPruneTree: Collector node → all-true (no column stats available)" + ); + Self { + rg_can_match: vec![true; num], + children: vec![], + } } } } @@ -1439,33 +1501,41 @@ mod tests { use crate::indexed_table::bool_tree::BoolNode; // 5 RGs: price [0..9], [10..19], [20..29], [30..39], [40..49] - let schema = Arc::new(Schema::new(vec![Field::new("price", DataType::Int32, false)])); + let schema = Arc::new(Schema::new(vec![Field::new( + "price", + DataType::Int32, + false, + )])); let tmp = NamedTempFile::new().unwrap(); let props = WriterProperties::builder() .set_max_row_group_size(10) .set_statistics_enabled(EnabledStatistics::Chunk) .build(); - let mut w = ArrowWriter::try_new(tmp.reopen().unwrap(), schema.clone(), Some(props)).unwrap(); + let mut w = + ArrowWriter::try_new(tmp.reopen().unwrap(), schema.clone(), Some(props)).unwrap(); for i in 0..5i32 { let vals: Vec = (i * 10..(i + 1) * 10).collect(); - let batch = RecordBatch::try_new(schema.clone(), vec![Arc::new(Int32Array::from(vals))]).unwrap(); + let batch = + RecordBatch::try_new(schema.clone(), vec![Arc::new(Int32Array::from(vals))]) + .unwrap(); w.write(&batch).unwrap(); } w.close().unwrap(); - let meta = ArrowReaderMetadata::load(&tmp.reopen().unwrap(), ArrowReaderOptions::new()).unwrap(); + let meta = + ArrowReaderMetadata::load(&tmp.reopen().unwrap(), ArrowReaderOptions::new()).unwrap(); let arc_meta = meta.metadata().clone(); assert_eq!(arc_meta.num_row_groups(), 5); let rg_indices: Vec = (0..5).collect(); // Leaf predicates and their expected rg_can_match bitsets: - let p1 = pred_leaf("price", Operator::Lt, 30, &schema); // [11100] + let p1 = pred_leaf("price", Operator::Lt, 30, &schema); // [11100] let p2 = pred_leaf("price", Operator::GtEq, 10, &schema); // [01111] - let p3 = pred_leaf("price", Operator::Gt, 45, &schema); // [00001] - let p4 = pred_leaf("price", Operator::Lt, 25, &schema); // [11100] + let p3 = pred_leaf("price", Operator::Gt, 45, &schema); // [00001] + let p4 = pred_leaf("price", Operator::Lt, 25, &schema); // [11100] let p5 = pred_leaf("price", Operator::GtEq, 20, &schema); // [00111] - let p6 = pred_leaf("price", Operator::Lt, 40, &schema); // [11110] - let p7 = pred_leaf("price", Operator::Gt, 30, &schema); // [00011] - let p8 = pred_leaf("price", Operator::Lt, 15, &schema); // [11000] + let p6 = pred_leaf("price", Operator::Lt, 40, &schema); // [11110] + let p7 = pred_leaf("price", Operator::Gt, 30, &schema); // [00011] + let p8 = pred_leaf("price", Operator::Lt, 15, &schema); // [11000] // Tree: AND(OR(AND(p1,p2), p3), OR(AND(p4,p5), Collector, p6), AND(p7, NOT(p8))) let collector = BoolNode::Collector { annotation_id: 0 }; @@ -1479,10 +1549,7 @@ mod tests { collector, p6.clone(), ]), - BoolNode::And(vec![ - p7.clone(), - BoolNode::Not(Box::new(p8.clone())), - ]), + BoolNode::And(vec![p7.clone(), BoolNode::Not(Box::new(p8.clone()))]), ]); // Build PruningPredicates for all df leaves. @@ -1496,7 +1563,11 @@ mod tests { } let spt = StatsPruneTree::build_from_bool_node( - &tree, &leaf_predicates, &arc_meta, &schema, &rg_indices, + &tree, + &leaf_predicates, + &arc_meta, + &schema, + &rg_indices, ); // Verify bottom-up: @@ -1513,28 +1584,191 @@ mod tests { assert_eq!(spt.children.len(), 3); // Child 0: OR₁ - assert_eq!(spt.children[0].rg_can_match, vec![false, true, true, false, true]); + assert_eq!( + spt.children[0].rg_can_match, + vec![false, true, true, false, true] + ); assert_eq!(spt.children[0].children.len(), 2); // OR₁/AND - assert_eq!(spt.children[0].children[0].rg_can_match, vec![false, true, true, false, false]); + assert_eq!( + spt.children[0].children[0].rg_can_match, + vec![false, true, true, false, false] + ); // OR₁/AND/p1 - assert_eq!(spt.children[0].children[0].children[0].rg_can_match, vec![true, true, true, false, false]); + assert_eq!( + spt.children[0].children[0].children[0].rg_can_match, + vec![true, true, true, false, false] + ); // OR₁/AND/p2 - assert_eq!(spt.children[0].children[0].children[1].rg_can_match, vec![false, true, true, true, true]); + assert_eq!( + spt.children[0].children[0].children[1].rg_can_match, + vec![false, true, true, true, true] + ); // OR₁/p3 - assert_eq!(spt.children[0].children[1].rg_can_match, vec![false, false, false, false, true]); + assert_eq!( + spt.children[0].children[1].rg_can_match, + vec![false, false, false, false, true] + ); // Child 1: OR₂ — luc makes it all-true - assert_eq!(spt.children[1].rg_can_match, vec![true, true, true, true, true]); + assert_eq!( + spt.children[1].rg_can_match, + vec![true, true, true, true, true] + ); // Child 2: AND₃ - assert_eq!(spt.children[2].rg_can_match, vec![false, false, false, true, true]); + assert_eq!( + spt.children[2].rg_can_match, + vec![false, false, false, true, true] + ); assert_eq!(spt.children[2].children.len(), 2); // AND₃/p7 - assert_eq!(spt.children[2].children[0].rg_can_match, vec![false, false, false, true, true]); + assert_eq!( + spt.children[2].children[0].rg_can_match, + vec![false, false, false, true, true] + ); // AND₃/NOT → always true - assert_eq!(spt.children[2].children[1].rg_can_match, vec![true, true, true, true, true]); + assert_eq!( + spt.children[2].children[1].rg_can_match, + vec![true, true, true, true, true] + ); // AND₃/NOT/p8 - assert_eq!(spt.children[2].children[1].children[0].rg_can_match, vec![true, true, false, false, false]); + assert_eq!( + spt.children[2].children[1].children[0].rg_can_match, + vec![true, true, false, false, false] + ); + } + + // Schema drift: the table schema orders columns differently from the segment's own + // parquet file, so resolving `severity` positionally against the table schema lands on + // a DIFFERENT real file column (`neg`, all-negative). The always-true `severity >= 0` + // then reads neg's stats (max < 0) and `eval_leaf` wrongly prunes the RG. Resolving + // against the segment's own schema reads the real `severity` stats and keeps it. + #[test] + fn eval_leaf_resolves_stats_against_segment_schema_under_drift() { + let file_schema = Arc::new(Schema::new(vec![ + Field::new("name", DataType::Int32, false), + Field::new("neg", DataType::Int32, false), + Field::new("severity", DataType::Int32, false), + ])); + let table_schema = Arc::new(Schema::new(vec![ + Field::new("name", DataType::Int32, false), + Field::new("severity", DataType::Int32, false), + Field::new("neg", DataType::Int32, false), + ])); + let batch = RecordBatch::try_new( + file_schema.clone(), + vec![ + Arc::new(Int32Array::from(vec![1, 2, 3, 4])), + Arc::new(Int32Array::from(vec![-9, -8, -7, -6])), + Arc::new(Int32Array::from(vec![0, 5, 10, 17])), + ], + ) + .unwrap(); + let tmp = NamedTempFile::new().unwrap(); + let mut w = ArrowWriter::try_new(tmp.reopen().unwrap(), file_schema, None).unwrap(); + w.write(&batch).unwrap(); + w.close().unwrap(); + let md = ArrowReaderMetadata::load(&tmp.reopen().unwrap(), ArrowReaderOptions::new()) + .unwrap() + .metadata() + .clone(); + + let expr = bin(col("severity", 1), Operator::GtEq, lit_int(0)); + let pp = build_pruning_predicate(&expr, table_schema.clone()).unwrap(); + assert_eq!( + eval_leaf(&pp, &md, &table_schema, &[0]), + vec![true], + "severity >= 0 is always true; eval_leaf must not prune under schema drift" + ); + } + + /// Verifies that `build_from_bool_node` works correctly when + /// `rg_indices` is a subset that doesn't start at 0 (e.g. chunk + /// contains RGs [2,3,4] out of a 5-RG file). The `rg_can_match` + /// vector should be 3 elements long, indexed 0..2 mapping to + /// absolute RGs 2,3,4. Consumers use a reverse map to translate. + #[test] + fn stats_prune_tree_offset_rg_indices() { + use crate::indexed_table::bool_tree::BoolNode; + + // 5 RGs: price [0..9], [10..19], [20..29], [30..39], [40..49] + let schema = Arc::new(Schema::new(vec![Field::new( + "price", + DataType::Int32, + false, + )])); + let tmp = NamedTempFile::new().unwrap(); + let props = WriterProperties::builder() + .set_max_row_group_size(10) + .set_statistics_enabled(EnabledStatistics::Chunk) + .build(); + let mut w = + ArrowWriter::try_new(tmp.reopen().unwrap(), schema.clone(), Some(props)).unwrap(); + for i in 0..5i32 { + let vals: Vec = (i * 10..(i + 1) * 10).collect(); + let batch = + RecordBatch::try_new(schema.clone(), vec![Arc::new(Int32Array::from(vals))]) + .unwrap(); + w.write(&batch).unwrap(); + } + w.close().unwrap(); + let meta = + ArrowReaderMetadata::load(&tmp.reopen().unwrap(), ArrowReaderOptions::new()).unwrap(); + let arc_meta = meta.metadata().clone(); + assert_eq!(arc_meta.num_row_groups(), 5); + + // Chunk only has RGs [2, 3, 4] (prices [20..49]) + let rg_indices: Vec = vec![2, 3, 4]; + + // price < 35 → full file would be [T,T,T,T,F]; subset [2,3,4] → [T,T,F] + let p1 = pred_leaf("price", Operator::Lt, 35, &schema); + // price >= 30 → full file would be [F,F,F,T,T]; subset [2,3,4] → [F,T,T] + let p2 = pred_leaf("price", Operator::GtEq, 30, &schema); + + // AND(p1, p2) on subset → [T,T,F] & [F,T,T] = [F,T,F] + let tree = BoolNode::And(vec![p1.clone(), p2.clone()]); + + let mut leaf_predicates: HashMap> = HashMap::new(); + for node in [&p1, &p2] { + if let BoolNode::Predicate(expr) = node { + let key = Arc::as_ptr(expr) as *const () as usize; + let pp = build_pruning_predicate(expr, schema.clone()).unwrap(); + leaf_predicates.insert(key, pp); + } + } + + let spt = StatsPruneTree::build_from_bool_node( + &tree, + &leaf_predicates, + &arc_meta, + &schema, + &rg_indices, + ); + + // rg_can_match is 3 elements (one per chunk RG), relative indexing. + assert_eq!(spt.rg_can_match.len(), 3); + // Position 0 → absolute RG 2 (price [20..29]): p1=T, p2=F → AND=F + // Position 1 → absolute RG 3 (price [30..39]): p1=T, p2=T → AND=T + // Position 2 → absolute RG 4 (price [40..49]): p1=F, p2=T → AND=F + assert_eq!(spt.rg_can_match, vec![false, true, false]); + + // Verify consumer-side reverse map lookup works correctly: + let rg_index_to_pos: HashMap = rg_indices + .iter() + .enumerate() + .map(|(pos, &idx)| (idx, pos)) + .collect(); + + // Absolute RG 3 should map to position 1 → can_match = true + let pos = rg_index_to_pos.get(&3).unwrap(); + assert_eq!(spt.rg_can_match[*pos], true); + + // Absolute RG 2 should map to position 0 → can_match = false + let pos = rg_index_to_pos.get(&2).unwrap(); + assert_eq!(spt.rg_can_match[*pos], false); + + // Absolute RG 0 (not in chunk) should have no entry + assert!(rg_index_to_pos.get(&0).is_none()); } } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/parquet_bridge.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/parquet_bridge.rs index 5231187a694e2..200e0f5fc3674 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/parquet_bridge.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/parquet_bridge.rs @@ -24,18 +24,21 @@ use std::time::{Duration, Instant}; use datafusion::arrow::datatypes::SchemaRef; use datafusion::common::Result; -use datafusion::datasource::physical_plan::parquet::metadata::DFParquetMetadata; +use datafusion::datasource::physical_plan::parquet::metadata::CachedParquetMetaData; use datafusion::datasource::physical_plan::parquet::{ ParquetAccessPlan, ParquetFileMetrics, ParquetFileReaderFactory, RowGroupAccess, }; use datafusion::datasource::physical_plan::ParquetSource; +use datafusion::execution::cache::cache_manager::CachedFileMetadataEntry; use datafusion::execution::cache::cache_manager::FileMetadataCache; use datafusion::execution::object_store::ObjectStoreUrl; use datafusion::execution::SendableRecordBatchStream; use datafusion::parquet::arrow::arrow_reader::{ArrowReaderOptions, RowSelection}; use datafusion::parquet::arrow::async_reader::AsyncFileReader; +use datafusion::parquet::arrow::async_reader::ParquetObjectReader; use datafusion::parquet::arrow::parquet_to_arrow_schema; use datafusion::parquet::file::metadata::ParquetMetaData; +use datafusion::parquet::file::metadata::{PageIndexPolicy, ParquetMetaDataReader}; use datafusion::physical_plan::metrics::ExecutionPlanMetricsSet; use datafusion::physical_plan::ExecutionPlan; use datafusion_datasource::file_scan_config::FileScanConfigBuilder; @@ -43,13 +46,24 @@ use datafusion_datasource::source::DataSourceExec; use datafusion_datasource::PartitionedFile; use futures::future::BoxFuture; use futures::FutureExt; +use native_bridge_common::log_debug; use object_store::{ObjectStore, ObjectStoreExt}; use prost::bytes::Bytes; // ── Parquet Metadata Loading ───────────────────────────────────────── -/// Load parquet metadata via DataFusion's `DFParquetMetadata`, consulting the -/// caller-supplied `FileMetadataCache`. +/// Load footer-only parquet metadata, consulting the caller-supplied cache. +/// +/// On a cache hit the cached (footer-only) metadata is returned with no IO. +/// On a cache miss we fetch with `PageIndexPolicy::Skip` — never fetching page +/// index bytes — then store the footer in the cache for future hits. +/// +/// Issues a `head()` to learn the file's size + last-modified. Callers that +/// already hold an authoritative [`ObjectMeta`] (e.g. from the listing snapshot +/// passed into a `ParquetFileReaderFactory`) should call +/// [`load_parquet_metadata_with_meta`] instead — the `head()` is a `File::open` +/// + `fstat` syscall per call on local stores, which adds up under repeated +/// per-file reader creation. pub async fn load_parquet_metadata( store: Arc, location: &object_store::path::Path, @@ -58,18 +72,75 @@ pub async fn load_parquet_metadata( let meta = store .head(location) .await - .map_err(|e| format!("object-store head {}: {}", location, e))?; + .map_err(|e| format!("object-store head {location}: {e}"))?; + load_parquet_metadata_with_meta(store, location, meta, metadata_cache).await +} + +/// Like [`load_parquet_metadata`] but uses a caller-supplied [`ObjectMeta`] +/// (size + last_modified) instead of issuing a `head()`. The cache validity +/// check ([`CachedFileMetadataEntry::is_valid_for`]) only consults `size` and +/// `last_modified`, both present in the listing's `ObjectMeta`, so no `head()` +/// is needed when the caller already has it. +pub async fn load_parquet_metadata_with_meta( + store: Arc, + location: &object_store::path::Path, + meta: object_store::ObjectMeta, + metadata_cache: Arc, +) -> std::result::Result<(SchemaRef, u64, Arc), String> { let size = meta.size; - let pq_meta = DFParquetMetadata::new(&*store, &meta) - .with_file_metadata_cache(Some(metadata_cache)) - .fetch_metadata() - .await - .map_err(|e| format!("load parquet metadata {}: {}", location, e))?; + // Cache hit — return footer-only metadata without any IO. + let pq_meta = if let Some(entry) = metadata_cache.get(location) { + if entry.is_valid_for(&meta) { + entry + .file_metadata + .as_any() + .downcast_ref::() + .map(|cached| Arc::clone(cached.parquet_metadata())) + } else { + None + } + } else { + None + }; + + // Cache miss — fetch metadata. When the scoped page-index cache is enabled, + // skip page index bytes (they are loaded lazily per-column by the scoped cache). + // When disabled (fallback mode), fetch the full page index so the metadata cache + // retains it and DataFusion's default page pruning path continues to work. + // Scoped enabled: skip page index bytes entirely (scoped cache handles it lazily). + // Scoped disabled (fallback): use Optional — reads page index if it falls within + // the same footer fetch range, without issuing a separate IO request for it. + let policy = if crate::cache::page_index::is_scoped_page_index_enabled() { + PageIndexPolicy::Skip + } else { + PageIndexPolicy::Optional + }; + let pq_meta = match pq_meta { + Some(m) => m, + None => { + let mut reader = ParquetObjectReader::new(Arc::clone(&store), location.clone()); + let fetched = Arc::new( + ParquetMetaDataReader::new() + .with_page_index_policy(policy) + .load_and_finish(&mut reader, size) + .await + .map_err(|e| format!("load parquet metadata {location}: {e}"))?, + ); + metadata_cache.put( + location, + CachedFileMetadataEntry::new( + meta, + Arc::new(CachedParquetMetaData::new(Arc::clone(&fetched))), + ), + ); + fetched + } + }; let file_meta = pq_meta.file_metadata(); let schema = parquet_to_arrow_schema(file_meta.schema_descr(), file_meta.key_value_metadata()) - .map_err(|e| format!("parquet_to_arrow_schema {}: {}", location, e))?; + .map_err(|e| format!("parquet_to_arrow_schema {location}: {e}"))?; Ok((Arc::new(schema), size, pq_meta)) } @@ -107,16 +178,22 @@ pub struct RowGroupStreamConfig { /// /// Predicate pushdown IS safe here — `RowSelection` is applied during decode, /// so the predicate sees only selected rows and indices stay aligned. +/// +/// `selectivity` is the fraction of rows in this RG that are candidates +/// (0.0 = no rows, 1.0 = all rows). Used to gate LC: highly selective +/// queries (low selectivity) benefit from LC's column-by-column decode +/// with filter pushdown. pub fn create_row_selection_stream( config: &RowGroupStreamConfig, rg_index: usize, selection: RowSelection, push_predicate: bool, + selectivity: f64, ) -> Result<(SendableRecordBatchStream, Arc)> { let num_rgs = config.metadata.num_row_groups(); let mut access_plan = ParquetAccessPlan::new_none(num_rgs); access_plan.set(rg_index, RowGroupAccess::Selection(selection)); - create_stream_with_access_plan(config, access_plan, push_predicate) + create_stream_with_access_plan(config, access_plan, push_predicate, selectivity) } /// Create a stream that reads a single row group with full scan. @@ -148,13 +225,15 @@ pub fn create_full_scan_stream( // rows with gaps?) so the caller's post-decode mask alignment stays // correct. Documented in `pr-reviews/EVALUATOR_HANDOFF.md`. access_plan.set(rg_index, RowGroupAccess::Scan); - create_stream_with_access_plan(config, access_plan, false) + // Full scan = selectivity 1.0 (all rows). + create_stream_with_access_plan(config, access_plan, false, 1.0) } fn create_stream_with_access_plan( config: &RowGroupStreamConfig, access_plan: ParquetAccessPlan, push_predicate: bool, + selectivity: f64, ) -> Result<(SendableRecordBatchStream, Arc)> { let partitioned_file = PartitionedFile::new(config.file_path.clone(), config.file_size) .with_extensions(Arc::new(access_plan)); @@ -180,9 +259,76 @@ fn create_stream_with_access_plan( } } - let mut config_builder = + // Liquid Cache engagement gate: wrap the ParquetSource with + // LiquidParquetSource when ALL projected columns are cacheable + // (numeric/date/timestamp/boolean) and no predicate column is a string. + // The opener decides per-file whether to STREAM or DELEGATE. + let use_lc = { + let lc_globally_enabled = crate::liquid_cache::LiquidOnlyRuntime::is_enabled_globally(); + let max_cols = crate::liquid_cache::lc_max_columns(); + let all_numeric_projection = config.projection.as_ref().map_or(false, |proj| { + !proj.is_empty() + && proj.len() <= max_cols + && proj.iter().all(|&idx| { + config.full_schema.fields().get(idx).map_or(false, |f| { + f.data_type().is_numeric() + || matches!( + f.data_type(), + datafusion::arrow::datatypes::DataType::Date32 + | datafusion::arrow::datatypes::DataType::Date64 + | datafusion::arrow::datatypes::DataType::Timestamp(_, _) + | datafusion::arrow::datatypes::DataType::Boolean + ) + }) + }) + }); + let predicate_has_string = config.predicate.as_ref().map_or(false, |pred| { + let referenced = datafusion::physical_expr::utils::collect_columns(pred); + referenced.iter().any(|col| { + config + .full_schema + .fields() + .get(col.index()) + .map_or(false, |f| { + matches!( + f.data_type(), + datafusion::arrow::datatypes::DataType::Utf8 + | datafusion::arrow::datatypes::DataType::Utf8View + | datafusion::arrow::datatypes::DataType::LargeUtf8 + | datafusion::arrow::datatypes::DataType::Binary + | datafusion::arrow::datatypes::DataType::BinaryView + | datafusion::arrow::datatypes::DataType::LargeBinary + ) + }) + }) + }); + let result = lc_globally_enabled && all_numeric_projection && !predicate_has_string; + log_debug!( + "[parquet_bridge] gate: selectivity={:.3}, all_numeric_proj={}, pred_has_string={}, use_lc={}", + selectivity, + all_numeric_projection, + predicate_has_string, + result, + ); + result + }; + + let mut config_builder = if use_lc { + if let Some(cache_ref) = crate::liquid_cache::LiquidOnlyRuntime::cache_ref_globally() { + let liquid_source = liquid_cache_datafusion::LiquidParquetSource::from_parquet_source( + parquet_source, + cache_ref, + ); + FileScanConfigBuilder::new(config.store_url.clone(), Arc::new(liquid_source)) + .with_file(partitioned_file) + } else { + FileScanConfigBuilder::new(config.store_url.clone(), Arc::new(parquet_source)) + .with_file(partitioned_file) + } + } else { FileScanConfigBuilder::new(config.store_url.clone(), Arc::new(parquet_source)) - .with_file(partitioned_file); + .with_file(partitioned_file) + }; if let Some(ref proj) = config.projection { // Empty projection (e.g. COUNT(*)) is honoured as "read no @@ -213,7 +359,11 @@ impl CachedMetadataReaderFactory { metadata: Arc, io_stats: Arc, ) -> Self { - Self { store, metadata, io_stats } + Self { + store, + metadata, + io_stats, + } } } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/partitioning.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/partitioning.rs index d1d6590e53b60..193d9a68dee5f 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/partitioning.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/partitioning.rs @@ -226,8 +226,11 @@ pub fn compute_assignments_one_per_segment( segments: &[SegmentFileInfo], layouts: &[SegmentLayout], ) -> Vec { - debug_assert_eq!(segments.len(), layouts.len(), - "segments and layouts must match (one entry per segment)"); + debug_assert_eq!( + segments.len(), + layouts.len(), + "segments and layouts must match (one entry per segment)" + ); if segments.is_empty() { return Vec::new(); } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/row_id_injection.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/row_id_injection.rs index 08ff4ebb6f82f..a6348ee652181 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/row_id_injection.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/row_id_injection.rs @@ -63,7 +63,9 @@ pub fn inject_row_ids( ctx.position_map.as_ref(), ctx.base, ); - Arc::new(Int64Array::from_iter_values(ids.into_iter().map(|id| id as i64))) + Arc::new(Int64Array::from_iter_values( + ids.into_iter().map(|id| id as i64), + )) } }; @@ -77,14 +79,17 @@ pub fn inject_row_ids( _ => batch_schema .index_of(field.name()) .map(|col_idx| Arc::clone(output.column(col_idx))) - .unwrap_or_else(|_| datafusion::arrow::array::new_null_array(field.data_type(), num_surviving)), + .unwrap_or_else(|_| { + datafusion::arrow::array::new_null_array(field.data_type(), num_surviving) + }), }) .collect(); RecordBatch::try_new_with_options( schema.clone(), columns, - &datafusion::arrow::record_batch::RecordBatchOptions::new().with_row_count(Some(num_surviving)), + &datafusion::arrow::record_batch::RecordBatchOptions::new() + .with_row_count(Some(num_surviving)), ) .map_err(|e| datafusion::common::DataFusionError::ArrowError(Box::new(e), None)) } @@ -100,29 +105,23 @@ fn compute_row_ids( base: u64, ) -> Vec { match eval_mask { - Some(mask) => { - (0..batch_len) - .filter(|&i| mask.is_valid(i) && mask.value(i)) - .map(|i| position_to_global_id(batch_start_delivered + i, pm, base)) - .collect() - } + Some(mask) => (0..batch_len) + .filter(|&i| mask.is_valid(i) && mask.value(i)) + .map(|i| position_to_global_id(batch_start_delivered + i, pm, base)) + .collect(), None => match current_mask { - Some(candidate_mask) => { - (0..batch_len) - .filter(|&i| { - let mi = mask_offset_before + i; - mi < candidate_mask.len() - && candidate_mask.is_valid(mi) - && candidate_mask.value(mi) - }) - .map(|i| position_to_global_id(batch_start_delivered + i, pm, base)) - .collect() - } - None => { - (0..batch_len) - .map(|i| position_to_global_id(batch_start_delivered + i, pm, base)) - .collect() - } + Some(candidate_mask) => (0..batch_len) + .filter(|&i| { + let mi = mask_offset_before + i; + mi < candidate_mask.len() + && candidate_mask.is_valid(mi) + && candidate_mask.value(mi) + }) + .map(|i| position_to_global_id(batch_start_delivered + i, pm, base)) + .collect(), + None => (0..batch_len) + .map(|i| position_to_global_id(batch_start_delivered + i, pm, base)) + .collect(), }, } } @@ -135,6 +134,11 @@ fn position_to_global_id(delivered_idx: usize, pm: Option<&PositionMap>, base: u None => delivered_idx, }; let id = base + rg_pos as u64; - debug_assert!(id >= base, "position_to_global_id: underflow base={} rg_pos={}", base, rg_pos); + debug_assert!( + id >= base, + "position_to_global_id: underflow base={} rg_pos={}", + base, + rg_pos + ); id } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/segment_info.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/segment_info.rs index 0c3f7025965ed..b98562c0e6f12 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/segment_info.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/segment_info.rs @@ -77,9 +77,13 @@ pub async fn build_segments( // RuntimeEnv that `infer_schema` uses below shares this data when // both are pointed at the same object location, so this isn't a // duplicated cold fetch in practice. - let (file_schema, size, pq_meta) = parquet_bridge::load_parquet_metadata( + // `meta` is the authoritative listing snapshot — pass it through so the + // footer load resolves from cache without a redundant `head()` syscall + // per segment. + let (file_schema, size, pq_meta) = parquet_bridge::load_parquet_metadata_with_meta( Arc::clone(&store), &meta.location, + meta.clone(), Arc::clone(&metadata_cache), ) .await @@ -291,7 +295,9 @@ mod tests { use datafusion::execution::cache::DefaultFilesMetadataCache; use datafusion::execution::context::SessionContext; use datafusion::parquet::arrow::ArrowWriter; - use object_store::{local::LocalFileSystem, path::Path as ObjectPath, ObjectStore, ObjectStoreExt}; + use object_store::{ + local::LocalFileSystem, path::Path as ObjectPath, ObjectStore, ObjectStoreExt, + }; use tempfile::tempdir; /// Mirror of what `CacheManager::try_new` auto-installs when no custom diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/stream.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/stream.rs index bda6dd599593c..ce4ea0fc09878 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/stream.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/stream.rs @@ -33,7 +33,6 @@ use std::pin::Pin; use std::sync::Arc; use std::task::{Context, Poll}; -use native_bridge_common::log_debug; use datafusion::arrow::array::{Array, BooleanArray, UInt64Array}; use datafusion::arrow::compute::filter_record_batch; use datafusion::arrow::datatypes::{DataType, Field, Schema, SchemaRef}; @@ -48,6 +47,7 @@ use datafusion::physical_plan::{ }; use datafusion_common::DataFusionError; use futures::{Future, Stream}; +use native_bridge_common::log_debug; use tokio::task::JoinHandle; use super::eval::{PrefetchedRg, RowGroupBitsetSource}; @@ -66,11 +66,12 @@ pub struct RowGroupInfo { pub num_rows: i64, } - -/// Test-only override for the per-RG `min_skip_run` selectivity heuristic. -/// `IndexedStream` normally picks `min_skip_run` from candidate -/// selectivity; setting `force_strategy` to one of these variants pins the -/// choice so tests can exercise either extreme. +/// Override for the per-RG `min_skip_run` selectivity heuristic. `IndexedStream` +/// normally picks `min_skip_run` from candidate selectivity; setting +/// `force_strategy` to one of these variants pins the choice node-wide. +/// +/// Backed by the `datafusion.indexed.force_strategy` cluster setting (wire +/// `-1` = `None` = let selectivity decide). #[derive(Debug, Clone, Copy, PartialEq)] pub enum FilterStrategy { /// Force row-granular selection (`min_skip_run = 1`). @@ -134,6 +135,9 @@ struct IndexReader { dynamic_prune_ctx: Option, /// Count of RGs skipped at prefetch time (before the Lucene eval). dynamic_filter_rg_pruned_at_prefetch: Option, + /// Per-query cancellation token. Checked before each row group is dispatched + /// and before the evaluator runs in a queued blocking job. `None` disables cancellation. + cancellation_token: Option, } impl IndexReader { @@ -147,6 +151,7 @@ impl IndexReader { prefetch_wait_count: Option, metadata: Option>, dynamic_filter_rg_pruned_at_prefetch: Option, + cancellation_token: Option, ) -> Self { Self { evaluator, @@ -162,9 +167,19 @@ impl IndexReader { metadata, dynamic_prune_ctx: None, dynamic_filter_rg_pruned_at_prefetch, + cancellation_token, } } + /// True when this query's task has been cancelled. Cheap (one relaxed + /// atomic load); `None` token (untracked/test) is never cancelled. + #[inline] + fn is_cancelled(&self) -> bool { + self.cancellation_token + .as_ref() + .is_some_and(|t| t.is_cancelled()) + } + /// Result of a prefetch task. `Pruned` is distinct from `Ok(None)` /// (empty candidate set): it means the dynamic filter excluded the RG /// before the Lucene eval ran, so it must be attributed to the @@ -174,11 +189,20 @@ impl IndexReader { row_groups: &[RowGroupInfo], rg_idx: usize, doc_range: Option<(i32, i32)>, - prune: Option<(super::dynamic_filter::RgPruningContext, Arc)>, + prune: Option<( + super::dynamic_filter::RgPruningContext, + Arc, + )>, + cancellation_token: Option<&tokio_util::sync::CancellationToken>, ) -> std::result::Result { if rg_idx >= row_groups.len() { return Ok(PrefetchOutcome::Empty); } + // Bail before the expensive evaluator.prefetch_rg if the query was + // cancelled after this blocking job was queued but before it started. + if cancellation_token.is_some_and(|t| t.is_cancelled()) { + return Err("query cancelled".to_string()); + } let rg = row_groups[rg_idx].clone(); // Prefetch-phase dynamic-filter prune: if the snapshot-so-far proves @@ -202,7 +226,10 @@ impl IndexReader { } match evaluator.prefetch_rg(&rg, min_doc, max_doc)? { None => Ok(PrefetchOutcome::Empty), - Some(prefetched) => Ok(PrefetchOutcome::Fetched(PrefetchedRowGroup { rg, prefetched })), + Some(prefetched) => Ok(PrefetchOutcome::Fetched(PrefetchedRowGroup { + rg, + prefetched, + })), } } @@ -219,8 +246,16 @@ impl IndexReader { (Some(ctx), Some(md)) => Some((ctx, Arc::clone(md))), _ => None, }; + let token = self.cancellation_token.clone(); let handle = tokio::task::spawn_blocking(move || { - Self::fetch_row_group(&evaluator, &row_groups, rg_idx, doc_range, prune) + Self::fetch_row_group( + &evaluator, + &row_groups, + rg_idx, + doc_range, + prune, + token.as_ref(), + ) }); self.pending_prefetch = Some(handle); } @@ -230,6 +265,12 @@ impl IndexReader { cx: &mut Context<'_>, ) -> Poll, DataFusionError>> { loop { + // Bail before dispatching the next row group if the query is cancelled. + if self.is_cancelled() { + return Poll::Ready(Err(DataFusionError::Execution( + "query cancelled".to_string(), + ))); + } if self.current_rg_idx >= self.row_groups.len() { return Poll::Ready(Ok(None)); } @@ -286,12 +327,10 @@ impl IndexReader { .cloned() .or_else(|| payload.downcast_ref::<&str>().map(|s| s.to_string())) .unwrap_or_else(|| "unknown panic".into()); - return Poll::Ready(Err(DataFusionError::Execution( - format!( - "prefetch for row group {} panicked: {}", - self.current_rg_idx, panic_msg - ), - ))); + return Poll::Ready(Err(DataFusionError::Execution(format!( + "prefetch for row group {} panicked: {}", + self.current_rg_idx, panic_msg + )))); } // Task was cancelled (runtime shutting down) — retry once self.start_prefetch(self.current_rg_idx); @@ -357,6 +396,10 @@ pub struct IndexedExec { /// parquet statistics cannot satisfy the (tightening) predicate. `None` /// when no dynamic filter was pushed to this query. pub(crate) dynamic_filter: Option>, + /// Per-query cancellation token. When cancelled, `IndexReader` stops + /// dispatching further row groups and `IndexedStream` stops draining. + /// `None` disables cancellation checks. + pub(crate) cancellation_token: Option, } impl fmt::Debug for IndexedExec { @@ -428,7 +471,10 @@ impl ExecutionPlan for IndexedExec { self.stream_metrics.prefetch_wait_time.clone(), self.stream_metrics.prefetch_wait_count.clone(), Some(Arc::clone(&self.metadata)), - self.stream_metrics.dynamic_filter_rg_pruned_at_prefetch.clone(), + self.stream_metrics + .dynamic_filter_rg_pruned_at_prefetch + .clone(), + self.cancellation_token.clone(), ); Ok(Box::pin(IndexedStream::new( self.schema.clone(), @@ -442,7 +488,6 @@ impl ExecutionPlan for IndexedExec { Arc::clone(&self.metadata), self.predicate.clone(), self.stream_metrics.clone(), - self.query_config.force_pushdown, self.query_config.force_strategy, self.query_config.min_skip_run_default, self.query_config.min_skip_run_selectivity_threshold, @@ -489,17 +534,20 @@ struct IndexedStream { predicate: Option>, initialized: bool, metrics: StreamMetrics, - force_pushdown: Option, + /// Optional override for the per-RG `min_skip_run` choice, backed by the + /// `datafusion.indexed.force_strategy` cluster setting — see `pick_min_skip_run`. force_strategy: Option, - /// Baseline `min_skip_run` used when neither selectivity nor - /// `force_strategy` drives the choice. Extracted once from - /// `DatafusionQueryConfig` so the hot path reads a local `usize`. + /// Baseline `min_skip_run` used when selectivity drives the choice (the + /// only path in production; tests may pin it via `force_strategy`). + /// Extracted once from `DatafusionQueryConfig` so the hot path reads a + /// local `usize`. min_skip_run_default: usize, /// Below this candidate selectivity, pin `min_skip_run = 1` /// (row-granular selection). Same hot-path discipline as above. min_skip_run_selectivity_threshold: f64, /// Whether to ask parquet to apply residual predicates during decode. - /// `force_pushdown` still takes priority when set. + /// Node-wide default from the `datafusion.indexed.pushdown_filters` + /// cluster setting. indexed_pushdown_filters: bool, evaluator: Arc, /// Output coalescer — combines small post-filter batches up to @@ -542,7 +590,6 @@ impl IndexedStream { metadata: Arc, predicate: Option>, metrics: StreamMetrics, - force_pushdown: Option, force_strategy: Option, min_skip_run_default: usize, min_skip_run_selectivity_threshold: f64, @@ -554,12 +601,9 @@ impl IndexedStream { dynamic_filter: Option>, ) -> Self { let evaluator = Arc::clone(&index_reader.evaluator); - let batch_coalescer = - LimitedBatchCoalescer::new(schema.clone(), target_batch_size, None); - let dynamic_rg_pruner = super::dynamic_filter::DynamicRgPruner::new( - dynamic_filter, - full_schema.clone(), - ); + let batch_coalescer = LimitedBatchCoalescer::new(schema.clone(), target_batch_size, None); + let dynamic_rg_pruner = + super::dynamic_filter::DynamicRgPruner::new(dynamic_filter, full_schema.clone()); Self { schema, full_schema, @@ -584,7 +628,6 @@ impl IndexedStream { predicate, initialized: false, metrics, - force_pushdown, force_strategy, min_skip_run_default, min_skip_run_selectivity_threshold, @@ -600,6 +643,30 @@ impl IndexedStream { } } + /// Per-RG `min_skip_run` decision. + /// + /// When `force_strategy` is set (via the `datafusion.indexed.force_strategy` + /// cluster setting) it pins the choice to either extreme (`RowSelection` → 1, + /// `BooleanMask` → whole-RG select), bypassing the heuristic. + /// + /// Otherwise the choice is selectivity-driven: at low selectivity every gap + /// is worth skipping (`min_skip_run = 1`, row-granular); at higher selectivity + /// noisy short gaps would explode the selector Vec, so absorb anything shorter + /// than `min_skip_run_default` (block-granular). + fn pick_min_skip_run(&self, num_candidates: usize, rg_num_rows: usize) -> usize { + match self.force_strategy { + Some(FilterStrategy::RowSelection) => return 1, + Some(FilterStrategy::BooleanMask) => return rg_num_rows + 1, + None => {} + } + let selectivity = num_candidates as f64 / rg_num_rows as f64; + if selectivity < self.min_skip_run_selectivity_threshold { + 1 + } else { + self.min_skip_run_default + } + } + fn bridge_config(&self) -> RowGroupStreamConfig { RowGroupStreamConfig { file_path: self.object_path.to_string(), @@ -623,12 +690,14 @@ impl IndexedStream { rg: &RowGroupInfo, selection: RowSelection, push_predicate: bool, + selectivity: f64, ) -> Result<(SendableRecordBatchStream, Arc)> { parquet_bridge::create_row_selection_stream( &self.bridge_config(), rg.index, selection, push_predicate, + selectivity, ) } @@ -823,6 +892,13 @@ impl IndexedStream { cx: &mut Context<'_>, ) -> Poll>> { loop { + // Stop draining on cancellation; surfaces as a query-level error (no partial results). + if self.index_reader.is_cancelled() { + return Poll::Ready(Some(Err(DataFusionError::Execution( + "query cancelled".to_string(), + )))); + } + // 1. Drain any completed batch from the coalescer first. if let Some(batch) = self.batch_coalescer.next_completed_batch() { if let Some(ref counter) = self.metrics.output_rows { @@ -836,7 +912,12 @@ impl IndexedStream { // 2. If upstream is done and coalescer has drained, we're done. if self.coalescer_finished && self.batch_coalescer.is_empty() { - log_debug!("[scf-segment-done] file={} row_groups={} elapsed={:?}", self.object_path.filename().unwrap_or("?"), self.index_reader.row_groups.len(), self.stream_start.unwrap().elapsed()); + log_debug!( + "[scf-segment-done] file={} row_groups={} elapsed={:?}", + self.object_path.filename().unwrap_or("?"), + self.index_reader.row_groups.len(), + self.stream_start.unwrap().elapsed() + ); return Poll::Ready(None); } @@ -856,7 +937,12 @@ impl IndexedStream { if self.coalescer_finished { // Unreachable in practice — step 1 already drained or // step 2 already returned. Defensive. - log_debug!("[scf-segment-done] file={} row_groups={} elapsed={:?}", self.object_path.filename().unwrap_or("?"), self.index_reader.row_groups.len(), self.stream_start.unwrap().elapsed()); + log_debug!( + "[scf-segment-done] file={} row_groups={} elapsed={:?}", + self.object_path.filename().unwrap_or("?"), + self.index_reader.row_groups.len(), + self.stream_start.unwrap().elapsed() + ); return Poll::Ready(None); } @@ -991,28 +1077,17 @@ impl IndexedStream { self.current_rg_context = Some(prefetched.prefetched.context); self.batch_offset = 0; - // Decide min_skip_run for this RG. - // - // - `force_strategy = RowSelection`: row-granular - // (min_skip_run = 1) — "sparse" path. - // - `force_strategy = BooleanMask`: disable skipping - // (min_skip_run > rg.num_rows) — full scan. - // - otherwise: pick based on selectivity. At low - // selectivity every gap is worth skipping (1); at - // higher selectivity noisy short gaps would explode - // the selector Vec, so absorb anything smaller than - // the default block size. - let selectivity = candidates.len() as f64 / rg.num_rows as f64; - let min_skip_run = match self.force_strategy { - Some(FilterStrategy::RowSelection) => 1, - Some(FilterStrategy::BooleanMask) => rg.num_rows as usize + 1, - None => { - if selectivity < self.min_skip_run_selectivity_threshold { - 1 - } else { - self.min_skip_run_default - } - } + // Decide min_skip_run for this RG (see `pick_min_skip_run`). + let min_skip_run = + self.pick_min_skip_run(candidates.len() as usize, rg.num_rows as usize); + + // Candidate selectivity for this RG — forwarded to + // parquet_bridge so the Liquid Cache gate can log/decide + // per-RG engagement. + let selectivity = if rg.num_rows > 0 { + candidates.len() as f64 / rg.num_rows as f64 + } else { + 1.0 }; // Metrics: track which regime we landed in, using the @@ -1087,12 +1162,14 @@ impl IndexedStream { // dropped (supports_filters_pushdown = Exact) so // there's no safety net if pushdown misbehaves on a // UDF-containing predicate. - let base_push = self.force_pushdown.unwrap_or(self.indexed_pushdown_filters); + // Node-wide `indexed_pushdown_filters` setting, gated by + // alignment/forbid checks below. let alignment_risk = min_skip_run != 1 && self.evaluator.needs_row_mask(); - let push = - base_push && !alignment_risk && !self.evaluator.forbid_parquet_pushdown(); + let push = self.indexed_pushdown_filters + && !alignment_risk + && !self.evaluator.forbid_parquet_pushdown(); - match self.create_row_selection_stream(&rg, selection, push) { + match self.create_row_selection_stream(&rg, selection, push, selectivity) { Ok((stream, plan)) => { if let Some(ref timer) = self.metrics.parquet_time { timer.add_duration(t_plan.elapsed()); @@ -1164,7 +1241,7 @@ impl RecordBatchStream for IndexedStream { #[cfg(test)] mod tests { use super::*; - use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -1228,6 +1305,7 @@ mod tests { None, None, None, + None, ); // Poll the reader — should complete with an error within the timeout. @@ -1240,10 +1318,11 @@ mod tests { match &r { std::task::Poll::Pending => std::task::Poll::Ready(None), std::task::Poll::Ready(v) => std::task::Poll::Ready(Some( - v.as_ref().map(|_| ()).map_err(|e| e.to_string()) + v.as_ref().map(|_| ()).map_err(|e| e.to_string()), )), } - }).await; + }) + .await; if let Some(result) = poll_result { return result; } @@ -1271,4 +1350,139 @@ mod tests { Err(e) => panic!("Tokio JoinError: {}", e), }; } + + // `spawn_blocking` jobs cannot be aborted — tokio's task abort only cancels + // async tasks at `.await` points. The tests below verify that the cancellation + // token checkpoint stops new row groups from being dispatched. + + /// Evaluator whose `prefetch_rg` busy-spins (no `.await`/sleep — genuine + /// non-yielding CPU work like a real Lucene/Arrow scan) for a fixed duration, + /// counting how many row groups it actually evaluated. + use roaring::RoaringBitmap; + + struct SpinningEvaluator { + spin: Duration, + rgs_evaluated: Arc, + } + + impl RowGroupBitsetSource for SpinningEvaluator { + fn prefetch_rg( + &self, + rg: &RowGroupInfo, + _min_doc: i32, + _max_doc: i32, + ) -> Result, String> { + // Non-yielding busy work — mirrors a synchronous scan/decode poll. + let deadline = Instant::now() + self.spin; + while Instant::now() < deadline { + std::hint::spin_loop(); + } + self.rgs_evaluated.fetch_add(1, Ordering::SeqCst); + // Produce a non-empty candidate set so the RG is "Fetched". + let mut candidates = RoaringBitmap::new(); + candidates.insert_range(0..rg.num_rows as u32); + Ok(Some(PrefetchedRg::without_context(candidates, 0))) + } + + fn on_batch_mask( + &self, + _rg_state: &dyn std::any::Any, + _rg_first_row: i64, + _position_map: &PositionMap, + _batch_offset: usize, + _batch_len: usize, + _batch: &RecordBatch, + ) -> Result, String> { + Ok(None) + } + } + + /// Cancelling mid-scan stops `poll_next_row_group` from dispatching further + /// row groups. At most one already-in-flight `spawn_blocking` job (non-abortable) + /// may still complete; total evaluated is bounded to `evaluated_at_cancel + 1`. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn cancel_stops_row_group_dispatch() { + let token = tokio_util::sync::CancellationToken::new(); + let rgs_evaluated = Arc::new(AtomicUsize::new(0)); + + let evaluator = Arc::new(SpinningEvaluator { + spin: Duration::from_millis(200), + rgs_evaluated: rgs_evaluated.clone(), + }); + + // 8 row groups × 200ms spin each = ~1.6s of work if cancellation is ignored. + let row_groups: Vec = (0..8) + .map(|i| RowGroupInfo { + index: i, + first_row: (i as i64) * 100, + num_rows: 100, + }) + .collect(); + + let mut reader = IndexReader::new( + evaluator, + row_groups, + None, + None, + None, + None, + None, + None, + Some(token.clone()), + ); + + // Drive the reader exactly like IndexedStream does. Records whether the + // reader terminated via the cancellation Err path. + let cancelled_err = Arc::new(AtomicBool::new(false)); + let cancelled_err_drv = cancelled_err.clone(); + let driver = tokio::spawn(async move { + loop { + let done = futures::future::poll_fn(|cx| match reader.poll_next_row_group(cx) { + Poll::Pending => Poll::Ready(false), + Poll::Ready(Ok(None)) => Poll::Ready(true), + Poll::Ready(Ok(Some(_))) => Poll::Ready(false), + Poll::Ready(Err(_)) => { + cancelled_err_drv.store(true, Ordering::SeqCst); + Poll::Ready(true) + } + }) + .await; + if done { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }); + + // Let a couple of row groups start evaluating, then cancel. + tokio::time::sleep(Duration::from_millis(250)).await; + let evaluated_at_cancel = rgs_evaluated.load(Ordering::SeqCst); + token.cancel(); + + tokio::time::timeout(Duration::from_secs(10), driver) + .await + .expect("reader should terminate promptly after cancel") + .expect("driver task panicked"); + + let total_evaluated = rgs_evaluated.load(Ordering::SeqCst); + + // At most one already-in-flight spawn_blocking job (non-abortable) can + // complete after cancel; all others must be skipped by the checkpoint. + assert!( + total_evaluated <= evaluated_at_cancel + 1, + "cancelled reader kept evaluating row groups: evaluated_at_cancel={}, total_evaluated={}", + evaluated_at_cancel, + total_evaluated + ); + assert!( + cancelled_err.load(Ordering::SeqCst), + "reader should terminate via the cancellation Err path" + ); + // Sanity: it did NOT run all 8 row groups. + assert!( + total_evaluated < 8, + "expected early termination, but all row groups were evaluated ({})", + total_evaluated + ); + } } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/substrait_to_tree.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/substrait_to_tree.rs index a71901636d0ad..7d8165cf35011 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/substrait_to_tree.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/substrait_to_tree.rs @@ -62,7 +62,10 @@ pub fn plan_requests_row_ids(plan: &LogicalPlan) -> bool { Expr::Column(col) => col.name() == crate::ROW_ID_COLUMN_NAME, _ => false, }), - _ => plan.inputs().iter().any(|child| plan_requests_row_ids(child)), + _ => plan + .inputs() + .iter() + .any(|child| plan_requests_row_ids(child)), } } @@ -85,7 +88,7 @@ pub enum FilterClass { /// now that `Predicate` leaves carry `Arc` directly. #[derive(Debug)] pub struct ExtractionResult { - pub tree: BoolNode, + pub tree: Arc, } /// Extract the scan-level filter from a logical plan, skipping HAVING/window @@ -149,7 +152,9 @@ pub fn expr_to_bool_tree( } else { tree }; - Ok(ExtractionResult { tree }) + Ok(ExtractionResult { + tree: Arc::new(tree), + }) } fn convert_expr( @@ -238,10 +243,18 @@ fn convert_delegation_possible_function( let unqualified = strip_column_qualifiers(&args[0]); let original_expr = state .create_physical_expr(unqualified, df_schema) - .map_err(|e| format!("create_physical_expr for {} arg 0: {}", DELEGATION_POSSIBLE_FUNCTION_NAME, e))?; - let return_type = original_expr - .data_type(schema) - .map_err(|e| format!("data_type for {} arg 0: {}", DELEGATION_POSSIBLE_FUNCTION_NAME, e))?; + .map_err(|e| { + format!( + "create_physical_expr for {} arg 0: {}", + DELEGATION_POSSIBLE_FUNCTION_NAME, e + ) + })?; + let return_type = original_expr.data_type(schema).map_err(|e| { + format!( + "data_type for {} arg 0: {}", + DELEGATION_POSSIBLE_FUNCTION_NAME, e + ) + })?; if return_type != DataType::Boolean { return Err(format!( "{} arg 0 must be boolean-valued, got {:?}", @@ -321,7 +334,9 @@ fn is_and_only_collector_tree(tree: &BoolNode) -> bool { match tree { BoolNode::And(children) => children.iter().all(is_and_only_collector_tree), // Leaves are trivially fine (no Collector or DelegationPossible below them). - BoolNode::Collector { .. } | BoolNode::Predicate(_) | BoolNode::DelegationPossible { .. } => true, + BoolNode::Collector { .. } + | BoolNode::Predicate(_) + | BoolNode::DelegationPossible { .. } => true, // OR/NOT containing any Collector OR any DelegationPossible → Tree path. // For DelegationPossible under OR/NOT specifically, Tree path currently // routes to the Phase 3/7 unimplemented stubs — failing loud is preferred @@ -366,9 +381,7 @@ impl IndexFilterUdf { fn new() -> Self { Self { signature: Signature::one_of( - vec![ - TypeSignature::Exact(vec![DataType::Int32]), - ], + vec![TypeSignature::Exact(vec![DataType::Int32])], Volatility::Immutable, ), } @@ -427,7 +440,10 @@ impl DelegationPossibleUdf { Self { // Args: (originalPredicate: Boolean, annotationId: Int32). signature: Signature::one_of( - vec![TypeSignature::Exact(vec![DataType::Boolean, DataType::Int32])], + vec![TypeSignature::Exact(vec![ + DataType::Boolean, + DataType::Int32, + ])], Volatility::Immutable, ), } @@ -515,7 +531,7 @@ mod tests { fn simple_predicate() { let expr = col("price").gt(lit(100i32)); let r = expr_to_bool_tree(&expr, &test_schema(), &test_state()).unwrap(); - assert!(matches!(r.tree, BoolNode::Predicate(_))); + assert!(matches!(*r.tree, BoolNode::Predicate(_))); } #[test] @@ -527,35 +543,35 @@ mod tests { Box::new(col("price")), )); let r = expr_to_bool_tree(&expr, &test_schema(), &test_state()).unwrap(); - assert!(matches!(r.tree, BoolNode::Predicate(_))); + assert!(matches!(*r.tree, BoolNode::Predicate(_))); } #[test] fn and_of_predicates() { let expr = col("price").gt(lit(100i32)).and(col("qty").lt(lit(50i32))); let r = expr_to_bool_tree(&expr, &test_schema(), &test_state()).unwrap(); - assert!(matches!(r.tree, BoolNode::And(_))); + assert!(matches!(*r.tree, BoolNode::And(_))); } #[test] fn not_predicate() { let expr = Expr::Not(Box::new(col("active").eq(lit(true)))); let r = expr_to_bool_tree(&expr, &test_schema(), &test_state()).unwrap(); - assert!(matches!(r.tree, BoolNode::Not(_))); + assert!(matches!(*r.tree, BoolNode::Not(_))); } #[test] fn in_list_expression_is_accepted() { let expr = col("price").in_list(vec![lit(5i32), lit(10i32), lit(15i32)], false); let r = expr_to_bool_tree(&expr, &test_schema(), &test_state()).unwrap(); - assert!(matches!(r.tree, BoolNode::Predicate(_))); + assert!(matches!(*r.tree, BoolNode::Predicate(_))); } #[test] fn is_null_expression_is_accepted() { let expr = Expr::IsNull(Box::new(col("price"))); let r = expr_to_bool_tree(&expr, &test_schema(), &test_state()).unwrap(); - assert!(matches!(r.tree, BoolNode::Predicate(_))); + assert!(matches!(*r.tree, BoolNode::Predicate(_))); } #[test] @@ -565,9 +581,9 @@ mod tests { let r = expr_to_bool_tree(&expr, &test_schema(), &test_state()).unwrap(); // BETWEEN may desugar into And internally or stay as-is; either // shape is accepted so long as the result is boolean-valued. - match r.tree { + match *r.tree { BoolNode::Predicate(_) | BoolNode::And(_) => {} - other => panic!("expected Predicate or And, got {:?}", other), + ref other => panic!("expected Predicate or And, got {:?}", other), } } @@ -576,7 +592,7 @@ mod tests { // (price + 10) > 100 — our old converter would reject this. let expr = (col("price") + lit(10i32)).gt(lit(100i32)); let r = expr_to_bool_tree(&expr, &test_schema(), &test_state()).unwrap(); - assert!(matches!(r.tree, BoolNode::Predicate(_))); + assert!(matches!(*r.tree, BoolNode::Predicate(_))); } #[test] @@ -597,7 +613,7 @@ mod tests { vec![lit(ScalarValue::Int32(Some(42)))], )); let r = expr_to_bool_tree(&expr, &test_schema(), &test_state()).unwrap(); - match r.tree { + match *r.tree { BoolNode::Collector { annotation_id } => { assert_eq!(annotation_id, 42); } @@ -621,15 +637,13 @@ mod tests { Box::new(or_branch), )); let r = expr_to_bool_tree(&expr, &test_schema(), &test_state()).unwrap(); - assert!(matches!(r.tree, BoolNode::And(_))); + assert!(matches!(*r.tree, BoolNode::And(_))); } // ── classify_filter ────────────────────────────────────────────── fn collector(id: i32) -> BoolNode { - BoolNode::Collector { - annotation_id: id, - } + BoolNode::Collector { annotation_id: id } } fn dummy_predicate() -> BoolNode { // A stand-in Predicate leaf — classify only cares about shape, diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/table_provider.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/table_provider.rs index ec390e9d357ba..cb051ec0eec3e 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/table_provider.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/table_provider.rs @@ -28,6 +28,7 @@ use std::fmt; use std::sync::Arc; use async_trait::async_trait; +use datafusion::arrow::compute::SortOptions; use datafusion::arrow::datatypes::{DataType, Field, Schema, SchemaRef}; use datafusion::catalog::{Session, TableProvider}; use datafusion::common::{Result, Statistics}; @@ -35,14 +36,17 @@ use datafusion::datasource::TableType; use datafusion::execution::SendableRecordBatchStream; use datafusion::logical_expr::{Expr, TableProviderFilterPushDown}; use datafusion::parquet::file::metadata::ParquetMetaData; -use datafusion::physical_expr::{EquivalenceProperties, LexOrdering, Partitioning, PhysicalSortExpr}; use datafusion::physical_expr::expressions::col as physical_col; -use datafusion::arrow::compute::SortOptions; +use datafusion::physical_expr::{ + EquivalenceProperties, LexOrdering, Partitioning, PhysicalSortExpr, +}; +use datafusion::physical_optimizer::pruning::PruningPredicate; use datafusion::physical_plan::execution_plan::{Boundedness, EmissionType}; -use datafusion::physical_plan::metrics::{Count, ExecutionPlanMetricsSet, MetricBuilder, MetricsSet}; +use datafusion::physical_plan::metrics::{ + Count, ExecutionPlanMetricsSet, MetricBuilder, MetricsSet, +}; use datafusion::physical_plan::{DisplayAs, DisplayFormatType, ExecutionPlan, PlanProperties}; use datafusion_common::DataFusionError; -use datafusion::physical_optimizer::pruning::PruningPredicate; use datafusion::physical_plan::stream::RecordBatchStreamAdapter; use futures::StreamExt; @@ -55,7 +59,7 @@ use super::partitioning::{ compute_assignments, compute_assignments_one_per_segment, segments_chain_on_sort_key, PartitionAssignment, SegmentChunk, SegmentLayout, }; -use super::stream::{FilterStrategy, IndexedExec, RowGroupInfo}; +use super::stream::{IndexedExec, RowGroupInfo}; use crate::datafusion_query_config::DatafusionQueryConfig; use crate::indexed_table::metrics::StreamMetrics; use crate::indexed_table::page_pruner::StatsPruneTree; @@ -113,7 +117,7 @@ pub type EvaluatorFactory = Arc< &SegmentFileInfo, &SegmentChunk, &StreamMetrics, - Option<&StatsPruneTree>, + Option<&Arc>, ) -> Result, String> + Send + Sync, @@ -195,7 +199,7 @@ pub struct IndexedTableConfig { /// Query-level data for building StatsPruneTree per segment. /// (BoolNode tree, prebuilt PruningPredicates keyed by Arc ptr, schema) pub prune_tree_config: Option<( - BoolNode, + Arc, Arc>>, SchemaRef, )>, @@ -206,6 +210,10 @@ pub struct IndexedTableConfig { /// matches the wire format from `DataFusionPlugin`). Same length as /// `sort_fields` (validated at index creation). pub sort_orders: Vec, + /// Per-query cancellation token (from the global `QUERY_REGISTRY`). Threaded + /// down to `IndexReader` so the scan cooperatively stops when the query task + /// is cancelled. `None` for untracked queries (`context_id == 0`) and tests. + pub cancellation_token: Option, } /// Table provider. Returns a `QueryShardExec` that fans out across chunks. @@ -266,7 +274,9 @@ impl TableProvider for IndexedTableProvider { let row_id_col_in_full_schema = full_schema.index_of(crate::ROW_ID_COLUMN_NAME).ok(); let row_id_output_index: Option = if self.config.emit_row_ids { match projection { - Some(proj) => proj.iter().position(|&idx| Some(idx) == row_id_col_in_full_schema), + Some(proj) => proj + .iter() + .position(|&idx| Some(idx) == row_id_col_in_full_schema), None => row_id_col_in_full_schema, } } else { @@ -281,7 +291,8 @@ impl TableProvider for IndexedTableProvider { None => full_schema.clone(), }; if let Some(idx) = row_id_output_index { - let mut fields: Vec = base.fields().iter().map(|f| f.as_ref().clone()).collect(); + let mut fields: Vec = + base.fields().iter().map(|f| f.as_ref().clone()).collect(); fields[idx] = Field::new(crate::ROW_ID_COLUMN_NAME, DataType::Int64, false); Arc::new(Schema::new(fields)) } else { @@ -292,7 +303,8 @@ impl TableProvider for IndexedTableProvider { // Read projection = output columns (minus ___row_id) + predicate columns for evaluator. let read_projection: Option> = if self.config.emit_row_ids { let output_cols: Vec = match projection { - Some(proj) => proj.iter() + Some(proj) => proj + .iter() .filter(|&&idx| Some(idx) != row_id_col_in_full_schema) .copied() .collect(), @@ -372,9 +384,10 @@ impl TableProvider for IndexedTableProvider { None }; - let (assignments, eq_properties, advertised_ordering) = if chain_ok && lex_ordering.is_some() { - let assignments = - compute_assignments_one_per_segment(&self.config.segments, &layouts); + let (assignments, eq_properties, advertised_ordering) = if chain_ok + && lex_ordering.is_some() + { + let assignments = compute_assignments_one_per_segment(&self.config.segments, &layouts); let lex = lex_ordering.unwrap(); let eq = EquivalenceProperties::new_with_orderings( projected_schema.clone(), @@ -572,12 +585,6 @@ impl ExecutionPlan for QueryShardExec { FilterPushdownPhase, FilterPushdownPropagation, PushedDown, }; - // Feature gate: when disabled, decline everything so behaviour is - // identical to before this feature (parent keeps its FilterExec). - if !self.config.query_config.indexed_dynamic_filter_pushdown { - return Ok(FilterPushdownPropagation::if_all(child_pushdown_result)); - } - // Only the Post phase carries dynamic filters; in Pre we own static // WHERE semantics via the BoolNode tree and want no interference. if phase != FilterPushdownPhase::Post { @@ -596,7 +603,9 @@ impl ExecutionPlan for QueryShardExec { } if accepted.is_empty() { - return Ok(FilterPushdownPropagation::with_parent_pushdown_result(statuses)); + return Ok(FilterPushdownPropagation::with_parent_pushdown_result( + statuses, + )); } let new_self = self.clone_with_dynamic_filters(accepted); @@ -620,9 +629,10 @@ impl ExecutionPlan for QueryShardExec { pmetrics.into_stream_metrics(Some(Arc::clone(&self.inner_parquet_metrics))); stream_metrics.io_stats = Some(Arc::clone(&self.io_stats)); - let dynamic_filter: Option> = - (!self.dynamic_filters.is_empty()) - .then(|| datafusion::physical_expr::utils::conjunction(self.dynamic_filters.clone())); + let dynamic_filter: Option> = (!self + .dynamic_filters + .is_empty()) + .then(|| datafusion::physical_expr::utils::conjunction(self.dynamic_filters.clone())); let mut streams: Vec = Vec::with_capacity(assignment.chunks.len()); @@ -644,23 +654,38 @@ impl ExecutionPlan for QueryShardExec { } // Build stats prune tree for segment/RG/subtree-level pruning. - let stats_prune_tree = self.config.prune_tree_config.as_ref().map(|(tree, preds, schema)| { - let rg_indices: Vec = row_groups.iter().map(|rg| rg.index).collect(); - StatsPruneTree::build_from_bool_node( - tree, preds, &segment.metadata, schema, &rg_indices, - ) - }); + let stats_prune_tree = + self.config + .prune_tree_config + .as_ref() + .map(|(tree, preds, schema)| { + let rg_indices: Vec = row_groups.iter().map(|rg| rg.index).collect(); + Arc::new(StatsPruneTree::build_from_bool_node( + tree, + preds, + &segment.metadata, + schema, + &rg_indices, + )) + }); // Segment-level skip: if no RG in the chunk can match, skip entirely. if let Some(ref spt) = stats_prune_tree { if !spt.rg_can_match.iter().any(|&k| k) { - native_bridge_common::log_debug!("[segment-skip] skipping chunk — pruned by segment-level stats"); + native_bridge_common::log_debug!( + "[segment-skip] skipping chunk — pruned by segment-level stats" + ); continue; } } - let evaluator = (self.config.evaluator_factory)(segment, chunk, &stream_metrics, stats_prune_tree.as_ref()) - .map_err(|e| DataFusionError::External(e.into()))?; + let evaluator = (self.config.evaluator_factory)( + segment, + chunk, + &stream_metrics, + stats_prune_tree.as_ref(), + ) + .map_err(|e| DataFusionError::External(e.into()))?; // When the sort-aware path fired (`advertised_ordering: Some`), the // chain-aware partitioning guarantees this chunk is one whole @@ -705,15 +730,15 @@ impl ExecutionPlan for QueryShardExec { emit_row_ids: self.config.emit_row_ids, row_id_output_index: self.row_id_output_index, dynamic_filter: dynamic_filter.clone(), + cancellation_token: self.config.cancellation_token.clone(), }; streams.push(exec.execute(0, Arc::clone(&context))?); } let stream: SendableRecordBatchStream = match streams.len() { 0 => { - let empty = datafusion::physical_plan::empty::EmptyExec::new( - self.projected_schema.clone(), - ); + let empty = + datafusion::physical_plan::empty::EmptyExec::new(self.projected_schema.clone()); empty.execute(0, context)? } 1 => streams.into_iter().next().unwrap(), @@ -729,11 +754,7 @@ impl ExecutionPlan for QueryShardExec { impl Drop for QueryShardExec { fn drop(&mut self) { - accumulate_from_exec( - &self.metrics, - &self.inner_parquet_metrics, - &self.io_stats, - ); + accumulate_from_exec(&self.metrics, &self.inner_parquet_metrics, &self.io_stats); } } @@ -834,6 +855,7 @@ mod tests { prune_tree_config: None, sort_fields: vec![], sort_orders: vec![], + cancellation_token: None, } } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/constant_predicate.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/constant_predicate.rs index a235770b210a0..9834f8728bfba 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/constant_predicate.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/constant_predicate.rs @@ -11,6 +11,7 @@ //! `no index_filter(...) in plan`. Asserts constant-true keeps every row and //! constant-false drops every row (the residual is evaluated, not ignored). +use std::collections::HashMap; use std::sync::Arc; use datafusion::arrow::array::{Array, Int32Array, StringArray}; @@ -73,26 +74,29 @@ async fn run_constant_residual(residual: Arc) -> usize { row_groups: rgs, metadata: Arc::clone(&parquet_meta), global_base: 0, - sort_min: None, + sort_min: None, sort_max: None, -}; + }; // FilterClass::None: no pruning predicate (column-less), constant applied // as residual in on_batch_mask. let factory: EvaluatorFactory = { let schema = schema.clone(); let residual = Arc::clone(&residual); - Arc::new(move |segment: &SegmentFileInfo, _chunk, stream_metrics, _stats_prune_tree| { - let pruner = Arc::new(PagePruner::new(&schema, Arc::clone(&segment.metadata))); - let eval: Arc = Arc::new(PredicateOnlyEvaluator::new( - pruner, - None, - Some(Arc::clone(&residual)), - Some(PagePruneMetrics::from_stream_metrics(stream_metrics)), - None, - )); - Ok(eval) - }) + Arc::new( + move |segment: &SegmentFileInfo, _chunk, stream_metrics, _stats_prune_tree| { + let pruner = Arc::new(PagePruner::new(&schema, Arc::clone(&segment.metadata))); + let eval: Arc = Arc::new(PredicateOnlyEvaluator::new( + pruner, + None, + Some(Arc::clone(&residual)), + Some(PagePruneMetrics::from_stream_metrics(stream_metrics)), + None, + HashMap::new(), + )); + Ok(eval) + }, + ) }; let store: Arc = @@ -101,7 +105,7 @@ async fn run_constant_residual(residual: Arc) -> usize { let qc = crate::datafusion_query_config::DatafusionQueryConfig::builder() .target_partitions(1) .force_strategy(Some(FilterStrategy::BooleanMask)) - .force_pushdown(Some(false)) + .indexed_pushdown_filters(false) .build(); let provider = Arc::new(IndexedTableProvider::new(IndexedTableConfig { schema: schema.clone(), @@ -116,6 +120,7 @@ async fn run_constant_residual(residual: Arc) -> usize { prune_tree_config: None, sort_fields: vec![], sort_orders: vec![], + cancellation_token: None, })); let ctx = SessionContext::new(); diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/dynamic_filter_pushdown.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/dynamic_filter_pushdown.rs index 67f6fbf197a0e..6ac424ca24b3d 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/dynamic_filter_pushdown.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/dynamic_filter_pushdown.rs @@ -27,11 +27,11 @@ use datafusion::parquet::arrow::ArrowWriter; use futures::StreamExt; use tempfile::NamedTempFile; +use super::super::eval::RowGroupBitsetSource; use super::super::index::RowGroupDocsCollector; use super::super::page_pruner::PagePruner; use super::super::stream::{FilterStrategy, RowGroupInfo}; use super::super::table_provider::{IndexedTableConfig, IndexedTableProvider, SegmentFileInfo}; -use super::super::eval::RowGroupBitsetSource; /// 16 rows, `price` = 15..0 **descending** in file order, 4 rows per row group → /// RG ranges (by row position) [15..12], [11..8], [7..4], [3..0]. The scan reads @@ -91,12 +91,7 @@ impl RowGroupDocsCollector for MatchAllCollector { /// Build the indexed provider over the fixture and run `sql`. Returns the /// `(price)` rows in emission order plus the executed physical plan (for /// reading metrics). -async fn run_indexed( - sql: &str, -) -> ( - Vec, - Arc, -) { +async fn run_indexed(sql: &str) -> (Vec, Arc) { let (tmp, schema) = write_fixture(); let path = tmp.path().to_path_buf(); let size = std::fs::metadata(&path).unwrap().len(); @@ -131,9 +126,9 @@ async fn run_indexed( row_groups: rgs, metadata: Arc::clone(&parquet_meta), global_base: 0, - sort_min: None, + sort_min: None, sort_max: None, -}; + }; let factory: super::super::table_provider::EvaluatorFactory = { let schema = schema.clone(); @@ -157,6 +152,7 @@ async fn run_indexed( 0, None, None, + std::collections::HashMap::new(), ), ); Ok(eval) @@ -174,7 +170,6 @@ async fn run_indexed( let qc = crate::datafusion_query_config::DatafusionQueryConfig::builder() .target_partitions(1) .batch_size(RG_ROWS) - .indexed_dynamic_filter_pushdown(true) .build(); let provider = Arc::new(IndexedTableProvider::new(IndexedTableConfig { schema: schema.clone(), @@ -189,6 +184,7 @@ async fn run_indexed( prune_tree_config: None, sort_fields: vec![], sort_orders: vec![], + cancellation_token: None, })); let ctx = SessionContext::new(); @@ -238,7 +234,8 @@ async fn topk_dynamic_filter_prunes_row_groups() { // ORDER BY price DESC LIMIT 2 → top-2 prices are 15, 14 (both in the last RG // [12..15]). As the TopK heap fills, the threshold rises and the earlier RGs // ([0..3], [4..7], [8..11]) become prunable. - let (prices, plan) = run_indexed("SELECT brand, price FROM t ORDER BY price DESC LIMIT 2").await; + let (prices, plan) = + run_indexed("SELECT brand, price FROM t ORDER BY price DESC LIMIT 2").await; // (1) Correctness: exactly the global top-2 by price, in DESC order. assert_eq!(prices, vec![15, 14], "top-2 DESC prices"); @@ -271,6 +268,10 @@ async fn no_limit_means_no_dynamic_filter_pruning() { let (prices, plan) = run_indexed("SELECT brand, price FROM t ORDER BY price DESC").await; assert_eq!(prices.len(), ROWS, "full result set without LIMIT"); assert_eq!(prices.first().copied(), Some(15)); - assert_eq!(rg_pruned_at_prefetch(&plan), 0, "no filter → no prefetch prune"); + assert_eq!( + rg_pruned_at_prefetch(&plan), + 0, + "no filter → no prefetch prune" + ); assert_eq!(rg_pruned_at_poll(&plan), 0, "no filter → no poll prune"); } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/fuzz/corpus.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/fuzz/corpus.rs index 226ea0472062a..07e29918b31b7 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/fuzz/corpus.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/fuzz/corpus.rs @@ -403,15 +403,13 @@ fn write_parquet( // the flush trigger. Utf8 columns (many bytes/value) flush // faster than Int32/Boolean columns, yielding genuinely // different per-column page counts. - builder = builder.set_dictionary_enabled(false).set_data_page_size_limit(512); + builder = builder + .set_dictionary_enabled(false) + .set_data_page_size_limit(512); } let props = builder.build(); - let mut w = ArrowWriter::try_new( - tmp.reopen().expect("reopen"), - batch.schema(), - Some(props), - ) - .expect("arrow writer"); + let mut w = ArrowWriter::try_new(tmp.reopen().expect("reopen"), batch.schema(), Some(props)) + .expect("arrow writer"); w.write(batch).expect("write"); w.close().expect("close"); tmp diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/fuzz/delegation.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/fuzz/delegation.rs index 562c14178b126..299dfef87cd73 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/fuzz/delegation.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/fuzz/delegation.rs @@ -140,10 +140,7 @@ fn gen_binary_predicate_expr( Arc::new(BinaryExpr::new(col_expr, op, lit_expr)) } -fn pick_literal_for( - rng: &mut StdRng, - dt: &datafusion::arrow::datatypes::DataType, -) -> ScalarValue { +fn pick_literal_for(rng: &mut StdRng, dt: &datafusion::arrow::datatypes::DataType) -> ScalarValue { use datafusion::arrow::datatypes::{DataType, TimeUnit}; let strategy = rng.gen_range(0..100u32); match dt { @@ -246,11 +243,12 @@ impl DelegatedBackendCollectorFactory for MockDelegatedBackendCollectorFactory { _doc_min: i32, _doc_max: i32, ) -> Result, String> { - let matching = self - .match_sets - .get(&provider_key) - .cloned() - .ok_or_else(|| format!("MockDelegatedBackend: no match-set for provider_key={}", provider_key))?; + let matching = self.match_sets.get(&provider_key).cloned().ok_or_else(|| { + format!( + "MockDelegatedBackend: no match-set for provider_key={}", + provider_key + ) + })?; Ok(Arc::new(StaticBitsetCollector { matching }) as Arc) } } @@ -298,9 +296,15 @@ fn compare_cell_lit_true(cell: &CellValue, op: Operator, lit: &ScalarValue) -> b }; } let ord: Option = match (cell, lit) { - (CellValue::Utf8(c), ScalarValue::Utf8(l)) => Some(bail_unknown!(c).as_str().cmp(bail_unknown!(l).as_str())), - (CellValue::Int32(c), ScalarValue::Int32(l)) => Some(bail_unknown!(c).cmp(bail_unknown!(l))), - (CellValue::Int64(c), ScalarValue::Int64(l)) => Some(bail_unknown!(c).cmp(bail_unknown!(l))), + (CellValue::Utf8(c), ScalarValue::Utf8(l)) => { + Some(bail_unknown!(c).as_str().cmp(bail_unknown!(l).as_str())) + } + (CellValue::Int32(c), ScalarValue::Int32(l)) => { + Some(bail_unknown!(c).cmp(bail_unknown!(l))) + } + (CellValue::Int64(c), ScalarValue::Int64(l)) => { + Some(bail_unknown!(c).cmp(bail_unknown!(l))) + } (CellValue::Float64(c), ScalarValue::Float64(l)) => { let c = bail_unknown!(c); let l = bail_unknown!(l); @@ -312,7 +316,9 @@ fn compare_cell_lit_true(cell: &CellValue, op: Operator, lit: &ScalarValue) -> b (CellValue::Boolean(c), ScalarValue::Boolean(l)) => { Some((*bail_unknown!(c) as i32).cmp(&(*bail_unknown!(l) as i32))) } - (CellValue::Date32(c), ScalarValue::Date32(l)) => Some(bail_unknown!(c).cmp(bail_unknown!(l))), + (CellValue::Date32(c), ScalarValue::Date32(l)) => { + Some(bail_unknown!(c).cmp(bail_unknown!(l))) + } (CellValue::TimestampNanos(c), ScalarValue::TimestampNanosecond(l, _)) => { Some(bail_unknown!(c).cmp(bail_unknown!(l))) } @@ -421,6 +427,7 @@ pub(in crate::indexed_table::tests_e2e) async fn execute_delegation_tree( 0, None, None, + HashMap::new(), )); Ok(eval) }) @@ -441,7 +448,7 @@ pub(in crate::indexed_table::tests_e2e) async fn execute_delegation_tree( // arithmetic were wrong we'd silently corrupt results on any segment after the first. let qc = crate::datafusion_query_config::DatafusionQueryConfig::builder() .target_partitions(corpus.config.target_partitions.max(1)) - .force_pushdown(Some(true)) + .indexed_pushdown_filters(true) .batch_size(1024) .build(); @@ -449,8 +456,7 @@ pub(in crate::indexed_table::tests_e2e) async fn execute_delegation_tree( use datafusion::common::tree_node::TreeNode; let mut indices = std::collections::BTreeSet::new(); let _ = residual_physical.apply(|node| { - if let Some(col) = node - .downcast_ref::() + if let Some(col) = node.downcast_ref::() { indices.insert(col.index()); } @@ -472,6 +478,7 @@ pub(in crate::indexed_table::tests_e2e) async fn execute_delegation_tree( prune_tree_config: None, sort_fields: vec![], sort_orders: vec![], + cancellation_token: None, })); let ctx = SessionContext::new(); diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/fuzz/harness.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/fuzz/harness.rs index 87ff2ea1d0174..7bb095b82ba71 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/fuzz/harness.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/fuzz/harness.rs @@ -13,6 +13,7 @@ //! is paid once per test, then many iterations run cheap tree //! generation + execution against the same file. +use std::collections::HashMap; use std::sync::Arc; use datafusion::arrow::array::{Array, Int32Array}; @@ -105,9 +106,9 @@ pub(in crate::indexed_table::tests_e2e) fn load_segment(corpus: &Corpus) -> Load row_groups: rgs, metadata: Arc::clone(&parquet_meta), global_base: 0, - sort_min: None, + sort_min: None, sort_max: None, -}); + }); global_first_row += seg_rows as i64; } LoadedSegment { @@ -213,32 +214,43 @@ pub(in crate::indexed_table::tests_e2e) async fn execute_tree_with_plan_pushdown let cfg_target_partitions = _corpus.config.target_partitions; let cfg_min_skip_run = _corpus.config.min_skip_run_override; + // Build per-leaf PruningPredicates the same way indexed_executor.rs + // does in production, so our harness exercises real page-pruning + // behavior instead of silently falling back to universe bitmaps. + let mut leaf_exprs: Vec> = Vec::new(); + collect_predicate_exprs_harness(&bool_tree, &mut leaf_exprs); + let pruning_predicates: Arc< + std::collections::HashMap< + usize, + Arc, + >, + > = Arc::new( + leaf_exprs + .iter() + .filter_map(|expr| { + crate::indexed_table::page_pruner::build_pruning_predicate( + expr, + loaded.schema.clone(), + ) + .map(|pp| (Arc::as_ptr(expr) as *const () as usize, pp)) + }) + .collect(), + ); + let factory: EvaluatorFactory = { let per_leaf = per_leaf.clone(); let tree = Arc::clone(&bool_tree); let schema = loaded.schema.clone(); - // Build per-leaf PruningPredicates the same way indexed_executor.rs - // does in production, so our harness exercises real page-pruning - // behavior instead of silently falling back to universe bitmaps. - let mut leaf_exprs: Vec> = Vec::new(); - collect_predicate_exprs_harness(&bool_tree, &mut leaf_exprs); - let pruning_predicates: Arc< - std::collections::HashMap< - usize, - Arc, - >, - > = Arc::new( - leaf_exprs - .iter() - .filter_map(|expr| { - crate::indexed_table::page_pruner::build_pruning_predicate(expr, schema.clone()) - .map(|pp| (Arc::as_ptr(expr) as *const () as usize, pp)) - }) - .collect(), - ); - Arc::new(move |segment, _chunk, stream_metrics, _stats_prune_tree| { + let pruning_predicates = Arc::clone(&pruning_predicates); + Arc::new(move |segment, chunk, stream_metrics, stats_prune_tree| { let resolved = tree.resolve(&per_leaf)?; let pruner = Arc::new(PagePruner::new(&schema, Arc::clone(&segment.metadata))); + let rg_index_to_pos: HashMap = chunk + .row_group_indices + .iter() + .enumerate() + .map(|(pos, &idx)| (idx, pos)) + .collect(); let eval: Arc = Arc::new(TreeBitsetSource { tree: Arc::new(resolved), evaluator: Arc::new(BitmapTreeEvaluator), @@ -266,7 +278,8 @@ pub(in crate::indexed_table::tests_e2e) async fn execute_tree_with_plan_pushdown crate::indexed_table::eval::CollectorCallStrategy::FullRange, crate::indexed_table::eval::CollectorCallStrategy::PageRangeSplit, ][seed as usize % 3], - stats_prune_tree: None, + stats_prune_tree: stats_prune_tree.cloned(), + rg_index_to_pos, }); Ok(eval) }) @@ -278,8 +291,12 @@ pub(in crate::indexed_table::tests_e2e) async fn execute_tree_with_plan_pushdown let mut qcb = crate::datafusion_query_config::DatafusionQueryConfig::builder() .target_partitions(cfg_target_partitions.max(1)) .force_strategy(force_strategy) - .force_pushdown(force_pushdown) .batch_size(cfg_batch_size.unwrap_or([128, 1024, 8192][seed as usize % 3])); + // `force_pushdown` overrides the node-wide indexed_pushdown_filters default; + // `None` leaves the default in place. + if let Some(push) = force_pushdown { + qcb = qcb.indexed_pushdown_filters(push); + } if let Some(msr) = cfg_min_skip_run { qcb = qcb.min_skip_run_default(msr); } @@ -294,9 +311,14 @@ pub(in crate::indexed_table::tests_e2e) async fn execute_tree_with_plan_pushdown query_config: Arc::new(qc), predicate_columns: collect_predicate_column_indices(&bool_tree), emit_row_ids: false, - prune_tree_config: None, + prune_tree_config: Some(( + Arc::clone(&bool_tree), + Arc::clone(&pruning_predicates), + loaded.schema.clone(), + )), sort_fields: vec![], sort_orders: vec![], + cancellation_token: None, })); let ctx = SessionContext::new(); @@ -410,13 +432,23 @@ pub(in crate::indexed_table::tests_e2e) async fn execute_tree_single_collector( 0, None, None, + std::collections::HashMap::new(), )); let _ = segment; Ok(eval) }) }; - Some(run_single_collector_query(loaded, factory, residual_logical, force_strategy, _corpus.config.min_skip_run_override).await) + Some( + run_single_collector_query( + loaded, + factory, + residual_logical, + force_strategy, + _corpus.config.min_skip_run_override, + ) + .await, + ) } /// Execute `SELECT * FROM t WHERE ` so DataFusion's planner @@ -446,8 +478,8 @@ async fn run_single_collector_query( let mut indices = std::collections::BTreeSet::new(); if let Some(ref pp) = pushdown_predicate { let _ = pp.apply(|node| { - if let Some(col) = node - .downcast_ref::() + if let Some(col) = + node.downcast_ref::() { indices.insert(col.index()); } @@ -463,7 +495,7 @@ async fn run_single_collector_query( let mut qcb = crate::datafusion_query_config::DatafusionQueryConfig::builder() .target_partitions(1) .force_strategy(force_strategy) - .force_pushdown(Some(true)) + .indexed_pushdown_filters(true) .batch_size([128, 1024, 8192][loaded.segments.len() % 3]); if let Some(msr) = min_skip_run_override { qcb = qcb.min_skip_run_default(msr); @@ -482,6 +514,7 @@ async fn run_single_collector_query( prune_tree_config: None, sort_fields: vec![], sort_orders: vec![], + cancellation_token: None, })); let ctx = SessionContext::new(); ctx.register_table("t", provider).unwrap(); @@ -675,12 +708,16 @@ async fn run_with_factory_plan( let store: Arc = Arc::new(object_store::local::LocalFileSystem::new()); let store_url = datafusion::execution::object_store::ObjectStoreUrl::local_filesystem(); - let qc = crate::datafusion_query_config::DatafusionQueryConfig::builder() + let mut qcb = crate::datafusion_query_config::DatafusionQueryConfig::builder() .target_partitions(1) .force_strategy(force_strategy) - .force_pushdown(force_pushdown) - .batch_size([256, 1024, 8192][loaded.segments.len() % 3]) - .build(); + .batch_size([256, 1024, 8192][loaded.segments.len() % 3]); + // `force_pushdown` overrides the node-wide indexed_pushdown_filters default; + // `None` leaves the default in place. + if let Some(push) = force_pushdown { + qcb = qcb.indexed_pushdown_filters(push); + } + let qc = qcb.build(); let provider = Arc::new(IndexedTableProvider::new(IndexedTableConfig { schema: loaded.schema.clone(), segments: loaded.segments.clone(), @@ -694,6 +731,7 @@ async fn run_with_factory_plan( prune_tree_config: None, sort_fields: vec![], sort_orders: vec![], + cancellation_token: None, })); let ctx = SessionContext::new(); ctx.register_table("t", provider).unwrap(); @@ -750,8 +788,7 @@ fn collect_predicate_column_indices(tree: &BoolNode) -> Vec { let mut indices = std::collections::BTreeSet::new(); for expr in &exprs { let _ = expr.apply(|node| { - if let Some(col) = node - .downcast_ref::() + if let Some(col) = node.downcast_ref::() { indices.insert(col.index()); } @@ -958,9 +995,7 @@ mod tests { let col: Arc = Arc::new(Column::new("price", 3)); let lit: Arc = Arc::new(Literal::new(ScalarValue::Int32(Some(1000)))); let predicate = BoolNode::Predicate(Arc::new(BinaryExpr::new(col, Operator::Lt, lit))); - let collector = BoolNode::Collector { - annotation_id: 0, - }; + let collector = BoolNode::Collector { annotation_id: 0 }; let tree_node = BoolNode::And(vec![collector, predicate]); let matching: Vec = (0..100i32).collect(); let gt = GeneratedTree { @@ -976,9 +1011,7 @@ mod tests { async fn harness_bare_collector() { let corpus = build_corpus(FixtureConfig::small(0x2222)); let loaded = load_segment(&corpus); - let collector = BoolNode::Collector { - annotation_id: 0, - }; + let collector = BoolNode::Collector { annotation_id: 0 }; let matching: Vec = (0..100i32).collect(); let gt = GeneratedTree { tree: collector, @@ -1005,9 +1038,7 @@ mod tests { let cfg = FixtureConfig::block_granular_dense(0x4444); let corpus = build_corpus(cfg); let loaded = load_segment(&corpus); - let tree = BoolNode::And(vec![BoolNode::Collector { - annotation_id: 0, - }]); + let tree = BoolNode::And(vec![BoolNode::Collector { annotation_id: 0 }]); let mut rng = StdRng::seed_from_u64(0x5555); let mut candidates: Vec = (0..corpus.num_rows() as i32).collect(); candidates.shuffle(&mut rng); @@ -1034,7 +1065,8 @@ mod tests { .await .expect("bare Collector classifies as SingleCollector"); assert_eq!( - expected, actual, + expected, + actual, "bare collector block-granular strategy={:?}: expected {} rows, got {}", strategy, expected.len(), @@ -1121,9 +1153,7 @@ mod tests { let col: Arc = Arc::new(Column::new("price", price_idx)); let lit: Arc = Arc::new(Literal::new(ScalarValue::Int32(Some(1000)))); let predicate = BoolNode::Predicate(Arc::new(BinaryExpr::new(col, Operator::Lt, lit))); - let collector_leaf = BoolNode::Collector { - annotation_id: 0, - }; + let collector_leaf = BoolNode::Collector { annotation_id: 0 }; let tree_node = BoolNode::And(vec![collector_leaf, predicate]); // 5% density, uniform. @@ -1196,12 +1226,8 @@ mod tests { BoolNode::Predicate(Arc::new(BinaryExpr::new(phys_col, Operator::Lt, phys_lit))); // Multi-collector → classifies as Tree path. - let c1 = BoolNode::Collector { - annotation_id: 0, - }; - let c2 = BoolNode::Collector { - annotation_id: 1, - }; + let c1 = BoolNode::Collector { annotation_id: 0 }; + let c2 = BoolNode::Collector { annotation_id: 1 }; let tree_node = BoolNode::And(vec![BoolNode::Or(vec![c1, c2]), predicate]); // Two collectors, 5% density each, uniform. @@ -1279,9 +1305,7 @@ mod tests { let col: Arc = Arc::new(Column::new("price", price_idx)); let lit: Arc = Arc::new(Literal::new(ScalarValue::Int32(Some(1000)))); let predicate = BoolNode::Predicate(Arc::new(BinaryExpr::new(col, Operator::Lt, lit))); - let collector_leaf = BoolNode::Collector { - annotation_id: 0, - }; + let collector_leaf = BoolNode::Collector { annotation_id: 0 }; let tree_node = BoolNode::And(vec![collector_leaf, predicate]); // Uniform random subset at `pct`% density. diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/fuzz/tree_gen.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/fuzz/tree_gen.rs index f276520309190..060f25adfa1a6 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/fuzz/tree_gen.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/fuzz/tree_gen.rs @@ -499,7 +499,9 @@ mod tests { cs.iter().for_each(|c| count_fanouts(c, sum, count)); } BoolNode::Not(c) => count_fanouts(c, sum, count), - BoolNode::Collector { .. } | BoolNode::Predicate(_) | BoolNode::DelegationPossible { .. } => {} + BoolNode::Collector { .. } + | BoolNode::Predicate(_) + | BoolNode::DelegationPossible { .. } => {} } } @@ -522,7 +524,9 @@ mod tests { match n { BoolNode::And(cs) | BoolNode::Or(cs) => 1 + cs.iter().map(depth).max().unwrap_or(0), BoolNode::Not(c) => 1 + depth(c), - BoolNode::Collector { .. } | BoolNode::Predicate(_) | BoolNode::DelegationPossible { .. } => 0, + BoolNode::Collector { .. } + | BoolNode::Predicate(_) + | BoolNode::DelegationPossible { .. } => 0, } } @@ -532,7 +536,9 @@ mod tests { cs.len().max(cs.iter().map(max_fanout).max().unwrap_or(0)) } BoolNode::Not(c) => max_fanout(c), - BoolNode::Collector { .. } | BoolNode::Predicate(_) | BoolNode::DelegationPossible { .. } => 0, + BoolNode::Collector { .. } + | BoolNode::Predicate(_) + | BoolNode::DelegationPossible { .. } => 0, } } } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/mod.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/mod.rs index 662b71accee58..f5d9dbf79c839 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/mod.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/mod.rs @@ -15,6 +15,7 @@ #![cfg(test)] +use std::collections::HashMap; use std::sync::Arc; use std::sync::OnceLock; @@ -230,10 +231,10 @@ async fn run_tree_and_plan( parquet_size: size, row_groups: rgs, metadata: Arc::clone(&parquet_meta), - global_base: 0, - sort_min: None, + global_base: 0, + sort_min: None, sort_max: None, -}; + }; // Normalize NOT push-down; build one collector per Collector leaf in DFS order. let tree = tree.push_not_down(); @@ -272,8 +273,10 @@ async fn run_tree_and_plan( _stream_metrics, ), ), - collector_strategy: crate::indexed_table::eval::CollectorCallStrategy::TightenOuterBounds, + collector_strategy: + crate::indexed_table::eval::CollectorCallStrategy::TightenOuterBounds, stats_prune_tree: None, + rg_index_to_pos: HashMap::new(), }); Ok(eval) }) @@ -289,7 +292,7 @@ async fn run_tree_and_plan( let qc = crate::datafusion_query_config::DatafusionQueryConfig::builder() .target_partitions(1) .force_strategy(Some(FilterStrategy::BooleanMask)) - .force_pushdown(Some(false)) + .indexed_pushdown_filters(false) .build(); let provider = Arc::new(IndexedTableProvider::new(IndexedTableConfig { schema: schema.clone(), @@ -304,6 +307,7 @@ async fn run_tree_and_plan( prune_tree_config: None, sort_fields: vec![], sort_orders: vec![], + cancellation_token: None, })); let ctx = SessionContext::new(); diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/multi_segment.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/multi_segment.rs index 1c7d54eb95a46..9df63221fde97 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/multi_segment.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/multi_segment.rs @@ -131,9 +131,9 @@ async fn run_two_segment_query( row_groups: rgs, metadata: Arc::clone(&parquet_meta), global_base: 0, - sort_min: None, + sort_min: None, sort_max: None, -}); + }); } let schema = schema_opt.unwrap(); @@ -142,19 +142,18 @@ async fn run_two_segment_query( // Factory produces a single-collector evaluator per chunk. The collector's // matching set is pulled from `per_segment_matches[segment.writer_generation]`, // so wrong writer_generation propagation would immediately produce wrong rows. - let factory: super::super::table_provider::EvaluatorFactory = - { - let per_segment_matches = Arc::clone(&per_segment_matches); - let schema = schema.clone(); - Arc::new(move |segment, _chunk, _stream_metrics, _stats_prune_tree| { - let matching = per_segment_matches - .get(segment.writer_generation as usize) - .cloned() - .unwrap_or_default(); - let collector: Arc = - Arc::new(PerSegmentCollector { matching }); - let pruner = Arc::new(PagePruner::new(&schema, Arc::clone(&segment.metadata))); - let eval: Arc = Arc::new( + let factory: super::super::table_provider::EvaluatorFactory = { + let per_segment_matches = Arc::clone(&per_segment_matches); + let schema = schema.clone(); + Arc::new(move |segment, _chunk, _stream_metrics, _stats_prune_tree| { + let matching = per_segment_matches + .get(segment.writer_generation as usize) + .cloned() + .unwrap_or_default(); + let collector: Arc = + Arc::new(PerSegmentCollector { matching }); + let pruner = Arc::new(PagePruner::new(&schema, Arc::clone(&segment.metadata))); + let eval: Arc = Arc::new( crate::indexed_table::eval::single_collector::SingleCollectorEvaluator::new( Some(collector), pruner, None, None, None, None, crate::indexed_table::eval::single_collector::CollectorCallStrategy::FullRange, @@ -164,11 +163,12 @@ async fn run_two_segment_query( 0, None, None, + std::collections::HashMap::new(), ), ); - Ok(eval) - }) - }; + Ok(eval) + }) + }; let store: Arc = Arc::new(object_store::local::LocalFileSystem::new()); @@ -176,7 +176,7 @@ async fn run_two_segment_query( let qc = crate::datafusion_query_config::DatafusionQueryConfig::builder() .target_partitions(num_partitions) .force_strategy(Some(FilterStrategy::BooleanMask)) - .force_pushdown(Some(false)) + .indexed_pushdown_filters(false) .build(); let provider = Arc::new(IndexedTableProvider::new(IndexedTableConfig { schema: schema.clone(), @@ -191,6 +191,7 @@ async fn run_two_segment_query( prune_tree_config: None, sort_fields: vec![], sort_orders: vec![], + cancellation_token: None, })); let ctx = SessionContext::new(); @@ -266,11 +267,7 @@ struct ConcurrencyWitnessCollector { } impl RowGroupDocsCollector for ConcurrencyWitnessCollector { - fn collect_packed_u64_bitset( - &self, - min_doc: i32, - max_doc: i32, - ) -> Result, String> { + fn collect_packed_u64_bitset(&self, min_doc: i32, max_doc: i32) -> Result, String> { use std::sync::atomic::Ordering; let cur = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1; // Update high-water-mark with a CAS loop. @@ -339,9 +336,9 @@ async fn run_two_segment_query_witness( row_groups: rgs, metadata: Arc::clone(&parquet_meta), global_base: 0, - sort_min: None, + sort_min: None, sort_max: None, -}); + }); } let schema = schema_opt.unwrap(); @@ -359,12 +356,11 @@ async fn run_two_segment_query_witness( .get(segment.writer_generation as usize) .cloned() .unwrap_or_default(); - let collector: Arc = - Arc::new(ConcurrencyWitnessCollector { - inner: PerSegmentCollector { matching }, - in_flight: Arc::clone(&in_flight), - max_in_flight: Arc::clone(&max_in_flight), - }); + let collector: Arc = Arc::new(ConcurrencyWitnessCollector { + inner: PerSegmentCollector { matching }, + in_flight: Arc::clone(&in_flight), + max_in_flight: Arc::clone(&max_in_flight), + }); let pruner = Arc::new(PagePruner::new(&schema, Arc::clone(&segment.metadata))); let eval: Arc = Arc::new( crate::indexed_table::eval::single_collector::SingleCollectorEvaluator::new( @@ -376,6 +372,7 @@ async fn run_two_segment_query_witness( 0, None, None, + std::collections::HashMap::new(), ), ); Ok(eval) @@ -388,7 +385,7 @@ async fn run_two_segment_query_witness( let qc = crate::datafusion_query_config::DatafusionQueryConfig::builder() .target_partitions(num_partitions) .force_strategy(Some(FilterStrategy::BooleanMask)) - .force_pushdown(Some(false)) + .indexed_pushdown_filters(false) .build(); let provider = Arc::new(IndexedTableProvider::new(IndexedTableConfig { schema: schema.clone(), @@ -403,6 +400,7 @@ async fn run_two_segment_query_witness( prune_tree_config: None, sort_fields: vec![], sort_orders: vec![], + cancellation_token: None, })); let ctx = SessionContext::new(); @@ -435,8 +433,7 @@ async fn single_partition_runs_chunks_sequentially_no_inner_parallelism() { // so only the currently-active chunk's `prefetch_rg` (and therefore the // collector) is ever in flight → `max_in_flight == 1`. let matches = vec![vec![2, 6], vec![3, 7]]; - let (rows, max_in_flight) = - run_two_segment_query_witness(matches, /*num_partitions*/ 1).await; + let (rows, max_in_flight) = run_two_segment_query_witness(matches, /*num_partitions*/ 1).await; assert_eq!(rows.len(), 4, "result correctness sanity check"); assert_eq!( max_in_flight, 1, @@ -454,8 +451,7 @@ async fn cross_partition_parallelism_is_preserved() { // in *different* partitions and DataFusion drives them concurrently, // so the witness collector should observe `max_in_flight == 2`. let matches = vec![vec![2, 6], vec![3, 7]]; - let (rows, max_in_flight) = - run_two_segment_query_witness(matches, /*num_partitions*/ 2).await; + let (rows, max_in_flight) = run_two_segment_query_witness(matches, /*num_partitions*/ 2).await; assert_eq!(rows.len(), 4, "result correctness sanity check"); assert_eq!( max_in_flight, 2, @@ -557,26 +553,25 @@ async fn run_segments(specs: Vec, num_partitions: usize) -> Vec<(i32, S row_groups: rgs, metadata: Arc::clone(&parquet_meta), global_base: 0, - sort_min: None, + sort_min: None, sort_max: None, -}); + }); } let schema = schema_opt.unwrap(); let per_segment_matches = Arc::new(per_segment_matches); - let factory: super::super::table_provider::EvaluatorFactory = - { - let per_segment_matches = Arc::clone(&per_segment_matches); - let schema = schema.clone(); - Arc::new(move |segment, _chunk, _stream_metrics, _stats_prune_tree| { - let matching = per_segment_matches - .get(segment.writer_generation as usize) - .cloned() - .unwrap_or_default(); - let collector: Arc = - Arc::new(PerSegmentCollector { matching }); - let pruner = Arc::new(PagePruner::new(&schema, Arc::clone(&segment.metadata))); - let eval: Arc = Arc::new( + let factory: super::super::table_provider::EvaluatorFactory = { + let per_segment_matches = Arc::clone(&per_segment_matches); + let schema = schema.clone(); + Arc::new(move |segment, _chunk, _stream_metrics, _stats_prune_tree| { + let matching = per_segment_matches + .get(segment.writer_generation as usize) + .cloned() + .unwrap_or_default(); + let collector: Arc = + Arc::new(PerSegmentCollector { matching }); + let pruner = Arc::new(PagePruner::new(&schema, Arc::clone(&segment.metadata))); + let eval: Arc = Arc::new( crate::indexed_table::eval::single_collector::SingleCollectorEvaluator::new( Some(collector), pruner, None, None, None, None, crate::indexed_table::eval::single_collector::CollectorCallStrategy::FullRange, @@ -586,11 +581,12 @@ async fn run_segments(specs: Vec, num_partitions: usize) -> Vec<(i32, S 0, None, None, + std::collections::HashMap::new(), ), ); - Ok(eval) - }) - }; + Ok(eval) + }) + }; let store: Arc = Arc::new(object_store::local::LocalFileSystem::new()); @@ -598,7 +594,7 @@ async fn run_segments(specs: Vec, num_partitions: usize) -> Vec<(i32, S let qc = crate::datafusion_query_config::DatafusionQueryConfig::builder() .target_partitions(num_partitions) .force_strategy(Some(FilterStrategy::BooleanMask)) - .force_pushdown(Some(false)) + .indexed_pushdown_filters(false) .build(); let provider = Arc::new(IndexedTableProvider::new(IndexedTableConfig { schema: schema.clone(), @@ -613,6 +609,7 @@ async fn run_segments(specs: Vec, num_partitions: usize) -> Vec<(i32, S prune_tree_config: None, sort_fields: vec![], sort_orders: vec![], + cancellation_token: None, })); let ctx = SessionContext::new(); @@ -1053,9 +1050,9 @@ async fn run_wide_segments( row_groups: rgs, metadata: Arc::clone(&parquet_meta), global_base: 0, - sort_min: None, + sort_min: None, sort_max: None, -}); + }); } let schema = schema_opt.unwrap(); @@ -1094,7 +1091,7 @@ async fn run_wide_segments( pruning_predicates: std::sync::Arc::new(std::collections::HashMap::new()), page_prune_metrics: None, collector_strategy: crate::indexed_table::eval::CollectorCallStrategy::TightenOuterBounds, - stats_prune_tree: None, + stats_prune_tree: None, rg_index_to_pos: HashMap::new(), }, ); Ok(eval) @@ -1107,7 +1104,7 @@ async fn run_wide_segments( let qc = crate::datafusion_query_config::DatafusionQueryConfig::builder() .target_partitions(num_partitions) .force_strategy(Some(FilterStrategy::BooleanMask)) - .force_pushdown(Some(false)) + .indexed_pushdown_filters(false) .build(); let provider = Arc::new(IndexedTableProvider::new(IndexedTableConfig { schema: schema.clone(), @@ -1122,6 +1119,7 @@ async fn run_wide_segments( prune_tree_config: None, sort_fields: vec![], sort_orders: vec![], + cancellation_token: None, })); let ctx = SessionContext::new(); @@ -1229,9 +1227,7 @@ async fn wide_multi_segment_collector_and_two_predicates() { // Tree: AND(all_brand_docs, price > 50, qty < 3) let specs = wide_four_seg_specs(); let tree = BoolNode::And(vec![ - BoolNode::Collector { - annotation_id: 0, - }, + BoolNode::Collector { annotation_id: 0 }, pred_wide_int("price", Operator::Gt, 50), pred_wide_int("qty", Operator::Lt, 3), ]); @@ -1247,9 +1243,7 @@ async fn wide_multi_segment_or_of_predicates_under_collector() { // Tree: AND(all_brand_docs, OR(region == "us-east", active == true)) let specs = wide_four_seg_specs(); let tree = BoolNode::And(vec![ - BoolNode::Collector { - annotation_id: 0, - }, + BoolNode::Collector { annotation_id: 0 }, BoolNode::Or(vec![ pred_wide_str("region", Operator::Eq, "us-east"), pred_wide_bool("active", Operator::Eq, true), @@ -1267,9 +1261,7 @@ async fn wide_multi_segment_not_and_three_column_predicates() { // Tree: AND(all_brand_docs, NOT(price < 30), qty > 2, region != "eu-west") let specs = wide_four_seg_specs(); let tree = BoolNode::And(vec![ - BoolNode::Collector { - annotation_id: 0, - }, + BoolNode::Collector { annotation_id: 0 }, BoolNode::Not(Box::new(pred_wide_int("price", Operator::Lt, 30))), pred_wide_int("qty", Operator::Gt, 2), pred_wide_str("region", Operator::NotEq, "eu-west"), @@ -1289,9 +1281,7 @@ async fn wide_multi_segment_deep_tree_four_predicate_columns() { // Tree: AND(collector, OR(AND(price >= 100, qty = 3), AND(region == "us-west", active == true))) let specs = wide_four_seg_specs(); let tree = BoolNode::And(vec![ - BoolNode::Collector { - annotation_id: 0, - }, + BoolNode::Collector { annotation_id: 0 }, BoolNode::Or(vec![ BoolNode::And(vec![ pred_wide_int("price", Operator::GtEq, 100), @@ -1318,3 +1308,862 @@ async fn wide_multi_segment_deep_tree_four_predicate_columns() { assert_eq!(rows, expected, "np={} failed", np); } } + +// ── StatsPruneTree integration with offset RG indices ─────────────── +// +// These tests exercise the full pipeline with `prune_tree_config: Some(...)` +// enabled, causing `table_provider` to build `StatsPruneTree` per chunk. +// Verifies correctness when chunks have offset RG indices and when AND +// subtrees with native predicates get stats-pruned while OR subtrees +// containing collectors still produce empty bitmaps. +// +// Tree shape: AND(Predicate(price > X), OR(Collector, Predicate(qty/active))) +// Mirrors the failing PPL pattern. + +fn collect_pred_exprs( + tree: &BoolNode, + out: &mut Vec>, +) { + match tree { + BoolNode::And(c) | BoolNode::Or(c) => c.iter().for_each(|ch| collect_pred_exprs(ch, out)), + BoolNode::Not(inner) => collect_pred_exprs(inner, out), + BoolNode::Collector { .. } => {} + BoolNode::Predicate(expr) => out.push(Arc::clone(expr)), + BoolNode::DelegationPossible { original_expr, .. } => out.push(Arc::clone(original_expr)), + } +} + +async fn run_wide_segments_with_stats_pruning( + specs: Vec, + tree: BoolNode, + num_partitions: usize, +) -> Vec<(i32, i32, i32, String, bool)> { + #[derive(Debug)] + struct AllDocs; + impl RowGroupDocsCollector for AllDocs { + fn collect_packed_u64_bitset( + &self, + min_doc: i32, + max_doc: i32, + ) -> Result, String> { + let span = (max_doc - min_doc) as usize; + let mut out = vec![0u64; span.div_ceil(64)]; + for i in 0..span { + out[i / 64] |= 1u64 << (i % 64); + } + Ok(out) + } + } + + let tmps: Vec = specs + .iter() + .map(|s| write_wide_segment(s.brand, s.rows, s.max_rg_rows)) + .collect(); + + let mut segments: Vec = Vec::new(); + let mut schema_opt: Option = None; + for (ord, tmp) in tmps.iter().enumerate() { + let path = tmp.path().to_path_buf(); + let size = std::fs::metadata(&path).unwrap().len(); + let file = std::fs::File::open(&path).unwrap(); + let meta = + ArrowReaderMetadata::load(&file, ArrowReaderOptions::new().with_page_index(true)) + .unwrap(); + if schema_opt.is_none() { + schema_opt = Some(meta.schema().clone()); + } + let parquet_meta = meta.metadata().clone(); + let mut rgs = Vec::new(); + let mut offset = 0i64; + for i in 0..parquet_meta.num_row_groups() { + let n = parquet_meta.row_group(i).num_rows(); + rgs.push(RowGroupInfo { + index: i, + first_row: offset, + num_rows: n, + }); + offset += n; + } + let object_path = object_store::path::Path::from(path.to_string_lossy().as_ref()); + segments.push(SegmentFileInfo { + writer_generation: ord as i64, + max_doc: offset, + object_path, + parquet_size: size, + row_groups: rgs, + metadata: Arc::clone(&parquet_meta), + global_base: 0, + sort_min: None, + sort_max: None, + }); + } + + let schema = schema_opt.unwrap(); + let tree = tree.push_not_down().flatten(); + + let mut leaf_exprs: Vec> = Vec::new(); + collect_pred_exprs(&tree, &mut leaf_exprs); + let pruning_predicates: Arc< + HashMap>, + > = Arc::new( + leaf_exprs + .iter() + .filter_map(|expr| { + crate::indexed_table::page_pruner::build_pruning_predicate(expr, schema.clone()) + .map(|pp| (Arc::as_ptr(expr) as *const () as usize, pp)) + }) + .collect(), + ); + + let tree = Arc::new(tree); + + let factory: super::super::table_provider::EvaluatorFactory = { + let tree = Arc::clone(&tree); + let schema = schema.clone(); + let pruning_predicates = Arc::clone(&pruning_predicates); + Arc::new(move |segment, chunk, stream_metrics, stats_prune_tree| { + let leaf_count = tree.collector_leaf_count(); + let per_leaf: Vec<(i32, Arc)> = (0..leaf_count) + .map(|i| { + ( + i as i32, + Arc::new(AllDocs) as Arc, + ) + }) + .collect(); + let resolved = tree.resolve(&per_leaf)?; + let pruner = Arc::new(PagePruner::new(&schema, Arc::clone(&segment.metadata))); + let rg_index_to_pos: HashMap = chunk + .row_group_indices + .iter() + .enumerate() + .map(|(pos, &idx)| (idx, pos)) + .collect(); + let eval: Arc = Arc::new( + crate::indexed_table::eval::TreeBitsetSource { + tree: Arc::new(resolved), + evaluator: Arc::new(crate::indexed_table::eval::bitmap_tree::BitmapTreeEvaluator), + leaves: Arc::new(crate::indexed_table::eval::bitmap_tree::CollectorLeafBitmaps::without_metrics()), + page_pruner: pruner, + cost_predicate: 1, + cost_collector: 10, + max_collector_parallelism: 1, + pruning_predicates: Arc::clone(&pruning_predicates), + page_prune_metrics: Some( + crate::indexed_table::page_pruner::PagePruneMetrics::from_stream_metrics(stream_metrics), + ), + collector_strategy: crate::indexed_table::eval::CollectorCallStrategy::TightenOuterBounds, + stats_prune_tree: stats_prune_tree.cloned(), + rg_index_to_pos, + }, + ); + Ok(eval) + }) + }; + + let store: Arc = + Arc::new(object_store::local::LocalFileSystem::new()); + let store_url = datafusion::execution::object_store::ObjectStoreUrl::local_filesystem(); + let qc = crate::datafusion_query_config::DatafusionQueryConfig::builder() + .target_partitions(num_partitions) + .force_strategy(Some(FilterStrategy::BooleanMask)) + .indexed_pushdown_filters(false) + .build(); + let provider = Arc::new(IndexedTableProvider::new(IndexedTableConfig { + schema: schema.clone(), + segments, + store, + store_url, + evaluator_factory: factory, + pushdown_predicate: None, + query_config: std::sync::Arc::new(qc), + predicate_columns: vec![], + emit_row_ids: false, + prune_tree_config: Some(( + Arc::clone(&tree), + Arc::clone(&pruning_predicates), + schema.clone(), + )), + sort_fields: vec![], + sort_orders: vec![], + cancellation_token: None, + })); + + let ctx = SessionContext::new(); + ctx.register_table("t", provider).unwrap(); + let df = ctx + .sql("SELECT brand, price, qty, region, active FROM t") + .await + .unwrap(); + let mut stream = df.execute_stream().await.unwrap(); + let mut rows: Vec<(i32, i32, i32, String, bool)> = Vec::new(); + while let Some(batch) = stream.next().await { + let b = batch.unwrap(); + let brand = b.column(0).as_any().downcast_ref::().unwrap(); + let price = b.column(1).as_any().downcast_ref::().unwrap(); + let qty = b.column(2).as_any().downcast_ref::().unwrap(); + let region = b.column(3).as_any().downcast_ref::().unwrap(); + let active = b + .column(4) + .as_any() + .downcast_ref::() + .unwrap(); + for i in 0..b.num_rows() { + let ord = specs + .iter() + .position(|s| s.brand == brand.value(i)) + .unwrap_or(0) as i32; + rows.push(( + ord, + price.value(i), + qty.value(i), + region.value(i).to_string(), + active.value(i), + )); + } + } + rows.sort(); + rows +} + +/// Single segment (4 RGs of 4 rows), single partition. +/// Tree: AND(price > 50, OR(Collector, qty < 3)) +/// RGs with prices [0..30] are stats-pruned by `price > 50`. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stats_prune_single_segment_single_partition() { + let specs = vec![WideSegSpec { + brand: "amazon", + rows: 16, + max_rg_rows: 4, + }]; + let tree = BoolNode::And(vec![ + pred_wide_int("price", Operator::Gt, 50), + BoolNode::Or(vec![ + BoolNode::Collector { annotation_id: 0 }, + pred_wide_int("qty", Operator::Lt, 3), + ]), + ]); + // Collector matches all docs; OR(all, qty<3) = all; so just price > 50. + let expected = wide_oracle(&specs, |i| (i as i32) * 10 > 50); + let rows = run_wide_segments_with_stats_pruning(specs, tree, 1).await; + assert_eq!(rows, expected); +} + +/// Single segment (4 RGs), multi partition (4 partitions → 1 RG per chunk). +/// Each chunk has rg_indices=[N] where N>0 for non-first chunks. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stats_prune_single_segment_multi_partition() { + let specs = vec![WideSegSpec { + brand: "amazon", + rows: 16, + max_rg_rows: 4, + }]; + let tree = BoolNode::And(vec![ + pred_wide_int("price", Operator::Gt, 50), + BoolNode::Or(vec![ + BoolNode::Collector { annotation_id: 0 }, + pred_wide_int("qty", Operator::Lt, 3), + ]), + ]); + let expected = wide_oracle(&specs, |i| (i as i32) * 10 > 50); + let rows = run_wide_segments_with_stats_pruning(specs, tree, 4).await; + assert_eq!(rows, expected); +} + +/// Multi segment (4 segments × 4 RGs), single partition. +/// All segments in one partition — verifies stats pruning across segments. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stats_prune_multi_segment_single_partition() { + let specs = wide_four_seg_specs(); + let tree = BoolNode::And(vec![ + pred_wide_int("price", Operator::Gt, 80), + BoolNode::Or(vec![ + BoolNode::Collector { annotation_id: 0 }, + pred_wide_bool("active", Operator::Eq, true), + ]), + ]); + let expected = wide_oracle(&specs, |i| (i as i32) * 10 > 80); + let rows = run_wide_segments_with_stats_pruning(clone_wide_specs(&specs), tree, 1).await; + assert_eq!(rows, expected); +} + +/// Multi segment (4 segments × 4 RGs), multi partition (2, 3, 5, 8). +/// Maximum fragmentation — chunks split across segments with offset RGs. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stats_prune_multi_segment_multi_partition() { + let specs = wide_four_seg_specs(); + let tree = BoolNode::And(vec![ + pred_wide_int("price", Operator::Gt, 80), + BoolNode::Or(vec![ + BoolNode::Collector { annotation_id: 0 }, + pred_wide_bool("active", Operator::Eq, true), + ]), + ]); + let expected = wide_oracle(&specs, |i| (i as i32) * 10 > 80); + for np in [2usize, 3, 5, 8] { + let rows = + run_wide_segments_with_stats_pruning(clone_wide_specs(&specs), tree.clone(), np).await; + assert_eq!(rows, expected, "np={} failed", np); + } +} + +/// Deep tree with NOT under multi-partition multi-segment. +/// AND(NOT(price < 40), OR(Collector, qty >= 5)) +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stats_prune_multi_segment_multi_partition_with_not() { + let specs = wide_four_seg_specs(); + let tree = BoolNode::And(vec![ + BoolNode::Not(Box::new(pred_wide_int("price", Operator::Lt, 40))), + BoolNode::Or(vec![ + BoolNode::Collector { annotation_id: 0 }, + pred_wide_int("qty", Operator::GtEq, 5), + ]), + ]); + // NOT is conservative in stats (always true), so actual predicate filters. + let expected = wide_oracle(&specs, |i| !((i as i32) * 10 < 40)); + for np in [1usize, 3, 5] { + let rows = + run_wide_segments_with_stats_pruning(clone_wide_specs(&specs), tree.clone(), np).await; + assert_eq!(rows, expected, "np={} failed", np); + } +} + +/// Directly exercises `prefetch_rg` to assert: +/// 1. RGs with stats that prove no-match return `None` (pruned) +/// 2. RGs at offset indices (index > 0) are correctly handled +/// 3. Empty collector bitsets are produced for pruned subtrees +/// +/// Uses a single segment with 4 RGs (prices [0..30],[30..70],[70..110],[110..150]) +/// and tree AND(price > 60, OR(Collector, qty < 2)). +/// RG0 and RG1 should be pruned (max price < 60). RG2 and RG3 survive. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stats_prune_direct_prefetch_asserts_pruning_and_empty_bitsets() { + use crate::indexed_table::page_pruner::StatsPruneTree; + + // Build a segment with 4 RGs of 4 rows each. + // prices: [0,10,20,30], [40,50,60,70], [80,90,100,110], [120,130,140,150] + let spec = WideSegSpec { + brand: "amazon", + rows: 16, + max_rg_rows: 4, + }; + let tmp = write_wide_segment(spec.brand, spec.rows, spec.max_rg_rows); + let path = tmp.path().to_path_buf(); + let file = std::fs::File::open(&path).unwrap(); + let meta = + ArrowReaderMetadata::load(&file, ArrowReaderOptions::new().with_page_index(true)).unwrap(); + let parquet_meta = meta.metadata().clone(); + let schema = meta.schema().clone(); + assert_eq!(parquet_meta.num_row_groups(), 4); + + // Tree: AND(price > 60, OR(Collector, qty < 2)) + let tree = BoolNode::And(vec![ + pred_wide_int("price", Operator::Gt, 60), + BoolNode::Or(vec![ + BoolNode::Collector { annotation_id: 0 }, + pred_wide_int("qty", Operator::Lt, 2), + ]), + ]); + let tree = tree.push_not_down().flatten(); + + // Build pruning predicates. + let mut leaf_exprs: Vec> = Vec::new(); + collect_pred_exprs(&tree, &mut leaf_exprs); + let pruning_predicates: HashMap< + usize, + Arc, + > = leaf_exprs + .iter() + .filter_map(|expr| { + crate::indexed_table::page_pruner::build_pruning_predicate(expr, schema.clone()) + .map(|pp| (Arc::as_ptr(expr) as *const () as usize, pp)) + }) + .collect(); + + // Simulate a chunk with RGs [2, 3] (offset — doesn't start at 0). + let rg_indices: Vec = vec![2, 3]; + let spt = StatsPruneTree::build_from_bool_node( + &tree, + &pruning_predicates, + &parquet_meta, + &schema, + &rg_indices, + ); + + // Assert: rg_can_match for position 0 (RG2, prices 80-110) should be true (price>60 matches). + // Assert: rg_can_match for position 1 (RG3, prices 120-150) should be true. + let rg_index_to_pos: HashMap = rg_indices + .iter() + .enumerate() + .map(|(pos, &idx)| (idx, pos)) + .collect(); + assert_eq!(spt.rg_can_match.len(), 2, "subset-relative: 2 RGs in chunk"); + assert!( + spt.rg_can_match[0], + "RG2 (prices 80-110) should match price>60" + ); + assert!( + spt.rg_can_match[1], + "RG3 (prices 120-150) should match price>60" + ); + + // Now simulate a chunk with RGs [0, 1] — these SHOULD be pruned. + let rg_indices_low: Vec = vec![0, 1]; + let spt_low = StatsPruneTree::build_from_bool_node( + &tree, + &pruning_predicates, + &parquet_meta, + &schema, + &rg_indices_low, + ); + // RG0 (prices 0-30): price>60 → false → AND=false + assert!( + !spt_low.rg_can_match[0], + "RG0 (prices 0-30) should be pruned by price>60" + ); + // RG1 (prices 40-70): price>60 might be true for row with price=70 + // (stats: min=40, max=70, so max > 60 → can_match=true at RG stats level) + // This is expected: stats pruning is conservative. + + // Build a TreeBitsetSource with the offset chunk [2,3] and call prefetch_rg. + #[derive(Debug)] + struct AllDocs; + impl RowGroupDocsCollector for AllDocs { + fn collect_packed_u64_bitset( + &self, + min_doc: i32, + max_doc: i32, + ) -> Result, String> { + let span = (max_doc - min_doc) as usize; + let mut out = vec![0u64; span.div_ceil(64)]; + for i in 0..span { + out[i / 64] |= 1u64 << (i % 64); + } + Ok(out) + } + } + + let tree_arc = Arc::new(tree); + let per_leaf: Vec<(i32, Arc)> = + vec![(0, Arc::new(AllDocs) as Arc)]; + let resolved = tree_arc.resolve(&per_leaf).unwrap(); + let pruner = Arc::new(PagePruner::new(&schema, Arc::clone(&parquet_meta))); + + let source = crate::indexed_table::eval::TreeBitsetSource { + tree: Arc::new(resolved), + evaluator: Arc::new(crate::indexed_table::eval::bitmap_tree::BitmapTreeEvaluator), + leaves: Arc::new( + crate::indexed_table::eval::bitmap_tree::CollectorLeafBitmaps::without_metrics(), + ), + page_pruner: pruner, + cost_predicate: 1, + cost_collector: 10, + max_collector_parallelism: 1, + pruning_predicates: Arc::new(pruning_predicates), + page_prune_metrics: None, + collector_strategy: crate::indexed_table::eval::CollectorCallStrategy::TightenOuterBounds, + stats_prune_tree: Some(Arc::new(spt_low)), + rg_index_to_pos: rg_indices_low + .iter() + .enumerate() + .map(|(pos, &idx)| (idx, pos)) + .collect(), + }; + + // Assert: prefetch_rg for RG0 (offset index 0, pruned) returns None. + let rg0 = RowGroupInfo { + index: 0, + first_row: 0, + num_rows: 4, + }; + let result_rg0 = source.prefetch_rg(&rg0, 0, 4).unwrap(); + assert!( + result_rg0.is_none(), + "RG0 should be pruned by stats (price>60 fails for prices 0-30)" + ); + + // Assert: prefetch_rg for RG1 at offset index 1 — may or may not be pruned + // depending on stats (max price in RG1 is 70 which > 60, so can_match=true). + let rg1 = RowGroupInfo { + index: 1, + first_row: 4, + num_rows: 4, + }; + let result_rg1 = source.prefetch_rg(&rg1, 4, 8).unwrap(); + // RG1 (prices 40-70): max=70 > 60, so stats say can-match. Not pruned. + assert!( + result_rg1.is_some(), + "RG1 (max price=70) should not be stats-pruned" + ); + + // Now build source for chunk [2,3] — both survive. Verify offset lookup works. + let source2 = crate::indexed_table::eval::TreeBitsetSource { + tree: source.tree.clone(), + evaluator: Arc::new(crate::indexed_table::eval::bitmap_tree::BitmapTreeEvaluator), + leaves: Arc::new( + crate::indexed_table::eval::bitmap_tree::CollectorLeafBitmaps::without_metrics(), + ), + page_pruner: Arc::new(PagePruner::new(&schema, Arc::clone(&parquet_meta))), + cost_predicate: 1, + cost_collector: 10, + max_collector_parallelism: 1, + pruning_predicates: source.pruning_predicates.clone(), + page_prune_metrics: None, + collector_strategy: crate::indexed_table::eval::CollectorCallStrategy::TightenOuterBounds, + stats_prune_tree: Some(Arc::new(spt)), + rg_index_to_pos: rg_index_to_pos, + }; + + // RG2 at absolute index 2: should NOT be pruned (prices 80-110, all > 60). + let rg2 = RowGroupInfo { + index: 2, + first_row: 8, + num_rows: 4, + }; + let result_rg2 = source2.prefetch_rg(&rg2, 8, 12).unwrap(); + assert!( + result_rg2.is_some(), + "RG2 at offset index should not be pruned" + ); + // Verify the collector bitmap is non-empty (all docs match). + let prefetched = result_rg2.unwrap(); + assert!( + !prefetched.candidates.is_empty(), + "RG2 should have non-empty candidates" + ); + + // RG3 at absolute index 3: should NOT be pruned. + let rg3 = RowGroupInfo { + index: 3, + first_row: 12, + num_rows: 4, + }; + let result_rg3 = source2.prefetch_rg(&rg3, 12, 16).unwrap(); + assert!( + result_rg3.is_some(), + "RG3 at offset index should not be pruned" + ); +} + +/// Directly asserts that when a subtree under OR is stats-pruned, the +/// collector inside it gets an empty bitmap in `per_leaf`. +/// +/// Tree: OR(AND(price > 100, Collector0), Collector1) +/// For RG1 (prices 40-70): AND subtree is pruned (price>100 fails). +/// Collector1 survives → RG not skipped → refinement runs. +/// Collector0 must have an empty per_leaf entry. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stats_prune_asserts_empty_collector_bitset_in_pruned_subtree() { + use crate::indexed_table::eval::RowGroupBitsetSource; + use crate::indexed_table::page_pruner::StatsPruneTree; + + let spec = WideSegSpec { + brand: "amazon", + rows: 16, + max_rg_rows: 4, + }; + let tmp = write_wide_segment(spec.brand, spec.rows, spec.max_rg_rows); + let path = tmp.path().to_path_buf(); + let file = std::fs::File::open(&path).unwrap(); + let meta = + ArrowReaderMetadata::load(&file, ArrowReaderOptions::new().with_page_index(true)).unwrap(); + let parquet_meta = meta.metadata().clone(); + let schema = meta.schema().clone(); + + // Tree: OR(AND(price > 100, Collector0), Collector1) + // Collector0 is under AND with a predicate that fails for low-price RGs. + // Collector1 always matches → OR always has candidates. + let tree = BoolNode::Or(vec![ + BoolNode::And(vec![ + pred_wide_int("price", Operator::Gt, 100), + BoolNode::Collector { annotation_id: 0 }, + ]), + BoolNode::Collector { annotation_id: 1 }, + ]); + let tree = tree.push_not_down().flatten(); + + let mut leaf_exprs: Vec> = Vec::new(); + collect_pred_exprs(&tree, &mut leaf_exprs); + let pruning_predicates: HashMap< + usize, + Arc, + > = leaf_exprs + .iter() + .filter_map(|expr| { + crate::indexed_table::page_pruner::build_pruning_predicate(expr, schema.clone()) + .map(|pp| (Arc::as_ptr(expr) as *const () as usize, pp)) + }) + .collect(); + + // Chunk with RG1 only (prices 40-70). Offset index = 1. + let rg_indices: Vec = vec![1]; + let spt = StatsPruneTree::build_from_bool_node( + &tree, + &pruning_predicates, + &parquet_meta, + &schema, + &rg_indices, + ); + // Root OR should still be true (Collector1 child is always-true). + assert!( + spt.rg_can_match[0], + "OR root should be true (Collector1 always matches)" + ); + // AND child should be false (price>100 fails for RG1 max=70). + assert!( + !spt.children[0].rg_can_match[0], + "AND(price>100, Coll0) should be false for RG1" + ); + // Collector1 child should be true. + assert!(spt.children[1].rg_can_match[0], "Collector1 should be true"); + + // Build evaluator and call prefetch_rg for RG1. + #[derive(Debug)] + struct AllDocs; + impl RowGroupDocsCollector for AllDocs { + fn collect_packed_u64_bitset( + &self, + min_doc: i32, + max_doc: i32, + ) -> Result, String> { + let span = (max_doc - min_doc) as usize; + let mut out = vec![0u64; span.div_ceil(64)]; + for i in 0..span { + out[i / 64] |= 1u64 << (i % 64); + } + Ok(out) + } + } + + let per_leaf: Vec<(i32, Arc)> = vec![ + (0, Arc::new(AllDocs) as Arc), + (1, Arc::new(AllDocs) as Arc), + ]; + let resolved = Arc::new(tree).resolve(&per_leaf).unwrap(); + let pruner = Arc::new(PagePruner::new(&schema, Arc::clone(&parquet_meta))); + let rg_index_to_pos: HashMap = rg_indices + .iter() + .enumerate() + .map(|(pos, &idx)| (idx, pos)) + .collect(); + + let source = crate::indexed_table::eval::TreeBitsetSource { + tree: Arc::new(resolved), + evaluator: Arc::new(crate::indexed_table::eval::bitmap_tree::BitmapTreeEvaluator), + leaves: Arc::new( + crate::indexed_table::eval::bitmap_tree::CollectorLeafBitmaps::without_metrics(), + ), + page_pruner: pruner, + cost_predicate: 1, + cost_collector: 10, + max_collector_parallelism: 1, + pruning_predicates: Arc::new(pruning_predicates), + page_prune_metrics: None, + collector_strategy: crate::indexed_table::eval::CollectorCallStrategy::TightenOuterBounds, + stats_prune_tree: Some(Arc::new(spt)), + rg_index_to_pos, + }; + + // RG1 at absolute index 1: NOT pruned at root (OR is true). + let rg1 = RowGroupInfo { + index: 1, + first_row: 4, + num_rows: 4, + }; + let result = source.prefetch_rg(&rg1, 4, 8).unwrap(); + assert!( + result.is_some(), + "RG1 should NOT be pruned at root (OR has surviving child)" + ); + + let prefetched = result.unwrap(); + // Candidates should be non-empty (Collector1 matches all docs). + assert!( + !prefetched.candidates.is_empty(), + "Collector1 should produce non-empty candidates" + ); + + // Downcast context to TreePrefetch to inspect per_leaf bitmaps. + let tree_prefetch = prefetched + .context + .downcast_ref::() + .expect("context should be TreePrefetch"); + + // Critical assertion: per_leaf must have 2 entries (one per collector). + assert_eq!( + tree_prefetch.per_leaf.len(), + 2, + "both collectors must have per_leaf entries; got {}", + tree_prefetch.per_leaf.len() + ); + + // Collector0 (inside pruned AND subtree) must have EMPTY bitmap. + let has_empty = tree_prefetch.per_leaf.iter().any(|(_, bm)| bm.is_empty()); + assert!( + has_empty, + "pruned Collector0 should have an empty bitmap in per_leaf" + ); + + // Collector1 (surviving) must have NON-EMPTY bitmap. + let has_nonempty = tree_prefetch.per_leaf.iter().any(|(_, bm)| !bm.is_empty()); + assert!( + has_nonempty, + "surviving Collector1 should have a non-empty bitmap in per_leaf" + ); +} + +/// Regression test for the flatten-misalignment bug: when the BoolNode tree +/// used for StatsPruneTree is not normalized (push_not_down + flatten), child +/// indices in spt.children don't match ResolvedNode children, causing the +/// wrong branch's rg_can_match to be applied — pruning rows that match via +/// a sibling OR branch. +/// +/// Scenario: 3-way OR built as nested OR(OR(A,B), C) — after flatten becomes +/// OR(A, B, C). Each AND branch pairs a Collector with a Predicate. One +/// Predicate is prunable by RG stats. The test verifies that rows matching +/// a non-pruned branch still appear in results regardless of clause ordering. +/// +/// Tree: OR(AND(Collector0, price > 100), AND(Collector1, region = "us-east"), AND(Collector2, qty > 5)) +/// Data: 16 rows, 4 RGs of 4 rows each. +/// prices: 0,10,20,30 | 40,50,60,70 | 80,90,100,110 | 120,130,140,150 +/// qtys: 0,1,2,3 | 4,5,6,0 | 1,2,3,4 | 5,6,0,1 +/// regions: us-east,us-west,eu-west,us-east | us-west,eu-west,us-east,us-west | ... +/// +/// RG0: price max=30 → price>100 pruned. RG0 has region="us-east" rows → branch1 should match. +/// Without the fix, branch0's pruning would incorrectly affect branch1. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stats_prune_three_way_or_flatten_misalignment_regression() { + let specs = vec![WideSegSpec { + brand: "amazon", + rows: 16, + max_rg_rows: 4, + }]; + + // Build as nested OR(OR(A,B), C) — this is how convert_expr produces it + // from `A OR B OR C` (left-associative binary OR). + let tree = BoolNode::Or(vec![ + BoolNode::Or(vec![ + BoolNode::And(vec![ + BoolNode::Collector { annotation_id: 0 }, + pred_wide_int("price", Operator::Gt, 100), + ]), + BoolNode::And(vec![ + BoolNode::Collector { annotation_id: 1 }, + pred_wide_str("region", Operator::Eq, "us-east"), + ]), + ]), + BoolNode::And(vec![ + BoolNode::Collector { annotation_id: 2 }, + pred_wide_int("qty", Operator::Gt, 5), + ]), + ]); + + // Oracle: row matches if (price > 100) OR (region == "us-east") OR (qty > 5) + let expected = wide_oracle(&specs, |i| { + let price = (i as i32) * 10; + let qty = (i as i32) % 7; + let region = match i % 3 { + 0 => "us-east", + 1 => "us-west", + _ => "eu-west", + }; + price > 100 || region == "us-east" || qty > 5 + }); + + let rows = run_wide_segments_with_stats_pruning(specs, tree, 1).await; + assert_eq!( + rows, expected, + "3-way OR with nested structure must produce correct results after flatten" + ); +} + +/// Same as above but tests different clause orderings to ensure no +/// order-dependent pruning bugs. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stats_prune_three_way_or_ordering_independence() { + let specs = vec![WideSegSpec { + brand: "amazon", + rows: 16, + max_rg_rows: 4, + }]; + + let expected = wide_oracle(&specs, |i| { + let price = (i as i32) * 10; + let qty = (i as i32) % 7; + let region = match i % 3 { + 0 => "us-east", + 1 => "us-west", + _ => "eu-west", + }; + price > 100 || region == "us-east" || qty > 5 + }); + + // All 3 orderings of a nested OR that flatten differently. + let orderings: Vec = vec![ + // OR(OR(A,B), C) + BoolNode::Or(vec![ + BoolNode::Or(vec![ + BoolNode::And(vec![ + BoolNode::Collector { annotation_id: 0 }, + pred_wide_int("price", Operator::Gt, 100), + ]), + BoolNode::And(vec![ + BoolNode::Collector { annotation_id: 1 }, + pred_wide_str("region", Operator::Eq, "us-east"), + ]), + ]), + BoolNode::And(vec![ + BoolNode::Collector { annotation_id: 2 }, + pred_wide_int("qty", Operator::Gt, 5), + ]), + ]), + // OR(A, OR(B,C)) + BoolNode::Or(vec![ + BoolNode::And(vec![ + BoolNode::Collector { annotation_id: 0 }, + pred_wide_int("price", Operator::Gt, 100), + ]), + BoolNode::Or(vec![ + BoolNode::And(vec![ + BoolNode::Collector { annotation_id: 1 }, + pred_wide_str("region", Operator::Eq, "us-east"), + ]), + BoolNode::And(vec![ + BoolNode::Collector { annotation_id: 2 }, + pred_wide_int("qty", Operator::Gt, 5), + ]), + ]), + ]), + // OR(OR(B,C), A) + BoolNode::Or(vec![ + BoolNode::Or(vec![ + BoolNode::And(vec![ + BoolNode::Collector { annotation_id: 1 }, + pred_wide_str("region", Operator::Eq, "us-east"), + ]), + BoolNode::And(vec![ + BoolNode::Collector { annotation_id: 2 }, + pred_wide_int("qty", Operator::Gt, 5), + ]), + ]), + BoolNode::And(vec![ + BoolNode::Collector { annotation_id: 0 }, + pred_wide_int("price", Operator::Gt, 100), + ]), + ]), + ]; + + for (idx, tree) in orderings.into_iter().enumerate() { + let rows = run_wide_segments_with_stats_pruning( + vec![WideSegSpec { + brand: "amazon", + rows: 16, + max_rg_rows: 4, + }], + tree, + 1, + ) + .await; + assert_eq!(rows, expected, "ordering {} produced wrong results", idx); + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/null_columns.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/null_columns.rs index 197029d52bbeb..bfc5868b26f4e 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/null_columns.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/null_columns.rs @@ -333,10 +333,10 @@ async fn assert_engine_matches_reference_null(name: &str, tree: NT) { parquet_size: size, row_groups: rgs, metadata: Arc::clone(&parquet_meta), - global_base: 0, - sort_min: None, + global_base: 0, + sort_min: None, sort_max: None, -}; + }; let tree = Arc::new(bt); let per_leaf: Vec<(i32, Arc)> = collectors @@ -364,6 +364,7 @@ async fn assert_engine_matches_reference_null(name: &str, tree: NT) { collector_strategy: crate::indexed_table::eval::CollectorCallStrategy::TightenOuterBounds, stats_prune_tree: None, + rg_index_to_pos: HashMap::new(), }); Ok(eval) }) @@ -372,7 +373,7 @@ async fn assert_engine_matches_reference_null(name: &str, tree: NT) { let qc = crate::datafusion_query_config::DatafusionQueryConfig::builder() .target_partitions(1) .force_strategy(Some(FilterStrategy::BooleanMask)) - .force_pushdown(Some(false)) + .indexed_pushdown_filters(false) .build(); let provider = Arc::new(IndexedTableProvider::new(IndexedTableConfig { schema: schema.clone(), @@ -388,6 +389,7 @@ async fn assert_engine_matches_reference_null(name: &str, tree: NT) { prune_tree_config: None, sort_fields: vec![], sort_orders: vec![], + cancellation_token: None, })); let ctx = SessionContext::new(); ctx.register_table("t", provider).unwrap(); diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/page_pruning.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/page_pruning.rs index 7380793231abb..03c9e0ba7cec9 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/page_pruning.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/page_pruning.rs @@ -216,10 +216,10 @@ fn load_segment(tmp: &NamedTempFile) -> (SegmentFileInfo, SchemaRef) { parquet_size: size, row_groups: rgs, metadata: parquet_meta, - global_base: 0, - sort_min: None, + global_base: 0, + sort_min: None, sort_max: None, -}; + }; (seg, schema) } @@ -329,6 +329,7 @@ async fn run_bitmap_tree(tree: BoolNode) -> (Vec, Arc) { collector_strategy: crate::indexed_table::eval::CollectorCallStrategy::TightenOuterBounds, stats_prune_tree: None, + rg_index_to_pos: HashMap::new(), }); Ok(eval) }) @@ -367,6 +368,7 @@ async fn run_single_collector( 0, None, None, + std::collections::HashMap::new(), )); Ok(eval) }) @@ -386,7 +388,7 @@ async fn execute_and_collect( let qc = crate::datafusion_query_config::DatafusionQueryConfig::builder() .target_partitions(1) .force_strategy(Some(FilterStrategy::BooleanMask)) - .force_pushdown(Some(false)) + .indexed_pushdown_filters(false) .build(); let provider = Arc::new(IndexedTableProvider::new(IndexedTableConfig { schema: schema.clone(), @@ -401,6 +403,7 @@ async fn execute_and_collect( prune_tree_config: None, sort_fields: vec![], sort_orders: vec![], + cancellation_token: None, })); let ctx = SessionContext::new(); diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/qtf_fetch_phase.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/qtf_fetch_phase.rs index adc2324427011..fdb7c6b8b9ad6 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/qtf_fetch_phase.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/qtf_fetch_phase.rs @@ -62,9 +62,9 @@ async fn query_phase(tree: BoolNode) -> Vec { row_groups: rgs, metadata: Arc::clone(&parquet_meta), global_base: 0, - sort_min: None, + sort_min: None, sort_max: None, -}; + }; let tree = tree.push_not_down(); let collectors = wire_collectors(&tree); @@ -102,6 +102,7 @@ async fn query_phase(tree: BoolNode) -> Vec { collector_strategy: crate::indexed_table::eval::CollectorCallStrategy::TightenOuterBounds, stats_prune_tree: None, + rg_index_to_pos: HashMap::new(), }); Ok(eval) }) @@ -121,7 +122,7 @@ async fn query_phase(tree: BoolNode) -> Vec { let mut qc = crate::datafusion_query_config::DatafusionQueryConfig::test_default(); qc.target_partitions = 1; qc.force_strategy = Some(FilterStrategy::BooleanMask); - qc.force_pushdown = Some(false); + qc.indexed_pushdown_filters = false; qc }), predicate_columns: vec![0, 1, 2, 3], @@ -129,6 +130,7 @@ async fn query_phase(tree: BoolNode) -> Vec { prune_tree_config: None, sort_fields: vec![], sort_orders: vec![], + cancellation_token: None, })); let ctx = SessionContext::new(); @@ -154,10 +156,7 @@ async fn query_phase(tree: BoolNode) -> Vec { /// Run the fetch phase: given row IDs, build a ShardTableProvider with /// ParquetAccessPlan to read only those rows, then execute SQL to get data. /// Returns (row_id, column_values) tuples sorted by row_id. -async fn fetch_phase( - row_ids: &[i64], - fetch_columns: &[&str], -) -> Vec { +async fn fetch_phase(row_ids: &[i64], fetch_columns: &[&str]) -> Vec { if row_ids.is_empty() { return vec![]; } @@ -202,8 +201,7 @@ async fn fetch_phase( } } if !rg_bitmap.is_empty() { - let selection = - build_row_selection_with_min_skip_run(&rg_bitmap, rg_num_rows, 1); + let selection = build_row_selection_with_min_skip_run(&rg_bitmap, rg_num_rows, 1); plan.set(rg_idx, RowGroupAccess::Selection(selection)); } } @@ -303,7 +301,6 @@ fn extract_id_brand(batches: &[RecordBatch]) -> Vec<(i64, String)> { rows } - // ── Tests ──────────────────────────────────────────────────────────── /// Full QTF loop with SingleCollector: brand="amazon" -> get row IDs -> fetch brand+price -> verify. @@ -451,7 +448,8 @@ async fn test_qtf_full_loop_two_segments() { ctx.register_object_store(store_url.as_ref(), store); ctx.register_table("t", provider).unwrap(); - let sql = "SELECT (\"__row_id__\" + \"row_base\") AS \"__row_id__\", \"brand\", \"price\" FROM t"; + let sql = + "SELECT (\"__row_id__\" + \"row_base\") AS \"__row_id__\", \"brand\", \"price\" FROM t"; let df = ctx.sql(sql).await.unwrap(); let plan = df.create_physical_plan().await.unwrap(); let task_ctx = ctx.task_ctx(); @@ -509,10 +507,7 @@ async fn test_qtf_fetch_subset_columns() { /// brand="amazon" AND price=30 matches only row 12. #[tokio::test] async fn test_qtf_fetch_single_row() { - let tree = BoolNode::And(vec![ - index_leaf(0), - pred_int("price", Operator::Eq, 30), - ]); + let tree = BoolNode::And(vec![index_leaf(0), pred_int("price", Operator::Eq, 30)]); let (row_ids, batches) = query_then_fetch(tree, vec!["brand", "price"]).await; assert_eq!(row_ids, vec![12]); @@ -526,10 +521,7 @@ async fn test_qtf_fetch_single_row() { /// brand="amazon" AND price > 500 matches nothing. #[tokio::test] async fn test_qtf_fetch_empty_result() { - let tree = BoolNode::And(vec![ - index_leaf(0), - pred_int("price", Operator::Gt, 500), - ]); + let tree = BoolNode::And(vec![index_leaf(0), pred_int("price", Operator::Gt, 500)]); let (row_ids, batches) = query_then_fetch(tree, vec!["brand", "price"]).await; assert!(row_ids.is_empty()); diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/row_id_emission.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/row_id_emission.rs index 71a8ab263fe51..0a9b949e35926 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/row_id_emission.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/row_id_emission.rs @@ -19,7 +19,6 @@ use super::*; // Lot of query shapes --> for the query sent, how will that translate into at the data node side? - /// Helper: run a tree with `emit_row_ids: true` and return the collected row IDs. async fn run_tree_row_ids(tree: BoolNode) -> Vec { let tmp = write_fixture_parquet(); @@ -52,9 +51,9 @@ async fn run_tree_row_ids(tree: BoolNode) -> Vec { row_groups: rgs, metadata: Arc::clone(&parquet_meta), global_base: 0, - sort_min: None, + sort_min: None, sort_max: None, -}; + }; let tree = tree.push_not_down(); let collectors = wire_collectors(&tree); @@ -92,6 +91,7 @@ async fn run_tree_row_ids(tree: BoolNode) -> Vec { collector_strategy: crate::indexed_table::eval::CollectorCallStrategy::TightenOuterBounds, stats_prune_tree: None, + rg_index_to_pos: HashMap::new(), }); Ok(eval) }) @@ -111,7 +111,7 @@ async fn run_tree_row_ids(tree: BoolNode) -> Vec { let mut qc = crate::datafusion_query_config::DatafusionQueryConfig::test_default(); qc.target_partitions = 1; qc.force_strategy = Some(FilterStrategy::BooleanMask); - qc.force_pushdown = Some(false); + qc.indexed_pushdown_filters = false; qc }), predicate_columns: vec![0, 1, 2, 3], @@ -119,6 +119,7 @@ async fn run_tree_row_ids(tree: BoolNode) -> Vec { prune_tree_config: None, sort_fields: vec![], sort_orders: vec![], + cancellation_token: None, })); let ctx = SessionContext::new(); @@ -222,9 +223,9 @@ async fn run_tree_row_ids_with_global_base(tree: BoolNode, global_base: u64) -> row_groups: rgs, metadata: Arc::clone(&parquet_meta), global_base, - sort_min: None, + sort_min: None, sort_max: None, -}; + }; let tree = tree.push_not_down(); let collectors = wire_collectors(&tree); @@ -262,6 +263,7 @@ async fn run_tree_row_ids_with_global_base(tree: BoolNode, global_base: u64) -> collector_strategy: crate::indexed_table::eval::CollectorCallStrategy::TightenOuterBounds, stats_prune_tree: None, + rg_index_to_pos: HashMap::new(), }); Ok(eval) }) @@ -281,7 +283,7 @@ async fn run_tree_row_ids_with_global_base(tree: BoolNode, global_base: u64) -> let mut qc = crate::datafusion_query_config::DatafusionQueryConfig::test_default(); qc.target_partitions = 1; qc.force_strategy = Some(FilterStrategy::BooleanMask); - qc.force_pushdown = Some(false); + qc.indexed_pushdown_filters = false; qc }), predicate_columns: vec![0, 1, 2, 3], @@ -289,6 +291,7 @@ async fn run_tree_row_ids_with_global_base(tree: BoolNode, global_base: u64) -> prune_tree_config: None, sort_fields: vec![], sort_orders: vec![], + cancellation_token: None, })); let ctx = SessionContext::new(); @@ -370,17 +373,25 @@ async fn test_all_query_types_match_fixture_positions() { let ids = run_tree_row_ids(BoolNode::And(vec![ index_leaf(0), pred_int("price", Operator::Gt, 100), - ])).await; + ])) + .await; let expected = expected_rows(|i| BRANDS[i] == "amazon" && PRICES[i] > 100); - assert_eq!(ids, expected, "Collector+Predicate(amazon,price>100) mismatch"); + assert_eq!( + ids, expected, + "Collector+Predicate(amazon,price>100) mismatch" + ); // Collector + Predicate: apple AND price < 90 → rows 7,13 let ids = run_tree_row_ids(BoolNode::And(vec![ index_leaf(1), pred_int("price", Operator::Lt, 90), - ])).await; + ])) + .await; let expected = expected_rows(|i| BRANDS[i] == "apple" && PRICES[i] < 90); - assert_eq!(ids, expected, "Collector+Predicate(apple,price<90) mismatch"); + assert_eq!( + ids, expected, + "Collector+Predicate(apple,price<90) mismatch" + ); // Tree OR: amazon OR apple → rows 0-7,12,13 let ids = run_tree_row_ids(BoolNode::Or(vec![index_leaf(0), index_leaf(1)])).await; @@ -397,21 +408,19 @@ async fn test_all_query_types_match_fixture_positions() { let ids = run_tree_row_ids(BoolNode::And(vec![ BoolNode::Or(vec![index_leaf(0), index_leaf(1)]), pred_int("price", Operator::Gt, 100), - ])).await; - let expected = expected_rows(|i| (BRANDS[i] == "amazon" || BRANDS[i] == "apple") && PRICES[i] > 100); + ])) + .await; + let expected = + expected_rows(|i| (BRANDS[i] == "amazon" || BRANDS[i] == "apple") && PRICES[i] > 100); assert_eq!(ids, expected, "Tree OR+Predicate mismatch"); // Predicate only: price >= 150 → rows 1,6,9,11 - let ids = run_tree_row_ids(BoolNode::And(vec![ - pred_int("price", Operator::GtEq, 150), - ])).await; + let ids = run_tree_row_ids(BoolNode::And(vec![pred_int("price", Operator::GtEq, 150)])).await; let expected = expected_rows(|i| PRICES[i] >= 150); assert_eq!(ids, expected, "Predicate-only(price>=150) mismatch"); // Predicate only: price < 50 → rows 8,12,13 - let ids = run_tree_row_ids(BoolNode::And(vec![ - pred_int("price", Operator::Lt, 50), - ])).await; + let ids = run_tree_row_ids(BoolNode::And(vec![pred_int("price", Operator::Lt, 50)])).await; let expected = expected_rows(|i| PRICES[i] < 50); assert_eq!(ids, expected, "Predicate-only(price<50) mismatch"); @@ -419,14 +428,18 @@ async fn test_all_query_types_match_fixture_positions() { let ids = run_tree_row_ids(BoolNode::And(vec![ pred_int("price", Operator::Gt, 50), pred_int("price", Operator::Lt, 100), - ])).await; + ])) + .await; let expected = expected_rows(|i| PRICES[i] > 50 && PRICES[i] < 100); assert_eq!(ids, expected, "Multi-predicate(50 80) || (BRANDS[i] == "apple" && STATUSES[i] == "archived") @@ -489,9 +504,9 @@ async fn test_row_id_with_data_columns() { row_groups: rgs, metadata: Arc::clone(&parquet_meta), global_base: 0, - sort_min: None, + sort_min: None, sort_max: None, -}; + }; // Filter: brand = "amazon" (rows 0,1,2,3,12) let tree = BoolNode::And(vec![index_leaf(0)]).push_not_down(); @@ -530,6 +545,7 @@ async fn test_row_id_with_data_columns() { collector_strategy: crate::indexed_table::eval::CollectorCallStrategy::TightenOuterBounds, stats_prune_tree: None, + rg_index_to_pos: HashMap::new(), }); Ok(eval) }) @@ -549,7 +565,7 @@ async fn test_row_id_with_data_columns() { let mut qc = crate::datafusion_query_config::DatafusionQueryConfig::test_default(); qc.target_partitions = 1; qc.force_strategy = Some(FilterStrategy::BooleanMask); - qc.force_pushdown = Some(false); + qc.indexed_pushdown_filters = false; qc }), predicate_columns: vec![0, 1, 2, 3], @@ -557,6 +573,7 @@ async fn test_row_id_with_data_columns() { prune_tree_config: None, sort_fields: vec![], sort_orders: vec![], + cancellation_token: None, })); let ctx = SessionContext::new(); @@ -573,7 +590,11 @@ async fn test_row_id_with_data_columns() { let mut rows: Vec<(i64, String, i32)> = Vec::new(); while let Some(batch) = stream.next().await { let b = batch.unwrap(); - assert_eq!(b.num_columns(), 3, "should have 3 columns: __row_id__, brand, price"); + assert_eq!( + b.num_columns(), + 3, + "should have 3 columns: __row_id__, brand, price" + ); assert_eq!(b.schema().field(0).name(), "__row_id__"); assert_eq!(b.schema().field(1).name(), "brand"); assert_eq!(b.schema().field(2).name(), "price"); @@ -608,15 +629,20 @@ async fn test_row_id_column_detection() { vec![ Arc::new(datafusion::arrow::array::StringArray::from(BRANDS.to_vec())), Arc::new(datafusion::arrow::array::Int32Array::from(PRICES.to_vec())), - Arc::new(datafusion::arrow::array::StringArray::from(STATUSES.to_vec())), - Arc::new(datafusion::arrow::array::StringArray::from(CATEGORIES.to_vec())), + Arc::new(datafusion::arrow::array::StringArray::from( + STATUSES.to_vec(), + )), + Arc::new(datafusion::arrow::array::StringArray::from( + CATEGORIES.to_vec(), + )), Arc::new(datafusion::arrow::array::Int64Array::from(row_ids)), ], ) .unwrap(); let ctx = SessionContext::new(); - let mem_table = datafusion::datasource::MemTable::try_new(schema.clone(), vec![vec![batch]]).unwrap(); + let mem_table = + datafusion::datasource::MemTable::try_new(schema.clone(), vec![vec![batch]]).unwrap(); ctx.register_table("t", Arc::new(mem_table)).unwrap(); // Plan with __row_id__ in projection — should be detected @@ -734,9 +760,9 @@ async fn run_two_segments_row_ids(tree: BoolNode) -> Vec { row_groups: rgs1, metadata: Arc::clone(&parquet_meta1), global_base: 0, - sort_min: None, + sort_min: None, sort_max: None, -}; + }; let segment2 = SegmentFileInfo { writer_generation: 1, max_doc: 16, @@ -745,9 +771,9 @@ async fn run_two_segments_row_ids(tree: BoolNode) -> Vec { row_groups: rgs2, metadata: Arc::clone(&parquet_meta2), global_base: 16, - sort_min: None, + sort_min: None, sort_max: None, -}; + }; let tree = tree.push_not_down(); let collectors = wire_collectors(&tree); @@ -785,6 +811,7 @@ async fn run_two_segments_row_ids(tree: BoolNode) -> Vec { collector_strategy: crate::indexed_table::eval::CollectorCallStrategy::TightenOuterBounds, stats_prune_tree: None, + rg_index_to_pos: HashMap::new(), }); Ok(eval) }) @@ -804,7 +831,7 @@ async fn run_two_segments_row_ids(tree: BoolNode) -> Vec { let mut qc = crate::datafusion_query_config::DatafusionQueryConfig::test_default(); qc.target_partitions = 1; qc.force_strategy = Some(FilterStrategy::BooleanMask); - qc.force_pushdown = Some(false); + qc.indexed_pushdown_filters = false; qc }), predicate_columns: vec![0, 1, 2, 3], @@ -812,6 +839,7 @@ async fn run_two_segments_row_ids(tree: BoolNode) -> Vec { prune_tree_config: None, sort_fields: vec![], sort_orders: vec![], + cancellation_token: None, })); let ctx = SessionContext::new(); @@ -872,5 +900,8 @@ async fn test_emit_row_ids_two_segments_with_filter() { 10, "should have 10 row IDs (5 amazon rows per segment)" ); - assert_eq!(ids, expected, "filtered row IDs should be offset by global_base"); + assert_eq!( + ids, expected, + "filtered row IDs should be offset by global_base" + ); } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/row_id_strategies.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/row_id_strategies.rs index 7a7d8dc9fea53..1fde3ea9e3478 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/row_id_strategies.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/row_id_strategies.rs @@ -6,10 +6,10 @@ * compatible open source license. */ -//! End-to-end correctness tests for row ID emission across all three strategies. +//! End-to-end correctness tests for shard-global row ID computation. //! //! These tests create real parquet files with known `___row_id` values and verify -//! that all three `QueryStrategy` variants produce identical shard-global row IDs. +//! that the row-base prefix-sum produces unique, contiguous shard-global row IDs. #[cfg(test)] mod tests { @@ -26,7 +26,6 @@ mod tests { use tempfile::TempDir; use crate::api::{build_shard_files, FileRowMetadata}; - use crate::datafusion_query_config::QueryStrategy; use crate::project_row_id_optimizer::ProjectRowIdOptimizer; /// Create a test parquet file with `___row_id` column containing positional indices. @@ -57,7 +56,9 @@ mod tests { while written < num_rows { let batch_size = (num_rows - written).min(rows_per_rg); let row_ids: Vec = (written..written + batch_size).map(|i| i as i64).collect(); - let values: Vec = (written..written + batch_size).map(|i| (i * 10) as i32).collect(); + let values: Vec = (written..written + batch_size) + .map(|i| (i * 10) as i32) + .collect(); let names: Vec = (written..written + batch_size) .map(|i| format!("row_{}", i)) .collect(); @@ -94,9 +95,15 @@ mod tests { let (path3, rg_counts3) = create_test_parquet(dir.path(), "file3.parquet", 50, 100); let file_metadata = vec![ - FileRowMetadata { row_group_row_counts: rg_counts1 }, - FileRowMetadata { row_group_row_counts: rg_counts2 }, - FileRowMetadata { row_group_row_counts: rg_counts3 }, + FileRowMetadata { + row_group_row_counts: rg_counts1, + }, + FileRowMetadata { + row_group_row_counts: rg_counts2, + }, + FileRowMetadata { + row_group_row_counts: rg_counts3, + }, ]; let total_rows = 100 + 150 + 50; @@ -149,7 +156,7 @@ mod tests { let shard_files = build_shard_files(&object_metas, &metadata); // Verify row_base values - assert_eq!(shard_files[0].row_base, 0); // First file starts at 0 + assert_eq!(shard_files[0].row_base, 0); // First file starts at 0 assert_eq!(shard_files[1].row_base, 100); // Second file starts at 100 assert_eq!(shard_files[2].row_base, 250); // Third file starts at 250 @@ -214,9 +221,15 @@ mod tests { let (_path3, rg3) = create_test_parquet(dir.path(), "tiny.parquet", 7, 100); let metadata = vec![ - FileRowMetadata { row_group_row_counts: rg1 }, - FileRowMetadata { row_group_row_counts: rg2 }, - FileRowMetadata { row_group_row_counts: rg3 }, + FileRowMetadata { + row_group_row_counts: rg1, + }, + FileRowMetadata { + row_group_row_counts: rg2, + }, + FileRowMetadata { + row_group_row_counts: rg3, + }, ]; let object_metas: Vec = vec![ @@ -283,13 +296,6 @@ mod tests { assert_eq!(result.schema().field(1).name(), "b"); } - #[test] - fn test_query_strategy_default_is_none() { - let config = crate::datafusion_query_config::DatafusionQueryConfig::test_default(); - assert_eq!(config.query_strategy, QueryStrategy::None); - } - - #[test] fn test_build_shard_files_empty() { let shard_files = build_shard_files(&[], &[]); diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/schema_drift.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/schema_drift.rs index 665f5f0e878f4..8eb3322a3be46 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/schema_drift.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/schema_drift.rs @@ -96,10 +96,10 @@ async fn run_missing_col_tree(tree_bool: BoolNode) -> usize { parquet_size: size, row_groups: rgs, metadata: Arc::clone(&parquet_meta), - global_base: 0, - sort_min: None, + global_base: 0, + sort_min: None, sort_max: None, -}; + }; let tree = Arc::new(tree_bool); let factory: super::super::table_provider::EvaluatorFactory = { @@ -118,8 +118,10 @@ async fn run_missing_col_tree(tree_bool: BoolNode) -> usize { max_collector_parallelism: 1, pruning_predicates: std::sync::Arc::new(std::collections::HashMap::new()), page_prune_metrics: None, - collector_strategy: crate::indexed_table::eval::CollectorCallStrategy::TightenOuterBounds, + collector_strategy: + crate::indexed_table::eval::CollectorCallStrategy::TightenOuterBounds, stats_prune_tree: None, + rg_index_to_pos: HashMap::new(), }); Ok(eval) }) @@ -127,7 +129,7 @@ async fn run_missing_col_tree(tree_bool: BoolNode) -> usize { let qc = crate::datafusion_query_config::DatafusionQueryConfig::builder() .target_partitions(1) .force_strategy(Some(FilterStrategy::BooleanMask)) - .force_pushdown(Some(false)) + .indexed_pushdown_filters(false) .build(); let provider = Arc::new(IndexedTableProvider::new(IndexedTableConfig { schema: schema.clone(), @@ -143,6 +145,7 @@ async fn run_missing_col_tree(tree_bool: BoolNode) -> usize { prune_tree_config: None, sort_fields: vec![], sort_orders: vec![], + cancellation_token: None, })); let ctx = SessionContext::new(); ctx.register_table("t", provider).unwrap(); @@ -289,18 +292,15 @@ fn page_pruner_prunes_existing_column_despite_missing_column() { // so ~10% of rows match, concentrated at the start of every 1000-row // cycle → some pages will be prunable). let score_idx = drift_schema.index_of("score").unwrap(); - let score_col: std::sync::Arc = std::sync::Arc::new( - datafusion::physical_expr::expressions::Column::new("score", score_idx), - ); + let score_col: std::sync::Arc = + std::sync::Arc::new(datafusion::physical_expr::expressions::Column::new( + "score", score_idx, + )); let lit_100: std::sync::Arc = std::sync::Arc::new( datafusion::physical_expr::expressions::Literal::new(ScalarValue::Int32(Some(100))), ); let score_lt: std::sync::Arc = std::sync::Arc::new( - datafusion::physical_expr::expressions::BinaryExpr::new( - score_col, - Operator::Lt, - lit_100, - ), + datafusion::physical_expr::expressions::BinaryExpr::new(score_col, Operator::Lt, lit_100), ); let pp_solo = build_pruning_predicate(&score_lt, drift_schema.clone()) .expect("score<100 is not always_true on this fixture"); @@ -345,8 +345,7 @@ fn page_pruner_prunes_existing_column_despite_missing_column() { match (solo.as_ref(), combined.as_ref()) { (Some(s), Some(c)) => { let solo_kept: usize = s.iter().filter(|r| !r.skip).map(|r| r.row_count).sum(); - let combined_kept: usize = - c.iter().filter(|r| !r.skip).map(|r| r.row_count).sum(); + let combined_kept: usize = c.iter().filter(|r| !r.skip).map(|r| r.row_count).sum(); assert!( combined_kept <= solo_kept, "rg {}: combined selection kept {} rows, solo kept {} — \ @@ -410,9 +409,9 @@ async fn query_with_mismatched_schema( row_groups: rgs, metadata: Arc::clone(&parquet_meta), global_base: 0, - sort_min: None, + sort_min: None, sort_max: None, -}; + }; let tree = Arc::new(tree_bool); let factory: super::super::table_provider::EvaluatorFactory = { let tree = Arc::clone(&tree); @@ -433,6 +432,7 @@ async fn query_with_mismatched_schema( collector_strategy: crate::indexed_table::eval::CollectorCallStrategy::TightenOuterBounds, stats_prune_tree: None, + rg_index_to_pos: HashMap::new(), }); Ok(eval) }) @@ -440,7 +440,7 @@ async fn query_with_mismatched_schema( let qc = crate::datafusion_query_config::DatafusionQueryConfig::builder() .target_partitions(1) .force_strategy(Some(FilterStrategy::BooleanMask)) - .force_pushdown(Some(false)) + .indexed_pushdown_filters(false) .build(); let provider = Arc::new(IndexedTableProvider::new(IndexedTableConfig { schema: table_schema, @@ -456,6 +456,7 @@ async fn query_with_mismatched_schema( prune_tree_config: None, sort_fields: vec![], sort_orders: vec![], + cancellation_token: None, })); let ctx = SessionContext::new(); ctx.register_table("t", provider).unwrap(); diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/sort_reverse_row_id.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/sort_reverse_row_id.rs index 3f50c80ef4ca1..9d24941baebd4 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/sort_reverse_row_id.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/sort_reverse_row_id.rs @@ -22,6 +22,7 @@ //! `reverse_segment_iteration_order` preserves each segment's original //! `global_base`, which is exactly what these tests verify end-to-end. +use std::collections::HashMap; use std::sync::Arc; use datafusion::arrow::array::{Int32Array, Int64Array, StringArray}; @@ -117,10 +118,7 @@ impl RowGroupDocsCollector for LocalOffsetCollector { /// Build a `Vec` for the given specs, including catalog-order /// `global_base` so `__row_id__ = global_base + local_pos` resolves to a unique /// shard-global value per matching row. -fn build_segments( - tmps: &[NamedTempFile], - specs: &[SegSpec], -) -> (Vec, SchemaRef) { +fn build_segments(tmps: &[NamedTempFile], specs: &[SegSpec]) -> (Vec, SchemaRef) { let mut segs = Vec::new(); let mut schema_opt: Option = None; let mut cumulative: u64 = 0; @@ -128,8 +126,9 @@ fn build_segments( let path = tmp.path().to_path_buf(); let size = std::fs::metadata(&path).unwrap().len(); let file = std::fs::File::open(&path).unwrap(); - let meta = ArrowReaderMetadata::load(&file, ArrowReaderOptions::new().with_page_index(true)) - .unwrap(); + let meta = + ArrowReaderMetadata::load(&file, ArrowReaderOptions::new().with_page_index(true)) + .unwrap(); if schema_opt.is_none() { schema_opt = Some(meta.schema().clone()); } @@ -173,7 +172,10 @@ async fn collect_row_ids( // EvaluatorFactory selects the matching set for the segment it's asked about — so // this works whether segments are in natural order or reversed (it identifies the // segment by its writer_generation, which moves with the segment). - let per_gen: Vec> = specs.iter().map(|s| s.match_local_offsets.clone()).collect(); + let per_gen: Vec> = specs + .iter() + .map(|s| s.match_local_offsets.clone()) + .collect(); let per_gen = Arc::new(per_gen); let factory: super::super::table_provider::EvaluatorFactory = { @@ -184,7 +186,8 @@ async fn collect_row_ids( .get(segment.writer_generation as usize) .cloned() .unwrap_or_default(); - let collector: Arc = Arc::new(LocalOffsetCollector { matching }); + let collector: Arc = + Arc::new(LocalOffsetCollector { matching }); let pruner = Arc::new(PagePruner::new(&schema, Arc::clone(&segment.metadata))); // Single-collector tree. let tree = BoolNode::Collector { annotation_id: 0 }; @@ -193,9 +196,11 @@ async fn collect_row_ids( let eval: Arc = Arc::new(TreeBitsetSource { tree: Arc::new(resolved), evaluator: Arc::new(BitmapTreeEvaluator), - leaves: Arc::new(crate::indexed_table::eval::bitmap_tree::CollectorLeafBitmaps { - ffm_collector_calls: _stream_metrics.ffm_collector_calls.clone(), - }), + leaves: Arc::new( + crate::indexed_table::eval::bitmap_tree::CollectorLeafBitmaps { + ffm_collector_calls: _stream_metrics.ffm_collector_calls.clone(), + }, + ), page_pruner: pruner, cost_predicate: 1, cost_collector: 10, @@ -206,8 +211,10 @@ async fn collect_row_ids( _stream_metrics, ), ), - collector_strategy: crate::indexed_table::eval::CollectorCallStrategy::TightenOuterBounds, + collector_strategy: + crate::indexed_table::eval::CollectorCallStrategy::TightenOuterBounds, stats_prune_tree: None, + rg_index_to_pos: HashMap::new(), }); Ok(eval) }) @@ -219,7 +226,7 @@ async fn collect_row_ids( let qc = crate::datafusion_query_config::DatafusionQueryConfig::builder() .target_partitions(1) .force_strategy(Some(FilterStrategy::BooleanMask)) - .force_pushdown(Some(false)) + .indexed_pushdown_filters(false) .build(); let provider = Arc::new(IndexedTableProvider::new(IndexedTableConfig { schema: schema.clone(), @@ -234,6 +241,7 @@ async fn collect_row_ids( prune_tree_config: None, sort_fields: vec![], sort_orders: vec![], + cancellation_token: None, })); let ctx = SessionContext::new(); @@ -259,9 +267,21 @@ async fn collect_row_ids( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn three_segments_row_ids_invariant_under_reversal() { let specs = vec![ - SegSpec { tag: "A", rows: 8, match_local_offsets: vec![0, 3, 7] }, // global ids 0, 3, 7 - SegSpec { tag: "B", rows: 6, match_local_offsets: vec![1, 4] }, // global ids 9, 12 - SegSpec { tag: "C", rows: 10, match_local_offsets: vec![0, 5, 9] }, // global ids 14, 19, 23 + SegSpec { + tag: "A", + rows: 8, + match_local_offsets: vec![0, 3, 7], + }, // global ids 0, 3, 7 + SegSpec { + tag: "B", + rows: 6, + match_local_offsets: vec![1, 4], + }, // global ids 9, 12 + SegSpec { + tag: "C", + rows: 10, + match_local_offsets: vec![0, 5, 9], + }, // global ids 14, 19, 23 ]; let tmps: Vec = specs.iter().map(write_segment).collect(); let (segs_natural, schema) = build_segments(&tmps, &specs); @@ -279,7 +299,10 @@ async fn three_segments_row_ids_invariant_under_reversal() { let expected = vec![0i64, 3, 7, 9, 12, 14, 19, 23]; assert_eq!(ids_natural, expected, "natural-order ids"); - assert_eq!(ids_reversed, expected, "reversed-order ids — same shard-global IDs as natural"); + assert_eq!( + ids_reversed, expected, + "reversed-order ids — same shard-global IDs as natural" + ); } /// 4 segments. Confirms the property holds at the larger end of the test contract @@ -288,10 +311,26 @@ async fn three_segments_row_ids_invariant_under_reversal() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn four_segments_row_ids_invariant_under_reversal() { let specs = vec![ - SegSpec { tag: "A", rows: 5, match_local_offsets: vec![0, 4] }, // 0, 4 - SegSpec { tag: "B", rows: 5, match_local_offsets: vec![2] }, // 7 - SegSpec { tag: "C", rows: 5, match_local_offsets: vec![1, 3] }, // 11, 13 - SegSpec { tag: "D", rows: 5, match_local_offsets: vec![0, 2, 4] }, // 15, 17, 19 + SegSpec { + tag: "A", + rows: 5, + match_local_offsets: vec![0, 4], + }, // 0, 4 + SegSpec { + tag: "B", + rows: 5, + match_local_offsets: vec![2], + }, // 7 + SegSpec { + tag: "C", + rows: 5, + match_local_offsets: vec![1, 3], + }, // 11, 13 + SegSpec { + tag: "D", + rows: 5, + match_local_offsets: vec![0, 2, 4], + }, // 15, 17, 19 ]; let tmps: Vec = specs.iter().map(write_segment).collect(); let (segs_natural, schema) = build_segments(&tmps, &specs); diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/streaming_at_scale.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/streaming_at_scale.rs index 821db439365b8..b709621d5b539 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/streaming_at_scale.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_table/tests_e2e/streaming_at_scale.rs @@ -406,10 +406,10 @@ async fn run_large( parquet_size: size, row_groups: rgs, metadata: Arc::clone(&parquet_meta), - global_base: 0, - sort_min: None, + global_base: 0, + sort_min: None, sort_max: None, -}; + }; let tree = Arc::new(tree); let per_leaf: Vec<(i32, Arc)> = collectors @@ -434,8 +434,10 @@ async fn run_large( max_collector_parallelism: 1, pruning_predicates: std::sync::Arc::new(std::collections::HashMap::new()), page_prune_metrics: None, - collector_strategy: crate::indexed_table::eval::CollectorCallStrategy::TightenOuterBounds, - stats_prune_tree: None, + collector_strategy: + crate::indexed_table::eval::CollectorCallStrategy::TightenOuterBounds, + stats_prune_tree: None, + rg_index_to_pos: HashMap::new(), }); Ok(eval) }) @@ -444,7 +446,7 @@ async fn run_large( let qc = crate::datafusion_query_config::DatafusionQueryConfig::builder() .target_partitions(1) .force_strategy(Some(FilterStrategy::BooleanMask)) - .force_pushdown(Some(false)) + .indexed_pushdown_filters(false) .build(); let provider = Arc::new(IndexedTableProvider::new(IndexedTableConfig { schema: schema.clone(), @@ -460,6 +462,7 @@ async fn run_large( prune_tree_config: None, sort_fields: vec![], sort_orders: vec![], + cancellation_token: None, })); let ctx = SessionContext::new(); @@ -864,10 +867,10 @@ async fn run_large_partitioned( parquet_size: size, row_groups: rgs, metadata: Arc::clone(&parquet_meta), - global_base: 0, - sort_min: None, + global_base: 0, + sort_min: None, sort_max: None, -}; + }; let tree = Arc::new(tree); let per_leaf: Vec<(i32, Arc)> = collectors @@ -892,8 +895,10 @@ async fn run_large_partitioned( max_collector_parallelism: 1, pruning_predicates: std::sync::Arc::new(std::collections::HashMap::new()), page_prune_metrics: None, - collector_strategy: crate::indexed_table::eval::CollectorCallStrategy::TightenOuterBounds, - stats_prune_tree: None, + collector_strategy: + crate::indexed_table::eval::CollectorCallStrategy::TightenOuterBounds, + stats_prune_tree: None, + rg_index_to_pos: HashMap::new(), }); Ok(eval) }) @@ -901,7 +906,7 @@ async fn run_large_partitioned( let qc = crate::datafusion_query_config::DatafusionQueryConfig::builder() .target_partitions(partitions) .force_strategy(Some(FilterStrategy::BooleanMask)) - .force_pushdown(Some(false)) + .indexed_pushdown_filters(false) .build(); let provider = Arc::new(IndexedTableProvider::new(IndexedTableConfig { schema: schema.clone(), @@ -917,6 +922,7 @@ async fn run_large_partitioned( prune_tree_config: None, sort_fields: vec![], sort_orders: vec![], + cancellation_token: None, })); let ctx = SessionContext::new(); ctx.register_table("t", provider).unwrap(); diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/io.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/io.rs deleted file mode 100644 index 76d22d11235a9..0000000000000 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/io.rs +++ /dev/null @@ -1,56 +0,0 @@ -/* - * 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. - */ -use futures::FutureExt; -use std::cell::RefCell; -use std::future::Future; -use std::pin::Pin; -use std::task::{Context, Poll}; -use tokio::runtime::Handle; -use tokio::task::JoinHandle; - -// IO runtime thread-local registration. -// Ensures CPU-bound DataFusion threads can dispatch IO to the IO runtime. - -thread_local! { - pub static IO_RUNTIME: RefCell> = const { RefCell::new(None) }; -} - -pub fn register_io_runtime(handle: Option) { - IO_RUNTIME.set(handle) -} - -/// Runs `fut` on the IO runtime registered for this thread. -pub async fn spawn_io(fut: Fut) -> Fut::Output -where - Fut: Future + Send + 'static, - Fut::Output: Send, -{ - let h = IO_RUNTIME - .with_borrow(|h| h.clone()) - .expect("No IO runtime registered"); - DropGuard(h.spawn(fut)).await -} - -struct DropGuard(JoinHandle); - -impl Drop for DropGuard { - fn drop(&mut self) { - self.0.abort() - } -} - -impl Future for DropGuard { - type Output = T; - fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - Poll::Ready(match std::task::ready!(self.0.poll_unpin(cx)) { - Ok(v) => v, - Err(e) if e.is_cancelled() => panic!("IO runtime was shut down"), - Err(e) => std::panic::resume_unwind(e.into_panic()), - }) - } -} diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/lib.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/lib.rs index c1c853db2c04a..7f2e9eda7e53a 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/lib.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/lib.rs @@ -25,14 +25,13 @@ pub mod api; pub mod cache; pub mod cancellation; pub mod cross_rt_stream; -pub mod custom_cache_manager; pub mod datafusion_query_config; -pub mod eviction_policy; pub mod executor; pub mod ffm; +pub mod helper; pub mod indexed_executor; pub mod indexed_table; -pub mod io; +pub mod liquid_cache; pub mod local_executor; pub mod memory; pub mod memory_guard; @@ -46,15 +45,34 @@ pub mod query_budget; pub mod query_executor; pub mod query_tracker; pub mod relabel_exec; -pub mod shard_table_provider; pub mod runtime_manager; pub mod schema_coerce; pub mod session_context; -pub mod statistics_cache; -pub mod udaf; -pub mod udf; -pub mod udwf; +pub mod shard_table_provider; + pub mod native_node_stats; +pub mod scoped_index_optimizer; +pub mod scoped_page_index_reader; pub mod search_stats; pub mod stats; pub mod task_monitors; +pub mod udaf; +pub mod udf; +pub mod udwf; + +// Path aliases — old module names still resolve unchanged. +pub use cache::custom_cache_manager; +pub use cache::eviction_policy; +pub use cache::page_index as parquet_page_cache; +pub use cache::statistics_cache; + +#[cfg(test)] +mod spill_e2e_test; + +// End-to-end TieredObjectStore + TieredBlockCache integration tests. Located here +// (not in the lower-level `opensearch-tiered-storage` crate) because they drive a +// real DataFusion session + Parquet I/O, and DataFusion/Parquet/Arrow are already +// normal dependencies of this crate — keeping the storage-primitive crate's test +// build free of the DataFusion stack. +#[cfg(test)] +mod tiered_storage_integration_tests; diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/liquid_cache.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/liquid_cache.rs new file mode 100644 index 0000000000000..73faa9945ef30 --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/liquid_cache.rs @@ -0,0 +1,191 @@ +/* SPDX-License-Identifier: Apache-2.0 */ + +//! Liquid Cache integration — in-memory decoded-batch cache for Parquet scans. +//! +//! Backed by the vendored in-memory liquid-cache subset +//! (`sandbox/libs/dataformat-native/rust/liquid-cache`). There is no disk +//! tier: entries are transcoded to the Liquid format under memory pressure +//! and evicted when the budget is exhausted (`TranscodeEvict`). + +use std::sync::{ + atomic::{AtomicBool, AtomicU32, Ordering}, + Arc, OnceLock, +}; + +use datafusion::{common::DataFusionError, physical_optimizer::PhysicalOptimizerRule}; + +use liquid_cache::cache::{CachePolicy, LiquidCache, TranscodeEvict}; +use liquid_cache::cache_policies::{LiquidPolicy, LruPolicy}; +use liquid_cache_datafusion::{LiquidCacheParquet, LiquidCacheParquetRef, LocalModeOptimizer}; +use native_bridge_common::log_debug; + +const EVICTION_POLICY_LRU: &str = "lru"; + +/// Liquid cache batch size — must be a power of two (upstream default). +const LIQUID_CACHE_BATCH_SIZE: usize = 8192; + +static INSTANCE: OnceLock> = OnceLock::new(); + +// Dynamic tuning knobs — updated via cluster settings without restart. +// Selectivity threshold stored as permille (800 = 0.800) to avoid floating-point atomics. +static LC_SELECTIVITY_THRESHOLD_PERMILLE: AtomicU32 = AtomicU32::new(800); +static LC_MAX_COLUMNS: AtomicU32 = AtomicU32::new(10); + +pub fn lc_selectivity_threshold() -> f64 { + LC_SELECTIVITY_THRESHOLD_PERMILLE.load(Ordering::Relaxed) as f64 / 1000.0 +} + +pub fn lc_max_columns() -> usize { + LC_MAX_COLUMNS.load(Ordering::Relaxed) as usize +} + +pub fn set_lc_selectivity_threshold(value: f64) { + let permille = (value * 1000.0) as u32; + LC_SELECTIVITY_THRESHOLD_PERMILLE.store(permille, Ordering::Relaxed); +} + +pub fn set_lc_max_columns(value: usize) { + LC_MAX_COLUMNS.store(value as u32, Ordering::Relaxed); +} + +pub struct LiquidOnlyRuntime { + optimizer: Arc, + cache_ref: LiquidCacheParquetRef, + storage: Arc, + enabled: AtomicBool, +} + +impl LiquidOnlyRuntime { + pub fn init( + max_cache_bytes: u64, + eviction_policy: &str, + ) -> Result<&'static Self, DataFusionError> { + INSTANCE + .get_or_init(|| Self::build(max_cache_bytes, eviction_policy)) + .as_ref() + .map_err(|e| DataFusionError::Execution(e.clone())) + } + + fn build(max_cache_bytes: u64, eviction_policy: &str) -> Result { + let policy: Box = match eviction_policy { + EVICTION_POLICY_LRU => Box::new(LruPolicy::new()), + _ => Box::new(LiquidPolicy::new()), + }; + + let cache_ref: LiquidCacheParquetRef = Arc::new(LiquidCacheParquet::new( + LIQUID_CACHE_BATCH_SIZE, + max_cache_bytes as usize, + policy, + Box::new(TranscodeEvict), + )); + let optimizer = Arc::new(LocalModeOptimizer::new(cache_ref.clone())); + + Ok(Self { + optimizer, + storage: cache_ref.storage().clone(), + cache_ref, + enabled: AtomicBool::new(true), + }) + } + + pub fn optimizer(&self) -> Arc { + self.optimizer.clone() + } + + pub fn cache_ref(&self) -> &LiquidCacheParquetRef { + &self.cache_ref + } + + pub fn cache_ref_globally() -> Option { + Self::get().map(|rt| rt.cache_ref.clone()) + } + + pub fn optimizer_globally() -> Option> { + Self::get().map(|rt| rt.optimizer()) + } + + pub fn is_enabled(&self) -> bool { + self.enabled.load(Ordering::Relaxed) + } + + pub fn set_enabled(&self, enabled: bool) { + self.enabled.store(enabled, Ordering::Relaxed); + } + + pub fn set_max_memory_bytes(&self, bytes: usize) { + self.storage.budget().set_max_memory_bytes(bytes); + } + + pub fn reset_cache(&self) { + // Safety: callers only reset via the explicit REST clear action; + // concurrent queries may observe a cold cache, which is benign for + // a read-through cache (they fall back to Parquet decode). + unsafe { self.cache_ref.reset() }; + let stats = self.storage.stats(); + log_debug!( + "[LiquidCache] cache cleared: entries={}, mem_usage={} bytes", + stats.total_entries, + stats.memory_usage_bytes + ); + } + + pub fn log_stats(&self) { + let s = self.storage.stats(); + log_debug!( + "[LiquidCache] entries={}, mem={}/{}, arrow={}({} B), liquid={}({} B)", + s.total_entries, + s.memory_usage_bytes, + s.max_memory_bytes, + s.memory_arrow_entries, + s.memory_arrow_bytes, + s.memory_liquid_entries, + s.memory_liquid_bytes, + ); + let mem_pct = if s.max_memory_bytes > 0 { + (s.memory_usage_bytes as f64 / s.max_memory_bytes as f64 * 100.0) as u64 + } else { + 0 + }; + log_debug!( + "[LiquidCache] hits={}, misses={}, predicate_evals={}, mem_evictions={}, transcodes={}, mem_pressure={}%", + s.runtime.cache_hit, + s.runtime.cache_miss, + s.runtime.eval_predicate, + s.runtime.memory_evictions, + s.runtime.transcodes, + mem_pct, + ); + } + + fn get() -> Option<&'static Self> { + INSTANCE.get().and_then(|r| r.as_ref().ok()) + } + + pub fn is_enabled_globally() -> bool { + Self::get().map(|rt| rt.is_enabled()).unwrap_or(false) + } + + pub fn set_enabled_globally(enabled: bool) { + if let Some(rt) = Self::get() { + rt.set_enabled(enabled); + } + } + + pub fn set_max_memory_bytes_globally(bytes: usize) { + if let Some(rt) = Self::get() { + rt.set_max_memory_bytes(bytes); + } + } + + pub fn log_stats_if_initialized() { + if let Some(rt) = Self::get() { + rt.log_stats(); + } + } + + pub fn reset_cache_if_initialized() { + if let Some(rt) = Self::get() { + rt.reset_cache(); + } + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/local_executor.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/local_executor.rs index 1bc58c1e14c3c..42bf70d811c4a 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/local_executor.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/local_executor.rs @@ -38,8 +38,8 @@ use datafusion::execution::{SendableRecordBatchStream, SessionStateBuilder}; use datafusion::physical_plan::displayable; use datafusion::physical_plan::streaming::PartitionStream; use datafusion::prelude::{SessionConfig, SessionContext}; -use native_bridge_common::log_debug; use datafusion_substrait::logical_plan::consumer::from_substrait_plan; +use native_bridge_common::log_debug; use prost::Message; use substrait::proto::Plan; @@ -71,17 +71,24 @@ impl LocalSession { pub fn new(runtime_env: &RuntimeEnv) -> Self { let runtime_env = Arc::new(runtime_env.clone()); let mut config = SessionConfig::new(); - config.options_mut().execution.target_partitions = crate::api::get_reduce_target_partitions(); + config.options_mut().execution.target_partitions = + crate::api::get_reduce_target_partitions(); let state = SessionStateBuilder::new() .with_config(config) .with_runtime_env(runtime_env) .with_default_features() - .with_physical_optimizer_rules(crate::agg_mode::physical_optimizer_rules_without_combine()) + .with_physical_optimizer_rules( + crate::agg_mode::physical_optimizer_rules_without_combine(), + ) .build(); let ctx = SessionContext::new_with_state(state); crate::udf::register_all(&ctx); crate::udaf::register_all(&ctx); - Self { ctx, prepared_plan: None, _phantom_reservation: None } + Self { + ctx, + prepared_plan: None, + _phantom_reservation: None, + } } /// Returns the configured batch_size for this session. @@ -91,7 +98,11 @@ impl LocalSession { /// Returns the current target_partitions for this session. pub fn target_partitions(&self) -> usize { - self.ctx.copied_config().options().execution.target_partitions + self.ctx + .copied_config() + .options() + .execution + .target_partitions } /// Reduce target_partitions on this session. Only reduces, never increases. @@ -114,7 +125,10 @@ impl LocalSession { /// Sets the phantom reservation for this session. Caller should check /// phantom_size() first and only acquire a new reservation if the new /// estimate is larger than the current one. - pub fn set_phantom(&mut self, reservation: datafusion::execution::memory_pool::MemoryReservation) { + pub fn set_phantom( + &mut self, + reservation: datafusion::execution::memory_pool::MemoryReservation, + ) { self._phantom_reservation = Some(reservation); } @@ -179,20 +193,34 @@ impl LocalSession { pub async fn execute_substrait( &self, bytes: &[u8], - ) -> Result<(SendableRecordBatchStream, Arc), DataFusionError> { + ) -> Result< + ( + SendableRecordBatchStream, + Arc, + ), + DataFusionError, + > { let plan = Plan::decode(bytes).map_err(|e| { DataFusionError::Execution(format!("Failed to decode Substrait plan: {}", e)) })?; let logical_plan = from_substrait_plan(&self.ctx.state(), &plan).await?; - log_debug!("DataFusion logical plan:\n{}", logical_plan.display_indent()); + log_debug!( + "DataFusion logical plan:\n{}", + logical_plan.display_indent() + ); let dataframe = self.ctx.execute_logical_plan(logical_plan).await?; let physical_plan = dataframe.create_physical_plan().await?; let target_schema = crate::schema_coerce::coerce_inferred_schema(physical_plan.schema()); - let physical_plan = crate::relabel_exec::wrap_if_relabel_needed(physical_plan, target_schema)?; - log_debug!("DataFusion coordinator reduce physical plan:\n{}", displayable(physical_plan.as_ref()).indent(true)); - let stream = datafusion::physical_plan::execute_stream(physical_plan.clone(), self.ctx.task_ctx()) - .map_err(|e| DataFusionError::Execution(format!("execute_substrait: {}", e)))?; + let physical_plan = + crate::relabel_exec::wrap_if_relabel_needed(physical_plan, target_schema)?; + log_debug!( + "DataFusion coordinator reduce physical plan:\n{}", + displayable(physical_plan.as_ref()).indent(true) + ); + let stream = + datafusion::physical_plan::execute_stream(physical_plan.clone(), self.ctx.task_ctx()) + .map_err(|e| DataFusionError::Execution(format!("execute_substrait: {}", e)))?; Ok((stream, physical_plan)) } @@ -230,6 +258,7 @@ impl LocalSession { let stripped = crate::agg_mode::apply_aggregate_mode( physical_plan, crate::agg_mode::Mode::Final, + false, )?; let target_schema = crate::schema_coerce::coerce_inferred_schema(stripped.schema()); @@ -255,7 +284,7 @@ impl LocalSession { mod tests { use super::*; - use arrow_array::{Int64Array, RecordBatch}; + use arrow_array::{Array, Int64Array, RecordBatch, StringArray}; use datafusion::arrow::datatypes::{DataType, Field, Schema}; use datafusion::execution::runtime_env::RuntimeEnvBuilder; use datafusion_substrait::logical_plan::producer::to_substrait_plan; @@ -284,6 +313,24 @@ mod tests { .expect("batch builds") } + fn two_string_schema(a: &str, b: &str) -> SchemaRef { + Arc::new(Schema::new(vec![ + Field::new(a, DataType::Utf8, false), + Field::new(b, DataType::Utf8, false), + ])) + } + + fn two_string_batch(schema: &SchemaRef, col_a: &[&str], col_b: &[&str]) -> RecordBatch { + RecordBatch::try_new( + Arc::clone(schema), + vec![ + Arc::new(StringArray::from(col_a.to_vec())), + Arc::new(StringArray::from(col_b.to_vec())), + ], + ) + .expect("string batch builds") + } + #[tokio::test] async fn register_partition_makes_table_resolvable() { let env = test_runtime_env(); @@ -338,7 +385,10 @@ mod tests { let producer = std::thread::spawn(move || { for chunk in &[vec![1i64, 2, 3], vec![4, 5, 6], vec![7, 8, 9]] { let outcome = sender.send_blocking(Ok(i64_batch(&producer_schema, chunk)), &handle); - assert!(matches!(outcome, crate::partition_stream::SendOutcome::Sent)); + assert!(matches!( + outcome, + crate::partition_stream::SendOutcome::Sent + )); } drop(sender); // EOF }); @@ -420,6 +470,76 @@ mod tests { assert_eq!(total, 45); } + /// `concat(substring(a, ...), '|', substring(b, ...))` over a WHERE filter — + /// the outer operands are scalar-function calls and the middle operand is a + /// string literal. Round-trips through SQL → Substrait → `from_substrait_plan` + /// and asserts one concatenated row per input. + #[tokio::test] + async fn execute_substrait_concat_substring_literal_substring() { + let env = test_runtime_env(); + let mut session = LocalSession::new(&env); + let schema = two_string_schema("url", "referer"); + + // Two rows, both non-empty so they survive the WHERE filter. + let batch = two_string_batch( + &schema, + &["http://alpha", "http://bravo"], + &["https://gamma", "https://delta"], + ); + session + .register_memtable("input-0", Arc::clone(&schema), vec![batch]) + .expect("register memtable"); + + let sql = "SELECT concat(substring(url, 1, 7), '|', substring(referer, 1, 8)) AS r \ + FROM \"input-0\" WHERE url <> '' AND referer <> ''"; + + let substrait_bytes = { + let env = test_runtime_env(); + let mut producer = LocalSession::new(&env); + producer + .register_memtable("input-0", Arc::clone(&schema), vec![]) + .expect("producer register"); + let df = producer.ctx.sql(sql).await.expect("concat sql parses"); + let plan = df.logical_plan().clone(); + let substrait = to_substrait_plan(&plan, &producer.ctx.state()).expect("to_substrait"); + let mut buf = Vec::new(); + substrait.encode(&mut buf).expect("encode"); + buf + }; + + let (mut stream, _plan) = session + .execute_substrait(&substrait_bytes) + .await + .expect("execute"); + + let mut results: Vec = Vec::new(); + while let Some(batch) = stream.next().await { + let batch = batch.expect("batch ok"); + // DataFusion's string functions may return Utf8/LargeUtf8/Utf8View depending on + // version; cast to Utf8 so the assertion is independent of the concrete string type. + let col = datafusion::arrow::compute::cast(batch.column(0), &DataType::Utf8) + .expect("cast concat output to Utf8"); + let col = col + .as_any() + .downcast_ref::() + .expect("utf8 col"); + for i in 0..col.len() { + results.push(col.value(i).to_string()); + } + } + + // Each row joins the two substring results with the middle '|' literal. + results.sort(); + assert_eq!( + results, + vec![ + "http://|https://".to_string(), + "http://|https://".to_string(), + ], + "concat(substring, '|', substring) must yield one '|'-joined row per input" + ); + } + #[tokio::test] async fn prepare_final_plan_stores_plan() { let env = test_runtime_env(); @@ -478,7 +598,8 @@ mod tests { let ctx_id = 98_765; let pool: Arc = Arc::new(GreedyMemoryPool::new(10_000)); - let _tracking = QueryTrackingContext::new(ctx_id, pool, query_tracker::QueryType::Coordinator); + let _tracking = + QueryTrackingContext::new(ctx_id, pool, query_tracker::QueryType::Coordinator); // A future that would block indefinitely — `cancel_query` is the // only way out. Mirrors a coord reduce stalled on an input partition diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/memory.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/memory.rs index bc5984295d6b2..408983c06392b 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/memory.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/memory.rs @@ -19,6 +19,57 @@ use std::sync::Arc; use datafusion::common::DataFusionError; use datafusion::execution::memory_pool::{MemoryPool, MemoryReservation}; +/// Outcome of the 85%→95% spill-gate decision for one reservation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SpillGateDecision { + /// Allow the request through so the spill can finish writing. + Exempt, + /// Reject: not a spillable consumer, so reject it to make it spill. + RejectNonSpillable, + /// Reject: spillable, but the request is larger than the remaining budget. + RejectCapped, +} + +/// Decides what to do with a memory request whose RSS is in the 85%-to-95% +/// band (above the spill threshold, below the critical threshold). +/// +/// A request from a spillable consumer is allowed through ("exempt"). When such +/// a consumer hits the spill threshold it spills: it frees its existing data +/// first, then needs a small temporary buffer to write the spilled data out. +/// That buffer must be allowed to allocate, otherwise the spill cannot finish +/// and the query gets stuck. The exemption is bounded by a fixed byte budget, +/// `cap - outstanding`, so that many spills happening at once cannot add up to +/// enough memory to cross the 95% critical threshold. +/// +/// The decision deliberately does not look at how many pages the allocator has +/// freed-but-not-yet-returned. Right after a spill frees its data, the OS RSS +/// has not dropped yet (the allocator holds the pages), so that signal reads as +/// near-zero exactly when the spill needs its buffer, and an earlier version of +/// this code wrongly rejected the buffer ("Failed to reserve memory for sort +/// during spill"). The fixed byte budget avoids that. The caller still applies +/// the pool-limit check and the 95% critical check, so the node stays protected +/// from running out of memory. +/// +/// Kept as a separate pure function so it can be unit-tested without needing +/// real process memory. The 95% critical check runs in the caller before this +/// is called, so this can never allow crossing 95%. +pub(crate) fn spill_gate_decision( + can_spill: bool, + additional: usize, + cap: usize, + outstanding: usize, +) -> SpillGateDecision { + if !can_spill { + return SpillGateDecision::RejectNonSpillable; + } + let cap_room = cap.saturating_sub(outstanding); + if additional <= cap_room { + SpillGateDecision::Exempt + } else { + SpillGateDecision::RejectCapped + } +} + /// A `MemoryPool` whose limit can be changed at runtime. /// /// Behaviour matches `GreedyMemoryPool` exactly, except the limit is stored @@ -32,6 +83,12 @@ pub struct DynamicLimitPool { used: AtomicUsize, dynamic_limit: Arc, tripped_count: Arc, + /// Total bytes currently allowed through the 85% gate by the spill + /// exemption that have not yet been freed. This is the `outstanding` value + /// the exemption budget is measured against, so concurrent spills together + /// cannot exceed the cap. Increased when a request is exempted, decreased in + /// `shrink` as memory is freed (saturating, so it never goes below zero). + exempt_outstanding: AtomicUsize, } /// Handle to change the pool limit at runtime. @@ -76,6 +133,7 @@ impl DynamicLimitPool { used: AtomicUsize::new(0), dynamic_limit: limit, tripped_count: tripped, + exempt_outstanding: AtomicUsize::new(0), }; (pool, handle) } @@ -113,13 +171,24 @@ impl MemoryPool for DynamicLimitPool { // callers `used + additional` cannot overflow `usize`. Use a saturating // CAS loop so that a buggy caller (or a malicious `additional == usize::MAX`) // cannot wrap the counter. - let _ = self.used.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |used| { - Some(used.saturating_add(additional)) - }); + let _ = self + .used + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |used| { + Some(used.saturating_add(additional)) + }); } fn shrink(&self, _reservation: &MemoryReservation, shrink: usize) { self.used.fetch_sub(shrink, Ordering::Relaxed); + // Give back exemption budget as memory is freed (a spill writing out its + // data calls `shrink`). Subtract without going below zero. A normal, + // non-exempt shrink may give budget back a little early, which is safe: + // it only makes the exemption stricter, never looser. + let _ = self + .exempt_outstanding + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |out| { + Some(out.saturating_sub(shrink)) + }); } fn try_grow( @@ -127,21 +196,18 @@ impl MemoryPool for DynamicLimitPool { reservation: &MemoryReservation, additional: usize, ) -> Result<(), DataFusionError> { - // Two-tier RSS guard for in-flight queries: + // Two memory checks based on process RSS: + // - At 95% (critical): reject every request. This is the hard limit that + // protects the node from running out of memory. + // - At 85% (spill): reject requests so consumers spill to disk, except + // spillable consumers, which are allowed through so they can finish + // spilling (see `spill_gate_decision`). The exemption is bounded by a + // byte budget, and the pool-limit and 95% checks still apply, so this + // cannot push the node over the limit. // - // - operator (85%): reject to trigger spill. The operator will flush its hash - // table to disk and retry. This is recoverable — the query continues. - // - critical (95%): hard reject to prevent OOM. Last resort — even spill can't - // save the node at this pressure level. - // - // Between 85-95%, the override (below) ensures spill sort buffers can still - // allocate: when the CAS fails and RSS has dropped below operator threshold - // (because the hash table was freed during spill), the override fires and - // allows the sort allocation through. - // - // The adaptive cache (cached_resident_bytes) bypasses the 100ms cache when - // the last value was above operator threshold — so the override sees the - // fresh (lower) RSS after spill frees memory, not a stale peak. + // Set if a spillable consumer is allowed through the 85% check; its + // budget is only counted after the allocation actually succeeds below. + let mut exempted = false; let limit = self.dynamic_limit.load(Ordering::Acquire); let resident = crate::memory_guard::cached_resident_bytes(); if resident > 0 && limit >= 16 * 1024 * 1024 { @@ -151,9 +217,9 @@ impl MemoryPool for DynamicLimitPool { let resident_usize = resident as usize; // Critical (95%): hard reject — OOM imminent, protect the node. + // Absolute and pre-CAS for every consumer, spillable or not. if resident_usize > critical_bytes { self.tripped_count.fetch_add(1, Ordering::Relaxed); - let used = self.used.load(Ordering::Relaxed); return Err(crate::native_error::pool_limit_error( additional, reservation.consumer().name(), @@ -163,26 +229,37 @@ impl MemoryPool for DynamicLimitPool { )); } - // Operator (85%): soft reject — triggers spill. The operator will - // flush to disk, free memory, then retry. Spill sort buffers will - // succeed on retry because RSS drops and the override allows them. + // RSS is between 85% and 95%. Allow a spillable consumer through so + // it can finish spilling; reject everything else so it spills. if resident_usize > spill_bytes { - self.tripped_count.fetch_add(1, Ordering::Relaxed); - let used = self.used.load(Ordering::Relaxed); - return Err(crate::native_error::pool_limit_error( - additional, - reservation.consumer().name(), - reservation.size(), - 0, - limit, - )); + let can_spill = reservation.consumer().can_spill(); + let outstanding = self.exempt_outstanding.load(Ordering::Relaxed); + let cap = crate::memory_guard::spill_exempt_cap_bytes(); + + match spill_gate_decision(can_spill, additional, cap, outstanding) { + // Continue to the allocation below. Only count this against + // the budget if the allocation actually succeeds (see + // `exempted`), so a failed one doesn't use up the budget. + SpillGateDecision::Exempt => exempted = true, + _ => { + self.tripped_count.fetch_add(1, Ordering::Relaxed); + return Err(crate::native_error::pool_limit_error( + additional, + reservation.consumer().name(), + reservation.size(), + 0, + limit, + )); + } + } } } let dynamic_limit = &self.dynamic_limit; // Fast path: try the normal CAS against the pool limit. - let cas_result = self.used + let cas_result = self + .used .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |used| { let limit = dynamic_limit.load(Ordering::Acquire); let new_used = used.checked_add(additional)?; @@ -190,6 +267,12 @@ impl MemoryPool for DynamicLimitPool { }); if cas_result.is_ok() { + // Charge the exemption budget only now that the grow succeeded; + // released saturating in `shrink`. + if exempted { + self.exempt_outstanding + .fetch_add(additional, Ordering::Relaxed); + } return Ok(()); } @@ -205,11 +288,20 @@ impl MemoryPool for DynamicLimitPool { let used = self.used.load(Ordering::Relaxed); // Only attempt override if the allocation is plausible (won't overflow). if used.checked_add(additional).is_some() { - if crate::memory_guard::should_override(limit, crate::memory_guard::OverrideContext::Execution) { + if crate::memory_guard::should_override( + limit, + crate::memory_guard::OverrideContext::Execution, + ) { // jemalloc confirms headroom — allow the grow - let _ = self.used.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |u| { - u.checked_add(additional) - }); + let _ = self + .used + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |u| { + u.checked_add(additional) + }); + if exempted { + self.exempt_outstanding + .fetch_add(additional, Ordering::Relaxed); + } return Ok(()); } } @@ -399,9 +491,9 @@ mod tests { break; } if handle.limit() >= 2048 { - reservation - .try_grow(2048) - .expect("once handle.limit() reflects the raise, try_grow must succeed"); + reservation.try_grow(2048).expect( + "once handle.limit() reflects the raise, try_grow must succeed", + ); break; } std::hint::spin_loop(); @@ -535,4 +627,64 @@ mod tests { ); assert_eq!(pool.reserved(), 4096); } + + // ---- Spill-gate exemption rule (pure, deterministic) ---- + + const MB: usize = 1024 * 1024; + + #[test] + fn test_spill_gate_rejects_non_spillable() { + // A non-spillable consumer in the 85% band is always rejected so it + // triggers spill — regardless of the cap budget. + let d = spill_gate_decision(false, 256 * 1024, 512 * MB, 0); + assert_eq!(d, SpillGateDecision::RejectNonSpillable); + } + + #[test] + fn test_spill_gate_exempts_spillable_within_cap() { + // Spillable, small request, full cap available → exempt. + // Mirrors the observed 256 KiB spill-trigger allocation. + let d = spill_gate_decision(true, 262_528, 512 * MB, 0); + assert_eq!(d, SpillGateDecision::Exempt); + } + + #[test] + fn test_spill_gate_exempts_buffer_within_budget() { + // A 193 MB spill buffer must be allowed through as long as it fits the + // budget. An earlier version also looked at how much memory the allocator + // had freed but not yet returned, which reads as near-zero right when a + // spill needs its buffer, so it wrongly rejected this and the spill could + // not finish. The decision now depends only on the budget. + let d = spill_gate_decision(true, 193 * MB, 512 * MB, 0); + assert_eq!(d, SpillGateDecision::Exempt); + // A buffer larger than the whole budget is still rejected. + let d2 = spill_gate_decision(true, 600 * MB, 512 * MB, 0); + assert_eq!(d2, SpillGateDecision::RejectCapped); + } + + #[test] + fn test_spill_gate_caps_when_budget_exhausted() { + // Most of the budget is already used (500MB of a 512MB cap, leaving + // 12MB). A 64 MB request does not fit, so it is rejected. + let d = spill_gate_decision(true, 64 * MB, 512 * MB, 500 * MB); + assert_eq!(d, SpillGateDecision::RejectCapped); + // A request that fits the remaining 12 MB is allowed through. + let d2 = spill_gate_decision(true, 8 * MB, 512 * MB, 500 * MB); + assert_eq!(d2, SpillGateDecision::Exempt); + } + + #[test] + fn test_spill_gate_bound_is_cap_minus_outstanding() { + // The sole bound is the hard cap minus outstanding exemptions. + // 40MB room (512 - 472), 60MB request → rejected. + assert_eq!( + spill_gate_decision(true, 60 * MB, 512 * MB, 472 * MB), + SpillGateDecision::RejectCapped + ); + // Same request fits when the cap is unused. + assert_eq!( + spill_gate_decision(true, 60 * MB, 512 * MB, 0), + SpillGateDecision::Exempt + ); + } } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/memory_guard.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/memory_guard.rs index 123310f9f75b4..75fc362872a47 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/memory_guard.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/memory_guard.rs @@ -58,7 +58,10 @@ pub fn cached_resident_bytes() -> i64 { let now_ms = base.elapsed().as_millis() as u64; let last = LAST_CHECK_MS.load(Ordering::Relaxed); if now_ms.wrapping_sub(last) >= RESIDENT_CACHE_INTERVAL_MS { - if LAST_CHECK_MS.compare_exchange(last, now_ms, Ordering::Relaxed, Ordering::Relaxed).is_ok() { + if LAST_CHECK_MS + .compare_exchange(last, now_ms, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { let r = native_bridge_common::allocator::resident_bytes(); CACHED_RESIDENT.store(r, Ordering::Relaxed); return r; @@ -93,6 +96,22 @@ static ADMISSION_REJECT_X1000: AtomicU64 = AtomicU64::new(850); static EXECUTION_SPILL_X1000: AtomicU64 = AtomicU64::new(850); static EXECUTION_CRITICAL_X1000: AtomicU64 = AtomicU64::new(950); +// Total byte budget for the spill-gate exemption (see +// `DynamicLimitPool::try_grow`). Limits how much memory spillable consumers can +// be allowed through the 85% check at the same time, so several spills together +// stay below the 95% limit. Default 512MB. +static SPILL_EXEMPT_CAP_BYTES: AtomicU64 = AtomicU64::new(512 * 1024 * 1024); + +/// Set the spill-gate exemption byte cap at runtime. +pub fn set_spill_exempt_cap_bytes(bytes: u64) { + SPILL_EXEMPT_CAP_BYTES.store(bytes, Ordering::Release); +} + +/// Current spill-gate exemption byte cap. +pub fn spill_exempt_cap_bytes() -> usize { + SPILL_EXEMPT_CAP_BYTES.load(Ordering::Acquire) as usize +} + /// Which layer is asking for the override check. #[derive(Debug, Clone, Copy)] pub enum OverrideContext { @@ -176,7 +195,9 @@ pub fn should_cancel_query(pool_limit_bytes: usize) -> bool { if resident <= 0 { return false; } - let critical_bytes = (pool_limit_bytes as u64).saturating_mul(EXECUTION_CRITICAL_X1000.load(Ordering::Acquire)) / 1000; + let critical_bytes = (pool_limit_bytes as u64) + .saturating_mul(EXECUTION_CRITICAL_X1000.load(Ordering::Acquire)) + / 1000; resident >= critical_bytes as i64 } @@ -313,7 +334,8 @@ pub fn per_query_spill_budget() -> SpillBudget { let fraction_x1000 = DISK_FRACTION_X1000.load(Ordering::Acquire); let budget = available * fraction_x1000 / 1000; - if budget < 64 * 1024 * 1024 { // 64MB minimum viable spill + if budget < 64 * 1024 * 1024 { + // 64MB minimum viable spill log::warn!( "[disk-pressure] Spill budget too low: {} MB (available={} MB)", budget / (1024 * 1024), @@ -360,7 +382,8 @@ mod tests { #[test] fn set_and_get_thresholds() { set_thresholds(MemoryThresholds { - admission_throttle: 0.60, admission_reject: 0.80, + admission_throttle: 0.60, + admission_reject: 0.80, execution_spill: 0.90, execution_critical: 0.97, }); @@ -372,6 +395,33 @@ mod tests { set_thresholds(MemoryThresholds::default()); } + #[test] + fn set_and_get_spill_exempt_cap() { + // Default is 512MB. + assert_eq!(spill_exempt_cap_bytes(), 512 * 1024 * 1024); + // Round-trips an arbitrary value. + set_spill_exempt_cap_bytes(64 * 1024 * 1024); + assert_eq!(spill_exempt_cap_bytes(), 64 * 1024 * 1024); + // Zero is valid (disables the exemption budget). + set_spill_exempt_cap_bytes(0); + assert_eq!(spill_exempt_cap_bytes(), 0); + // Restore default. + set_spill_exempt_cap_bytes(512 * 1024 * 1024); + } + + #[test] + fn ffi_spill_exempt_cap_clamps_negative_to_zero() { + // The FFI export takes a signed i64 (Java long); negative inputs must clamp + // to 0 rather than wrap to a huge u64. + crate::ffm::df_set_spill_exempt_cap_bytes(-1); + assert_eq!(spill_exempt_cap_bytes(), 0); + // A positive value passes through unchanged. + crate::ffm::df_set_spill_exempt_cap_bytes(256 * 1024 * 1024); + assert_eq!(spill_exempt_cap_bytes(), 256 * 1024 * 1024); + // Restore default. + set_spill_exempt_cap_bytes(512 * 1024 * 1024); + } + #[test] fn skip_for_small_pools() { // Pool below 16MB → always returns false (no override) @@ -389,7 +439,10 @@ mod tests { return; // jemalloc not active in this test env (CI) } let result = should_override(large_pool, OverrideContext::Execution); - assert!(result, "With 1TB pool limit, resident should be well below threshold — override should fire"); + assert!( + result, + "With 1TB pool limit, resident should be well below threshold — override should fire" + ); } #[test] @@ -424,7 +477,11 @@ mod tests { fn cached_resident_bytes_returns_non_negative() { // Returns > 0 when jemalloc is active, 0 when not (CI may not link jemalloc) let resident = cached_resident_bytes(); - assert!(resident >= 0, "cached_resident_bytes() should never return negative, got {}", resident); + assert!( + resident >= 0, + "cached_resident_bytes() should never return negative, got {}", + resident + ); } #[test] @@ -497,8 +554,14 @@ mod tests { let spill_result = should_override(pool_at_midpoint, OverrideContext::Execution); // admission: resident (77%) >= threshold (70%) → NOT below → override = false - assert!(!admission_result, "At 77% RSS, admission override should NOT fire (threshold 70%)"); + assert!( + !admission_result, + "At 77% RSS, admission override should NOT fire (threshold 70%)" + ); // operator: resident (77%) < threshold (85%) → below → override = true - assert!(spill_result, "At 77% RSS, spill override SHOULD fire (threshold 85%)"); + assert!( + spill_result, + "At 77% RSS, spill override SHOULD fire (threshold 85%)" + ); } } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/partition_stream.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/partition_stream.rs index ada38fa122b7b..a23db893d1489 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/partition_stream.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/partition_stream.rs @@ -291,9 +291,11 @@ mod tests { let (sender, receiver) = channel(Arc::clone(&schema)); drop(receiver); - let outcome = std::thread::spawn(move || sender.send_blocking(Ok(test_batch(&schema, &[1])), &handle)) - .join() - .unwrap(); + let outcome = std::thread::spawn(move || { + sender.send_blocking(Ok(test_batch(&schema, &[1])), &handle) + }) + .join() + .unwrap(); assert!(matches!(outcome, SendOutcome::ReceiverDropped)); } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/patterns/brain.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/patterns/brain.rs index c4a0ac4526ef2..b61f9d9b5d0d8 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/patterns/brain.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/patterns/brain.rs @@ -189,8 +189,10 @@ impl BrainLogParser { // none, the first element. sorted_word_combinations.sort(); - let candidate = Self::find_candidate(&sorted_word_combinations, self.threshold_percentage); - let group_candidate_str = format!("{},{}", candidate.word_freq, candidate.same_freq_count); + let candidate = + Self::find_candidate(&sorted_word_combinations, self.threshold_percentage); + let group_candidate_str = + format!("{},{}", candidate.word_freq, candidate.same_freq_count); // tokens.last() is the synthetic logId. let log_id = tokens @@ -227,7 +229,10 @@ impl BrainLogParser { /// list and pick the FIRST entry whose `word_freq > maxFreq * threshold%`. /// If none qualify, return the first entry. Mirrors `findCandidate`. fn find_candidate(sorted: &[WordCombination], threshold_percentage: f64) -> WordCombination { - assert!(!sorted.is_empty(), "Sorted word combinations must be non-empty"); + assert!( + !sorted.is_empty(), + "Sorted word combinations must be non-empty" + ); let max_freq = sorted.iter().map(|w| w.word_freq).max().unwrap_or(0); let threshold = (max_freq as f64) * threshold_percentage; for w in sorted { @@ -416,10 +421,7 @@ mod tests { #[test] fn collapse_passes_through_when_no_wildcards() { - assert_eq!( - collapse_continuous_wildcards("hello world"), - "hello world" - ); + assert_eq!(collapse_continuous_wildcards("hello world"), "hello world"); } #[test] diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/patterns/eval.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/patterns/eval.rs index feb6498a851b6..2c15c34595cb8 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/patterns/eval.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/patterns/eval.rs @@ -88,14 +88,24 @@ pub struct AggCandidate { /// Used by PPL's BRAIN label mode: the window function emits one /// `(pattern, count, samples)` map per row, and this scalar UDF picks the /// best-fit pattern for the original field value. -pub fn eval_agg(field: &str, agg_object: &[AggCandidate], show_numbered_token: bool) -> PatternResult { +pub fn eval_agg( + field: &str, + agg_object: &[AggCandidate], + show_numbered_token: bool, +) -> PatternResult { if field.trim().is_empty() || agg_object.is_empty() { return PatternResult::empty(); } let preprocessed_tokens = preprocess(field, default_filter_patterns(), default_delimiters()); let candidates: Vec> = agg_object .iter() - .map(|entry| entry.pattern.split(' ').map(String::from).collect::>()) + .map(|entry| { + entry + .pattern + .split(' ') + .map(String::from) + .collect::>() + }) .filter(|split| split.len() == preprocessed_tokens.len()) .collect(); let best = find_best_candidate(&candidates, &preprocessed_tokens); @@ -162,9 +172,18 @@ mod tests { // tokens = {token1: [amberduke], token2: [pyrami], token3: [com]}. let result = eval_field("<*>@<*>.<*>", "amberduke@pyrami.com"); assert_eq!(result.pattern, "@."); - assert_eq!(result.tokens.get("").unwrap(), &vec!["amberduke".to_string()]); - assert_eq!(result.tokens.get("").unwrap(), &vec!["pyrami".to_string()]); - assert_eq!(result.tokens.get("").unwrap(), &vec!["com".to_string()]); + assert_eq!( + result.tokens.get("").unwrap(), + &vec!["amberduke".to_string()] + ); + assert_eq!( + result.tokens.get("").unwrap(), + &vec!["pyrami".to_string()] + ); + assert_eq!( + result.tokens.get("").unwrap(), + &vec!["com".to_string()] + ); } #[test] @@ -252,8 +271,14 @@ mod tests { &candidates, true, ); - assert_eq!(result.pattern, "Verification succeeded blk_"); - assert_eq!(result.tokens.get("").unwrap(), &vec!["for".to_string()]); + assert_eq!( + result.pattern, + "Verification succeeded blk_" + ); + assert_eq!( + result.tokens.get("").unwrap(), + &vec!["for".to_string()] + ); // The blk_<*> preprocessing replaces -1547954353065580372 with <*> in // the input, so 's extracted value in this layer is the // preprocessed wildcard rather than the raw number. The exact value @@ -284,7 +309,10 @@ mod tests { &candidates, false, ); - assert_eq!(result.pattern, "Verification succeeded blk_"); + assert_eq!( + result.pattern, + "Verification succeeded blk_" + ); assert!( result.tokens.is_empty(), "show_numbered_token=false must produce empty tokens map, got {:?}", diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/patterns/preprocess.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/patterns/preprocess.rs index 96f051d9e4611..caba96555b096 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/patterns/preprocess.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/patterns/preprocess.rs @@ -249,9 +249,8 @@ mod tests { #[test] fn preprocess_substitutes_uuid() { - let tokens = pp( - "[PlaceOrder] user_id=d664d7be-77d8-11f0-8880-0242f00b101d user_currency=USD", - ); + let tokens = + pp("[PlaceOrder] user_id=d664d7be-77d8-11f0-8880-0242f00b101d user_currency=USD"); // testBrainParseWithUUID_NotShowNumberedToken expects: // "[PlaceOrder] user_id=<*UUID*> user_currency=USD" assert_eq!( @@ -265,11 +264,9 @@ mod tests { // From testBrainLabelMode_NotShowNumberedToken second row, the // task_id like `_task_200811092030_0002_r_000296_0/part-00296.` should // see every number-segment between underscores substituted to `<*>`. - let tokens = pp( - "BLOCK* NameSystem.allocateBlock: \ + let tokens = pp("BLOCK* NameSystem.allocateBlock: \ /user/root/sortrand/_temporary/_task_200811092030_0002_r_000296_0/part-00296. \ - blk_-6620182933895093708", - ); + blk_-6620182933895093708"); // Just sanity-check that the multi-digit numbers got substituted somewhere. let joined = tokens.join(" "); assert!( diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/patterns/utils.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/patterns/utils.rs index c33bf0aff58a5..8bb372d653167 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/patterns/utils.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/patterns/utils.rs @@ -21,15 +21,13 @@ use regex::Regex; /// Regex matching wildcard placeholders of the form `<* …>`. Mirrors Java's /// `PatternUtils.WILDCARD_PATTERN = Pattern.compile("<\\*[^>]*>")`. Examples /// it matches: `<*>`, `<*IP*>`, `<*UUID*>`, `<*DATETIME*>`. -pub static WILDCARD_PATTERN: Lazy = Lazy::new(|| { - Regex::new(r"<\*[^>]*>").expect("WILDCARD_PATTERN regex is well-formed") -}); +pub static WILDCARD_PATTERN: Lazy = + Lazy::new(|| Regex::new(r"<\*[^>]*>").expect("WILDCARD_PATTERN regex is well-formed")); /// Regex matching numbered token placeholders like ``, ``. /// Mirrors Java's `PatternUtils.TOKEN_PATTERN = Pattern.compile("")`. -pub static TOKEN_PATTERN: Lazy = Lazy::new(|| { - Regex::new(r"").expect("TOKEN_PATTERN regex is well-formed") -}); +pub static TOKEN_PATTERN: Lazy = + Lazy::new(|| Regex::new(r"").expect("TOKEN_PATTERN regex is well-formed")); /// The wildcard-style prefix used when generating numbered-token labels from /// wildcards. Matches Java's `PatternUtils.WILDCARD_PREFIX = "<*"`. @@ -203,10 +201,7 @@ mod tests { let parsed = parse_pattern("<*>@<*>.<*>", &WILDCARD_PATTERN); assert_eq!(parsed.parts, vec!["<*>", "@", "<*>", ".", "<*>"]); assert_eq!(parsed.is_token, vec![true, false, true, false, true]); - assert_eq!( - parsed.token_order, - vec!["", "", ""] - ); + assert_eq!(parsed.token_order, vec!["", "", ""]); } #[test] @@ -222,7 +217,12 @@ mod tests { fn extract_variables_extracts_email_parts() { let parsed = parse_pattern("<*>@<*>.<*>", &WILDCARD_PATTERN); let mut tokens = TokensMap::new(); - extract_variables(&parsed, "amberduke@pyrami.com", &mut tokens, WILDCARD_PREFIX); + extract_variables( + &parsed, + "amberduke@pyrami.com", + &mut tokens, + WILDCARD_PREFIX, + ); assert_eq!(tokens.get(""), Some(&vec!["amberduke".to_string()])); assert_eq!(tokens.get(""), Some(&vec!["pyrami".to_string()])); assert_eq!(tokens.get(""), Some(&vec!["com".to_string()])); @@ -264,7 +264,12 @@ mod tests { let parsed = parse_pattern("<*>@<*>", &WILDCARD_PATTERN); let mut tokens = TokensMap::new(); // No '@' in original — extraction must bail without inserting partial values. - extract_variables(&parsed, "amberduke.pyrami.com", &mut tokens, WILDCARD_PREFIX); + extract_variables( + &parsed, + "amberduke.pyrami.com", + &mut tokens, + WILDCARD_PREFIX, + ); assert!(tokens.is_empty(), "expected no tokens, got {:?}", tokens); } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/phantom_corrector.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/phantom_corrector.rs index 063de03ee4c61..99faa637c41c6 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/phantom_corrector.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/phantom_corrector.rs @@ -131,7 +131,8 @@ impl PhantomCorrector { if diff.abs() > threshold { self.pending_delta.fetch_add(diff, Ordering::Relaxed); - self.current_phantom_bytes.fetch_add(diff, Ordering::Relaxed); + self.current_phantom_bytes + .fetch_add(diff, Ordering::Relaxed); } } @@ -266,7 +267,8 @@ mod tests { let pipeline_slots = 13; let initial_phantom = measured_batch * pipeline_slots; - let c = PhantomCorrector::new_from_metadata(initial_phantom, measured_batch, pipeline_slots); + let c = + PhantomCorrector::new_from_metadata(initial_phantom, measured_batch, pipeline_slots); let mut phantom = initial_phantom as i64; for _ in 0..20 { @@ -298,7 +300,7 @@ mod tests { #[test] fn initial_bloat_before_correction() { let estimated_batch = 100 * 8192; // schema-based (over-estimate) - let actual_batch = 40 * 8192; // actual (2.5x smaller) + let actual_batch = 40 * 8192; // actual (2.5x smaller) let pipeline_slots = 13; let initial_phantom = estimated_batch * pipeline_slots; @@ -320,7 +322,9 @@ mod tests { println!( "Initial bloat: {:.2}x (corrects after {} batches = {} rows)", - initial_bloat, first_correction_at, first_correction_at * 8192 + initial_bloat, + first_correction_at, + first_correction_at * 8192 ); // First correction should fire within 4-8 batches (warmup + interval) diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/project_row_id_analyzer.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/project_row_id_analyzer.rs index a712ac09caa88..3922c8a11685f 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/project_row_id_analyzer.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/project_row_id_analyzer.rs @@ -40,95 +40,90 @@ impl ProjectRowIdAnalyzer { impl AnalyzerRule for ProjectRowIdAnalyzer { fn analyze(&self, plan: LogicalPlan, _config: &ConfigOptions) -> Result { - let rewritten = plan.transform_up(|node| { - match &node { - LogicalPlan::TableScan(scan) => { - let mut proj = scan.projection.clone().unwrap_or_else(|| { - (0..scan.projected_schema.fields().len()).collect() - }); - - let mut new_projected_schema = (*scan.projected_schema).clone(); - - if scan.source.schema().index_of(ROW_ID_FIELD_NAME).is_ok() { - let row_id_idx = - scan.source.schema().index_of(ROW_ID_FIELD_NAME).unwrap(); - - if !proj.contains(&row_id_idx) { - proj.push(row_id_idx); - - let qualifier = scan - .projected_schema - .qualified_field(0) - .0 - .cloned(); - - if let Some(q) = qualifier { - let row_id_schema = DFSchema::try_from_qualified_schema( - q, - &Schema::new(vec![Field::new( - ROW_ID_FIELD_NAME, - DataType::Int64, - false, - )]), - )?; - new_projected_schema = new_projected_schema - .join(&row_id_schema) - .map_err(|e| { - datafusion::error::DataFusionError::Internal(format!( - "ProjectRowIdAnalyzer: join schema: {}", - e - )) - })?; - } + let rewritten = plan.transform_up(|node| match &node { + LogicalPlan::TableScan(scan) => { + let mut proj = scan + .projection + .clone() + .unwrap_or_else(|| (0..scan.projected_schema.fields().len()).collect()); + + let mut new_projected_schema = (*scan.projected_schema).clone(); + + if scan.source.schema().index_of(ROW_ID_FIELD_NAME).is_ok() { + let row_id_idx = scan.source.schema().index_of(ROW_ID_FIELD_NAME).unwrap(); + + if !proj.contains(&row_id_idx) { + proj.push(row_id_idx); + + let qualifier = scan.projected_schema.qualified_field(0).0.cloned(); + + if let Some(q) = qualifier { + let row_id_schema = DFSchema::try_from_qualified_schema( + q, + &Schema::new(vec![Field::new( + ROW_ID_FIELD_NAME, + DataType::Int64, + false, + )]), + )?; + new_projected_schema = + new_projected_schema.join(&row_id_schema).map_err(|e| { + datafusion::error::DataFusionError::Internal(format!( + "ProjectRowIdAnalyzer: join schema: {}", + e + )) + })?; } } - - let new_scan = LogicalPlan::TableScan(datafusion_expr::TableScan { - table_name: scan.table_name.clone(), - source: scan.source.clone(), - projection: Some(proj), - projected_schema: Arc::new(new_projected_schema), - filters: scan.filters.clone(), - fetch: scan.fetch, - }); - Ok(Transformed::yes(new_scan)) } - LogicalPlan::Projection(p) => { - let already_has_row_id = p.expr.iter().any(|e| { - matches!(e, Expr::Column(c) if c.name == ROW_ID_FIELD_NAME) - }); - let input_has_row_id = p - .input - .schema() - .index_of_column(&Column::from_name(ROW_ID_FIELD_NAME)) - .is_ok(); - - if !already_has_row_id && input_has_row_id { - let mut new_exprs = p.expr.to_vec(); - new_exprs.push(col(ROW_ID_FIELD_NAME)); - - let row_id_schema = { - let qualifier = p.schema.qualified_field(0).0.cloned(); - match qualifier { - Some(q) => DFSchema::try_from_qualified_schema( - q, - &Schema::new(vec![Field::new( - ROW_ID_FIELD_NAME, - DataType::Int64, - false, - )]), - )?, - None => DFSchema::try_from(Schema::new(vec![Field::new( + let new_scan = LogicalPlan::TableScan(datafusion_expr::TableScan { + table_name: scan.table_name.clone(), + source: scan.source.clone(), + projection: Some(proj), + projected_schema: Arc::new(new_projected_schema), + filters: scan.filters.clone(), + fetch: scan.fetch, + }); + Ok(Transformed::yes(new_scan)) + } + + LogicalPlan::Projection(p) => { + let already_has_row_id = p + .expr + .iter() + .any(|e| matches!(e, Expr::Column(c) if c.name == ROW_ID_FIELD_NAME)); + let input_has_row_id = p + .input + .schema() + .index_of_column(&Column::from_name(ROW_ID_FIELD_NAME)) + .is_ok(); + + if !already_has_row_id && input_has_row_id { + let mut new_exprs = p.expr.to_vec(); + new_exprs.push(col(ROW_ID_FIELD_NAME)); + + let row_id_schema = { + let qualifier = p.schema.qualified_field(0).0.cloned(); + match qualifier { + Some(q) => DFSchema::try_from_qualified_schema( + q, + &Schema::new(vec![Field::new( ROW_ID_FIELD_NAME, DataType::Int64, false, - )]))?, - } - }; + )]), + )?, + None => DFSchema::try_from(Schema::new(vec![Field::new( + ROW_ID_FIELD_NAME, + DataType::Int64, + false, + )]))?, + } + }; - let merged_schema = if p - .schema + let merged_schema = + if p.schema .index_of_column(&Column::from_name(ROW_ID_FIELD_NAME)) .is_ok() { @@ -144,26 +139,21 @@ impl AnalyzerRule for ProjectRowIdAnalyzer { )?) }; - let new_proj = LogicalPlan::Projection( - Projection::try_new_with_schema( - new_exprs, - p.input.clone(), - merged_schema, - ) + let new_proj = LogicalPlan::Projection( + Projection::try_new_with_schema(new_exprs, p.input.clone(), merged_schema) .map_err(|e| { datafusion::error::DataFusionError::Internal(format!( "ProjectRowIdAnalyzer: create projection: {}", e )) })?, - ); - return Ok(Transformed::yes(new_proj)); - } - Ok(Transformed::no(node)) + ); + return Ok(Transformed::yes(new_proj)); } - - _ => Ok(Transformed::no(node)), + Ok(Transformed::no(node)) } + + _ => Ok(Transformed::no(node)), })?; Ok(rewritten.data) diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/project_row_id_optimizer.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/project_row_id_optimizer.rs index 1ddcf4aee4ab9..911a4b1761b13 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/project_row_id_optimizer.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/project_row_id_optimizer.rs @@ -97,18 +97,18 @@ impl PhysicalOptimizerRule for ProjectRowIdOptimizer { new_proj_indices.push(row_base_partition_idx); // Rebuild the DataSourceExec with new projections - let new_table_schema = TableSchema::new( - file_schema.clone(), - partition_cols.clone(), - ); + let new_table_schema = TableSchema::new(file_schema.clone(), partition_cols.clone()); let new_source = Arc::new(ParquetSource::new(new_table_schema)); let new_config = FileScanConfigBuilder::from(file_scan_config.clone()) .with_source(new_source) .with_projection_indices(Some(new_proj_indices)) - .map_err(|e| datafusion::error::DataFusionError::Internal( - format!("ProjectRowIdOptimizer: set projection: {}", e) - ))? + .map_err(|e| { + datafusion::error::DataFusionError::Internal(format!( + "ProjectRowIdOptimizer: set projection: {}", + e + )) + })? .build(); let new_datasource: Arc = @@ -132,10 +132,8 @@ impl PhysicalOptimizerRule for ProjectRowIdOptimizer { } else if field.name() == ROW_BASE_FIELD_NAME { continue; // drop from output } else { - projection_exprs.push(( - Arc::new(Column::new(field.name(), i)), - field.name().clone(), - )); + projection_exprs + .push((Arc::new(Column::new(field.name(), i)), field.name().clone())); } } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/query_budget.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/query_budget.rs index fbe652dc240a2..33f4f84f4fa45 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/query_budget.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/query_budget.rs @@ -67,12 +67,14 @@ //! memory allows, reduced parallelism under pressure, rejection only at //! extreme saturation. -use std::sync::Arc; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::Arc; use datafusion::arrow::datatypes::{DataType, SchemaRef}; use datafusion::common::DataFusionError; -use datafusion::execution::memory_pool::{MemoryConsumer, MemoryLimit, MemoryPool, MemoryReservation}; +use datafusion::execution::memory_pool::{ + MemoryConsumer, MemoryLimit, MemoryPool, MemoryReservation, +}; use once_cell::sync::Lazy; use parquet::file::metadata::ParquetMetaData; @@ -110,7 +112,6 @@ pub fn get_min_target_partitions() -> usize { MIN_TARGET_PARTITIONS_SETTING.load(Ordering::Acquire) } - /// How many batch-sized buffers exist per partition in the pipeline. /// /// Derived from the execution pipeline: @@ -165,7 +166,13 @@ pub fn acquire_budget( configured_target_partitions: usize, configured_batch_size: usize, ) -> Result { - acquire_budget_with_projection(pool, schema, configured_target_partitions, configured_batch_size, None) + acquire_budget_with_projection( + pool, + schema, + configured_target_partitions, + configured_batch_size, + None, + ) } /// Acquire budget using measured row bytes from parquet metadata. @@ -185,7 +192,13 @@ pub fn acquire_budget_from_metadata( let avg_row_bytes = estimate_row_bytes_from_metadata(schema, metadata) .unwrap_or_else(|| estimate_avg_row_bytes(schema)); let num_columns = schema.fields().len(); - acquire_budget_inner(pool, avg_row_bytes, num_columns, configured_target_partitions, configured_batch_size) + acquire_budget_inner( + pool, + avg_row_bytes, + num_columns, + configured_target_partitions, + configured_batch_size, + ) } /// Same as [`acquire_budget`] but accepts an optional projection. @@ -204,7 +217,13 @@ pub fn acquire_budget_with_projection( Some(indices) => indices.len(), None => schema.fields().len(), }; - acquire_budget_inner(pool, avg_row_bytes, num_columns, configured_target_partitions, configured_batch_size) + acquire_budget_inner( + pool, + avg_row_bytes, + num_columns, + configured_target_partitions, + configured_batch_size, + ) } /// Core budget acquisition logic. All public entry points delegate here. @@ -254,14 +273,20 @@ fn acquire_budget_inner( ); ADAPTIVE_BUDGET.rejections.fetch_add(1, Ordering::Relaxed); return Err(crate::native_error::admission_rejected_error( - compute_untracked_bytes_with_columns(min_partitions, MIN_BATCH_SIZE, avg_row_bytes, num_columns), + compute_untracked_bytes_with_columns( + min_partitions, + MIN_BATCH_SIZE, + avg_row_bytes, + num_columns, + ), min_partitions, MIN_BATCH_SIZE, avg_row_bytes, )); } // RSS between admission (70%) and operator (85%) — reduce partitions - let admission_threshold_bytes = (limit as f64 * thresholds.admission_throttle) as i64; + let admission_threshold_bytes = + (limit as f64 * thresholds.admission_throttle) as i64; if resident >= admission_threshold_bytes { native_bridge_common::log_info!( "Admission: pool reserved={}B, RSS={}B >= admission threshold ({:.0}%) — reducing to min partitions={}", @@ -275,7 +300,10 @@ fn acquire_budget_inner( loop { let phantom_bytes = compute_untracked_bytes_with_columns( - target_partitions, batch_size, avg_row_bytes, num_columns, + target_partitions, + batch_size, + avg_row_bytes, + num_columns, ); let consumer = MemoryConsumer::new(format!( @@ -351,7 +379,6 @@ fn acquire_budget_inner( } } - /// Attempt to acquire or grow the coordinator-reduce session's phantom /// reservation based on a child input's schema. Compares the estimated /// untracked memory for this schema against the reservation already held @@ -370,13 +397,22 @@ pub fn try_grow_reduce_budget( let avg_row_bytes = estimate_avg_row_bytes(schema); let num_columns = schema.fields().len(); let needed = compute_untracked_bytes_with_columns( - configured_target_partitions, batch_size, avg_row_bytes, num_columns, + configured_target_partitions, + batch_size, + avg_row_bytes, + num_columns, ); if needed <= prior_partition_reservation_bytes { return Ok(None); } - acquire_budget_inner(pool, avg_row_bytes, num_columns, configured_target_partitions, batch_size) - .map(Some) + acquire_budget_inner( + pool, + avg_row_bytes, + num_columns, + configured_target_partitions, + batch_size, + ) + .map(Some) } /// Compute the untracked byte envelope for given parameters. @@ -396,8 +432,8 @@ fn compute_untracked_bytes_with_columns( num_columns: usize, ) -> usize { let batch_bytes = batch_size * avg_row_bytes; - let decode_overhead = PAGE_DECODE_BASE_OVERHEAD_BYTES - + num_columns * PER_COLUMN_DECODE_OVERHEAD_BYTES; + let decode_overhead = + PAGE_DECODE_BASE_OVERHEAD_BYTES + num_columns * PER_COLUMN_DECODE_OVERHEAD_BYTES; if target_partitions == 1 { // No CoalescePartitionsExec, no merge channel. Pipeline is: @@ -533,7 +569,10 @@ fn estimate_field_bytes(dt: &DataType) -> usize { 4 * estimate_field_bytes(inner.data_type()) } DataType::FixedSizeList(inner, n) => *n as usize * estimate_field_bytes(inner.data_type()), - DataType::Struct(fields) => fields.iter().map(|f| estimate_field_bytes(f.data_type())).sum(), + DataType::Struct(fields) => fields + .iter() + .map(|f| estimate_field_bytes(f.data_type())) + .sum(), DataType::Map(entry, _) => estimate_field_bytes(entry.data_type()) * 4, _ => 32, } @@ -549,7 +588,10 @@ fn pool_limit(pool: &Arc) -> Option { /// Delegates to the common memory guard for admission-level override check. fn jemalloc_says_headroom_available(pool_limit_bytes: usize) -> bool { - crate::memory_guard::should_override(pool_limit_bytes, crate::memory_guard::OverrideContext::Admission) + crate::memory_guard::should_override( + pool_limit_bytes, + crate::memory_guard::OverrideContext::Admission, + ) } #[cfg(test)] @@ -734,7 +776,12 @@ mod tests { fn try_grow_reduce_budget_skips_when_existing_covers() { let pool = test_pool(1_000_000_000); let schema = schema_of(vec![("a", DataType::Int64), ("b", DataType::Int64)]); - let needed = compute_untracked_bytes_with_columns(4, 8192, estimate_avg_row_bytes(&schema), schema.fields().len()); + let needed = compute_untracked_bytes_with_columns( + 4, + 8192, + estimate_avg_row_bytes(&schema), + schema.fields().len(), + ); // Existing reservation is larger — should return Ok(None) let result = try_grow_reduce_budget(&pool, &schema, 8192, 4, needed + 1).unwrap(); @@ -767,7 +814,11 @@ mod tests { let result = try_grow_reduce_budget(&pool, &schema, 8192, 4, 0).unwrap(); assert!(result.is_some()); let budget = result.unwrap(); - assert!(budget.target_partitions < 4, "expected partitions < 4, got {}", budget.target_partitions); + assert!( + budget.target_partitions < 4, + "expected partitions < 4, got {}", + budget.target_partitions + ); } #[test] diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/query_executor.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/query_executor.rs index ba2347b70f093..ba2c9da1114f7 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/query_executor.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/query_executor.rs @@ -8,30 +8,29 @@ use std::sync::Arc; -use native_bridge_common::log_debug; +use datafusion::execution::cache::cache_manager::{CacheManagerConfig, CachedFileList}; +use datafusion::execution::cache::{CacheAccessor, DefaultListFilesCache}; +use datafusion::execution::context::SessionContext; +use datafusion::logical_expr::{col, lit}; use datafusion::{ - common::DataFusionError, - datasource::listing::{ListingOptions, ListingTable, ListingTableConfig, ListingTableUrl}, - execution::context::SessionContext, - execution::runtime_env::RuntimeEnvBuilder, - execution::SessionStateBuilder, - physical_plan::displayable, + common::DataFusionError, datasource::listing::ListingTableUrl, + execution::runtime_env::RuntimeEnvBuilder, physical_plan::displayable, physical_plan::execute_stream, - prelude::*, }; -use datafusion::datasource::file_format::parquet::ParquetFormat; -use datafusion::execution::cache::cache_manager::{CacheManagerConfig, CachedFileList}; -use datafusion::execution::cache::{CacheAccessor, DefaultListFilesCache}; use datafusion_substrait::logical_plan::consumer::from_substrait_plan; use log::error; +use native_bridge_common::log_debug; use object_store::ObjectMeta; use object_store::ObjectStore; use prost::Message; use substrait::proto::Plan; +use crate::api::{DataFusionRuntime, ShardFileInfo}; use crate::cross_rt_stream::CrossRtStream; use crate::executor::DedicatedExecutor; -use crate::api::{DataFusionRuntime, ShardFileInfo}; +use crate::helper::{ + build_query_runtime_env_with_store, build_query_session_context, register_listing_table, +}; use crate::session_context::SessionContextHandle; /// Execute a vanilla parquet query: substrait plan → DataFusion → CrossRtStream. @@ -55,119 +54,34 @@ pub async fn execute_query( phantom_corrector: Option>, sort_fields: &[String], sort_orders: &[String], + internal_search: crate::datafusion_query_config::InternalSearch, ) -> Result { - // Build per-query RuntimeEnv with list-files cache pre-populated. - let runtime_env = build_query_runtime_env(runtime, &table_path, object_metas.as_ref())?; - - // If a per-query memory pool is provided, rebuild with it overlaid. - // The per-query pool wraps the global pool, so global limits are still enforced. - let runtime_env = if let Some(pool) = query_memory_pool { - Arc::from( - RuntimeEnvBuilder::from_runtime_env(&runtime_env) - .with_memory_pool(pool) - .build() - .map_err(|e| { - error!("Failed to build runtime env with query pool: {}", e); - e - })?, - ) - } else { - runtime_env - }; - - // Register shard-specific object store on file:// scheme for this query. - // Routes reads through TieredObjectStore (local + remote) or default LocalFileSystem. - runtime_env.register_object_store( - &url::Url::parse("file://").unwrap(), + // Build per-query RuntimeEnv (optional pool overlay) + register the shard store. + let runtime_env = build_query_runtime_env_with_store( + runtime, + &table_path, + object_metas.as_ref(), shard_store, + query_memory_pool, + )?; + + // Build a fresh session context per query (default optimizer rules on the + // vanilla path). TODO : Tune this during planning per query. + let ctx = build_query_session_context( + query_config, + runtime_env, + query_config.target_partitions, + false, // vanilla path ); - // Build a fresh session state per query. TODO : Tune this during planning per query - let mut config = SessionConfig::new(); - config.options_mut().execution.parquet.pushdown_filters = query_config.parquet_pushdown_filters; - config.options_mut().execution.target_partitions = query_config.target_partitions; - config.options_mut().execution.batch_size = query_config.batch_size; - - let state = SessionStateBuilder::new() - .with_config(config) - .with_runtime_env(runtime_env) - .with_default_features() - .build(); - - let ctx = SessionContext::new_with_state(state); - crate::udf::register_all(&ctx); - crate::udaf::register_all(&ctx); - - // Register table provider based on strategy. - // - // Note: api::execute_query only routes to this function when the plan does NOT - // request row IDs (otherwise it dispatches to the indexed executor). The strategy - // therefore matters only for distinguishing the ShardTableProvider rewrite - // (ListingTable) from the plain ListingTable scan (None / IndexedPredicateOnly). - use crate::datafusion_query_config::QueryStrategy; - match query_config.query_strategy { - QueryStrategy::ListingTable => { - use crate::shard_table_provider::{ShardTableConfig, ShardTableProvider}; - - // Infer schema from the first file - let file_format = ParquetFormat::new(); - let listing_options = ListingOptions::new(Arc::new(file_format)) - .with_file_extension(".parquet") - .with_collect_stat(true); - let resolved_schema = listing_options - .infer_schema(&ctx.state(), &table_path) - .await - .map_err(|e| { error!("Failed to infer schema: {}", e); e })?; - let resolved_schema = crate::schema_coerce::coerce_inferred_schema(resolved_schema); - - // Build ShardFileInfo with row_base from cumulative row counts - let store = ctx.state().runtime_env().object_store(&table_path)?; - let files = build_shard_file_infos(&store, object_metas.as_ref()).await?; - - let store_url = store_url_from_table_path(&table_path)?; - - let provider = Arc::new(ShardTableProvider::new(ShardTableConfig { - file_schema: resolved_schema, - files, - store_url, - })); - ctx.register_table(&table_name, provider) - .map_err(|e| { error!("Failed to register table: {}", e); e })?; - } - _ => { - // Baseline: use standard ListingTable - let file_format = ParquetFormat::new(); - let mut listing_options = ListingOptions::new(Arc::new(file_format)) - .with_file_extension(".parquet") - .with_collect_stat(true); - // Declare per-file sort order to DataFusion if the index has `index.sort.field`. - // See `session_context::build_file_sort_order` for what the declaration buys us - // and the case/nulls/non-sort caveats. - if let Some(sort_exprs) = crate::session_context::build_file_sort_order(sort_fields, sort_orders) { - listing_options = listing_options.with_file_sort_order(vec![sort_exprs]); - } - let resolved_schema = listing_options - .infer_schema(&ctx.state(), &table_path) - .await - .map_err(|e| { error!("Failed to infer schema: {}", e); e })?; - let resolved_schema = crate::schema_coerce::coerce_inferred_schema(resolved_schema); - let table_config = ListingTableConfig::new(table_path) - .with_listing_options(listing_options) - .with_schema(resolved_schema); - let provider = Arc::new(ListingTable::try_new(table_config) - .map_err(|e| { error!("Failed to create listing table: {}", e); e })?); - ctx.register_table(&table_name, provider) - .map_err(|e| { error!("Failed to register table: {}", e); e })?; - } - } - - // Decode substrait → logical plan → physical plan → stream - let substrait_plan = Plan::decode(plan_bytes.as_slice()).map_err(|e| { - DataFusionError::Execution(format!("Failed to decode Substrait: {}", e)) - })?; + // Register the standard DataFusion ListingTable. This function only runs the vanilla + // (non-row-id) path — QTF row-id plans always route to the indexed executor. + // Declares the per-file sort order when the index has `index.sort.field`. + register_listing_table(&ctx, &table_name, table_path, sort_fields, sort_orders).await?; - let logical_plan = from_substrait_plan(&ctx.state(), &substrait_plan).await?; - let dataframe = ctx.execute_logical_plan(logical_plan).await?; + // Planning: build the query DataFrame (Substrait decode for normal search, native filter for an + // engine-internal point lookup). Physical planning + execution below is shared by both. + let dataframe = build_dataframe(&ctx, &table_name, &plan_bytes, internal_search).await?; let physical_plan = dataframe.create_physical_plan().await?; // Retag any physical-plan output columns whose type tags differ from what Substrait @@ -178,19 +92,6 @@ pub async fn execute_query( let target_schema = crate::schema_coerce::coerce_inferred_schema(physical_plan.schema()); let physical_plan = crate::relabel_exec::wrap_if_relabel_needed(physical_plan, target_schema)?; - // Apply row ID optimizer when ShardTableProvider injected `row_base`. - // For other strategies the vanilla scan output is already what the plan expects. - use datafusion::physical_optimizer::PhysicalOptimizerRule; - let physical_plan = match query_config.query_strategy { - QueryStrategy::ListingTable => { - // Rewrites ___row_id to ___row_id + row_base. - let optimizer = crate::project_row_id_optimizer::ProjectRowIdOptimizer; - let config = datafusion::common::config::ConfigOptions::default(); - optimizer.optimize(physical_plan, &config)? - } - _ => physical_plan, - }; - let df_stream = execute_stream(physical_plan, ctx.task_ctx()).map_err(|e| { error!("Failed to create execution stream: {}", e); e @@ -198,11 +99,14 @@ pub async fn execute_query( // Wrap in CrossRtStream — CPU work runs on DedicatedExecutor let (cross_rt_stream, abort_handle, _task_done) = - CrossRtStream::new_with_df_error_stream_cancellable(df_stream, cpu_executor); + CrossRtStream::new_with_df_error_stream_cancellable(df_stream, cpu_executor.clone(), None); if let Some(h) = abort_handle { crate::query_tracker::set_abort_handle(context_id, h); } + if let Some(rt) = cpu_executor.handle() { + crate::query_tracker::set_cpu_runtime_handle(context_id, rt); + } // Attach phantom corrector for self-correcting budget (if provided) let cross_rt_stream = match phantom_corrector { @@ -218,6 +122,64 @@ pub async fn execute_query( Ok(Box::into_raw(Box::new(wrapped)) as i64) } +/// Build the query DataFrame against the table already registered in `ctx`. +/// +/// An engine-internal point lookup (get-by-id / seq-no scan) returns early with a small native +/// filter DataFrame; otherwise this is the standard user-search flow: decode the Substrait plan +/// into a logical plan and execute it. +/// +/// Internal-search filters: +/// - [`InternalSearch::ByRowId`]: `SELECT * WHERE __row_id__ = n LIMIT 1`. `__row_id__` is the +/// physical row position the writer stamps at flush, so equality is order-independent and prunes +/// row-groups/pages via min/max stats. +/// - [`InternalSearch::SeqNoAbove`]: `SELECT _id,_seq_no,_primary_term,_version WHERE _seq_no > f` +/// (version-map restore on recovery; metadata columns only). +async fn build_dataframe( + ctx: &SessionContext, + table_name: &str, + plan_bytes: &[u8], + internal_search: crate::datafusion_query_config::InternalSearch, +) -> Result { + // Engine-internal point lookup — build a native filter DataFrame and return early. + if internal_search.is_internal_search() { + return internal_search_dataframe(ctx, table_name, internal_search).await; + } + + // Standard user-search flow: Substrait → logical plan → DataFrame. + let substrait_plan = Plan::decode(plan_bytes) + .map_err(|e| DataFusionError::Execution(format!("Failed to decode Substrait: {}", e)))?; + let logical_plan = from_substrait_plan(&ctx.state(), &substrait_plan).await?; + ctx.execute_logical_plan(logical_plan).await +} + +/// Build the native filter DataFrame for an engine-internal point lookup against the table already +/// registered in `ctx`. Not user search — those go through the Substrait flow in [`build_dataframe`]. +/// +/// - [`InternalSearch::ByRowId`]: `SELECT * WHERE __row_id__ = n LIMIT 1`. `__row_id__` is the +/// physical row position the writer stamps at flush, so equality is order-independent and prunes +/// row-groups/pages via min/max stats. +/// - [`InternalSearch::SeqNoAbove`]: `SELECT _id,_seq_no,_primary_term,_version WHERE _seq_no > f` +/// (version-map restore on recovery; metadata columns only). +/// +/// Panics on [`InternalSearch::Off`] — callers gate on `is_internal_search()` first. +async fn internal_search_dataframe( + ctx: &SessionContext, + table_name: &str, + internal_search: crate::datafusion_query_config::InternalSearch, +) -> Result { + use crate::datafusion_query_config::InternalSearch; + let df = ctx.table(table_name).await?; + match internal_search { + InternalSearch::ByRowId(row_id) => df + .filter(col(crate::ROW_ID_COLUMN_NAME).eq(lit(row_id)))? + .limit(0, Some(1)), + InternalSearch::SeqNoAbove(seq_no_floor) => df + .filter(col("_seq_no").gt(lit(seq_no_floor)))? + .select_columns(&["_id", "_seq_no", "_primary_term", "_version"]), + InternalSearch::Off => unreachable!("internal_search_dataframe called with Off"), + } +} + /// Executes a Substrait plan against a pre-configured SessionContext. /// /// Takes ownership of the handle by value. The ownership transfer (consuming the @@ -225,10 +187,8 @@ pub async fn execute_query( /// by the time this function is reached the pointer is already invalidated from /// Java's perspective and cleanup is pure RAII. /// -/// When the plan requests row IDs and a `QueryStrategy` is configured, this -/// function routes to the appropriate execution path: -/// - `ListingTable`: applies `ProjectRowIdOptimizer` to the physical plan -/// - `IndexedPredicateOnly`: delegates to the indexed executor with `emit_row_ids=true` +/// This is the fragment (non-row-id) execution path: row-id-requesting plans are +/// routed to the indexed executor by `df_execute_with_context` before reaching here. pub async fn execute_with_context( handle: SessionContextHandle, plan_bytes: &[u8], @@ -238,45 +198,9 @@ pub async fn execute_with_context( // Permit was acquired by the caller (ffm.rs) on the IO runtime before // spawning on the CPU runtime, so the Java search thread blocks at the // gate when it is full — creating backpressure at the Java threadpool level. - use crate::datafusion_query_config::QueryStrategy; - let context_id = handle.query_context.context_id(); let token = crate::query_tracker::get_cancellation_token(context_id); - let query_strategy = handle.query_config.query_strategy; - - // If ListingTable strategy: replace the default ListingTable with ShardTableProvider - // that adds row_base partition column for ProjectRowIdOptimizer. - // Also register the ProjectRowIdAnalyzer to ensure __row_id__ survives logical optimization. - if query_strategy == QueryStrategy::ListingTable { - use crate::shard_table_provider::{ShardTableConfig, ShardTableProvider}; - - handle.ctx.deregister_table(&handle.table_name)?; - - let store = handle.ctx.state().runtime_env().object_store(&handle.table_path)?; - - // Infer schema from existing files - let listing_options = ListingOptions::new(Arc::new(ParquetFormat::new())) - .with_file_extension(".parquet") - .with_collect_stat(true); - let resolved_schema = listing_options - .infer_schema(&handle.ctx.state(), &handle.table_path) - .await?; - let resolved_schema = crate::schema_coerce::coerce_inferred_schema(resolved_schema); - - // Build ShardFileInfo with cumulative row_base from parquet metadata. - let files = build_shard_file_infos(&store, handle.object_metas.as_ref()).await?; - - let store_url = store_url_from_table_path(&handle.table_path)?; - - let provider = Arc::new(ShardTableProvider::new(ShardTableConfig { - file_schema: resolved_schema, - files, - store_url, - })); - handle.ctx.register_table(&handle.table_name, provider)?; - } - let query_future = async { // If prepare_partial_plan stored a stripped plan on this handle (engine-native-merge // PARTIAL stage triggered by SETUP_PARTIAL_AGGREGATE), skip the substrait re-decode @@ -288,22 +212,37 @@ pub async fn execute_with_context( // and fall through to the standard decode + execute below. if let Some(prepared) = handle.prepared_plan.as_ref() { let physical_plan = std::sync::Arc::clone(prepared); - let df_stream = execute_stream(physical_plan.clone(), handle.ctx.task_ctx()).map_err(|e| { - error!("execute_with_context: failed to execute prepared plan: {}", e); - e - })?; + let df_stream = + execute_stream(physical_plan.clone(), handle.ctx.task_ctx()).map_err(|e| { + error!( + "execute_with_context: failed to execute prepared plan: {}", + e + ); + e + })?; let (cross_rt_stream, abort_handle, _task_done) = - CrossRtStream::new_with_df_error_stream_cancellable(df_stream, cpu_executor); + CrossRtStream::new_with_df_error_stream_cancellable( + df_stream, + cpu_executor.clone(), + None, + ); if let Some(h) = abort_handle { crate::query_tracker::set_abort_handle(context_id, h); } + if let Some(rt) = cpu_executor.handle() { + crate::query_tracker::set_cpu_runtime_handle(context_id, rt); + } let wrapped = datafusion::physical_plan::stream::RecordBatchStreamAdapter::new( cross_rt_stream.schema(), cross_rt_stream, ); - return Ok::<(i64, Option>), DataFusionError>( - (Box::into_raw(Box::new(wrapped)) as i64, Some(physical_plan)), - ); + return Ok::< + ( + i64, + Option>, + ), + DataFusionError, + >((Box::into_raw(Box::new(wrapped)) as i64, Some(physical_plan))); } let substrait_plan = Plan::decode(plan_bytes).map_err(|e| { @@ -312,7 +251,10 @@ pub async fn execute_with_context( // Union schema widening was applied at table registration (session_context::widen_to_union_schema). let logical_plan = from_substrait_plan(&handle.ctx.state(), &substrait_plan).await?; - log_debug!("DataFusion logical plan:\n{}", logical_plan.display_indent()); + log_debug!( + "DataFusion logical plan:\n{}", + logical_plan.display_indent() + ); // Empty shard: skip physical planning (ParquetExec errors on zero files) // and emit an EmptyExec stream with the logical plan's output schema. @@ -321,8 +263,7 @@ pub async fn execute_with_context( use datafusion::physical_plan::ExecutionPlan; let plan_schema: arrow::datatypes::SchemaRef = Arc::new(logical_plan.schema().as_arrow().clone()); - let plan_schema = - crate::schema_coerce::coerce_inferred_schema(plan_schema); + let plan_schema = crate::schema_coerce::coerce_inferred_schema(plan_schema); let empty_exec = EmptyExec::new(Arc::clone(&plan_schema)); let df_stream = empty_exec.execute(0, handle.ctx.task_ctx()).map_err(|e| { error!("execute_with_context: failed to create empty stream: {}", e); @@ -330,15 +271,28 @@ pub async fn execute_with_context( })?; let (cross_rt_stream, abort_handle, _task_done) = - CrossRtStream::new_with_df_error_stream_cancellable(df_stream, cpu_executor); + CrossRtStream::new_with_df_error_stream_cancellable( + df_stream, + cpu_executor.clone(), + None, + ); if let Some(h) = abort_handle { crate::query_tracker::set_abort_handle(context_id, h); } + if let Some(rt) = cpu_executor.handle() { + crate::query_tracker::set_cpu_runtime_handle(context_id, rt); + } let wrapped = datafusion::physical_plan::stream::RecordBatchStreamAdapter::new( cross_rt_stream.schema(), cross_rt_stream, ); - return Ok::<(i64, Option>), DataFusionError>((Box::into_raw(Box::new(wrapped)) as i64, None)); + return Ok::< + ( + i64, + Option>, + ), + DataFusionError, + >((Box::into_raw(Box::new(wrapped)) as i64, None)); } let dataframe = handle.ctx.execute_logical_plan(logical_plan).await?; @@ -347,40 +301,75 @@ pub async fn execute_with_context( let physical_plan = dataframe.create_physical_plan().await?; let target_schema = crate::schema_coerce::coerce_inferred_schema(physical_plan.schema()); - let physical_plan = crate::relabel_exec::wrap_if_relabel_needed(physical_plan, target_schema)?; - log_debug!("DataFusion physical plan:\n{}", displayable(physical_plan.as_ref()).indent(true)); + let physical_plan = + crate::relabel_exec::wrap_if_relabel_needed(physical_plan, target_schema)?; + log_debug!( + "DataFusion physical plan:\n{}", + displayable(physical_plan.as_ref()).indent(true) + ); - let df_stream = execute_stream(physical_plan.clone(), handle.ctx.task_ctx()).map_err(|e| { - error!("execute_with_context: failed to create stream: {}", e); - e - })?; + let df_stream = + execute_stream(physical_plan.clone(), handle.ctx.task_ctx()).map_err(|e| { + error!("execute_with_context: failed to create stream: {}", e); + e + })?; let (cross_rt_stream, abort_handle, _task_done) = - CrossRtStream::new_with_df_error_stream_cancellable(df_stream, cpu_executor); + CrossRtStream::new_with_df_error_stream_cancellable( + df_stream, + cpu_executor.clone(), + None, + ); if let Some(h) = abort_handle { crate::query_tracker::set_abort_handle(context_id, h); } + if let Some(rt) = cpu_executor.handle() { + crate::query_tracker::set_cpu_runtime_handle(context_id, rt); + } let wrapped = datafusion::physical_plan::stream::RecordBatchStreamAdapter::new( cross_rt_stream.schema(), cross_rt_stream, ); - Ok::<(i64, Option>), DataFusionError>((Box::into_raw(Box::new(wrapped)) as i64, Some(physical_plan))) + Ok::< + ( + i64, + Option>, + ), + DataFusionError, + >((Box::into_raw(Box::new(wrapped)) as i64, Some(physical_plan))) }; - let (stream_ptr, physical_plan) = crate::cancellation::cancellable(token.as_ref(), context_id, query_future) - .await - .map_err(|e| DataFusionError::Execution(e))?; + let (stream_ptr, physical_plan) = + crate::cancellation::cancellable(token.as_ref(), context_id, query_future) + .await + .map_err(|e| DataFusionError::Execution(e))?; // Reconstruct the stream from the raw pointer - let stream = unsafe { *Box::from_raw(stream_ptr as *mut datafusion::physical_plan::stream::RecordBatchStreamAdapter) }; + let stream = unsafe { + *Box::from_raw( + stream_ptr + as *mut datafusion::physical_plan::stream::RecordBatchStreamAdapter, + ) + }; // Permit is held until the QueryStreamHandle is dropped (query complete). // If cancellation fires → stream drops → handle drops → permit drops → gate releases. let stream_handle = match physical_plan { - Some(plan) => crate::api::QueryStreamHandle::with_physical_plan(stream, handle.query_context, handle.ctx, Some(permit), plan), - None => crate::api::QueryStreamHandle::with_session_context(stream, handle.query_context, handle.ctx, Some(permit)), + Some(plan) => crate::api::QueryStreamHandle::with_physical_plan( + stream, + handle.query_context, + handle.ctx, + Some(permit), + plan, + ), + None => crate::api::QueryStreamHandle::with_session_context( + stream, + handle.query_context, + handle.ctx, + Some(permit), + ), }; Ok(Box::into_raw(Box::new(stream_handle)) as i64) } @@ -396,7 +385,9 @@ pub fn query_runtime_env_builder( list_file_cache: Arc, ) -> RuntimeEnvBuilder { RuntimeEnvBuilder::from_runtime_env(&runtime.runtime_env) - .with_object_store_registry(Arc::new(datafusion::execution::object_store::DefaultObjectStoreRegistry::new())) + .with_object_store_registry(Arc::new( + datafusion::execution::object_store::DefaultObjectStoreRegistry::new(), + )) .with_cache_manager( CacheManagerConfig::default() .with_list_files_cache(Some(list_file_cache)) @@ -424,7 +415,10 @@ pub fn build_query_runtime_env( table: None, path: table_path.prefix().clone(), }; - list_file_cache.put(&table_scoped_path, CachedFileList::new(object_metas.to_vec())); + list_file_cache.put( + &table_scoped_path, + CachedFileList::new(object_metas.to_vec()), + ); let runtime_env = query_runtime_env_builder(runtime, list_file_cache).build()?; Ok(Arc::from(runtime_env)) @@ -440,8 +434,10 @@ pub async fn build_shard_file_infos( let mut cumulative_rows: i64 = 0; for meta in object_metas { let reader = datafusion::parquet::arrow::async_reader::ParquetObjectReader::new( - Arc::clone(store), meta.location.clone(), - ).with_file_size(meta.size); + Arc::clone(store), + meta.location.clone(), + ) + .with_file_size(meta.size); let builder = datafusion::parquet::arrow::ParquetRecordBatchStreamBuilder::new(reader) .await .map_err(|e| DataFusionError::Execution(format!("parquet metadata: {}", e)))?; @@ -465,32 +461,49 @@ pub async fn build_shard_file_infos( } /// Parse a ListingTableUrl into an ObjectStoreUrl (scheme + authority). -pub fn store_url_from_table_path(table_path: &ListingTableUrl) -> Result { +pub fn store_url_from_table_path( + table_path: &ListingTableUrl, +) -> Result { let url_str = table_path.as_str(); let parsed = url::Url::parse(url_str) .map_err(|e| DataFusionError::Execution(format!("parse URL: {}", e)))?; - datafusion::execution::object_store::ObjectStoreUrl::parse( - format!("{}://{}", parsed.scheme(), parsed.authority()), - ) + datafusion::execution::object_store::ObjectStoreUrl::parse(format!( + "{}://{}", + parsed.scheme(), + parsed.authority() + )) } /// Wrap a DataFusion stream in CrossRtStream and package as a QueryStreamHandle pointer. +/// +/// Wires cancellation like the shard-query path so a `cancel_query` on the QTF fetch-by-rowid +/// stream can break/abort the cross_rt task instead of stranding its pool reservation. pub fn wrap_stream_as_handle( df_stream: datafusion::execution::SendableRecordBatchStream, cpu_executor: DedicatedExecutor, runtime: &DataFusionRuntime, context_id: i64, ) -> i64 { - let cross_rt_stream = CrossRtStream::new_with_df_error_stream(df_stream, cpu_executor); - let wrapped = datafusion::physical_plan::stream::RecordBatchStreamAdapter::new( - cross_rt_stream.schema(), - cross_rt_stream, - ); + // Create the tracking context first so its cancellation token is registered before the task starts. let query_context = crate::query_tracker::QueryTrackingContext::new( context_id, runtime.runtime_env.memory_pool.clone(), crate::query_tracker::QueryType::Shard, ); + + let (cross_rt_stream, abort_handle, _task_done) = + CrossRtStream::new_with_df_error_stream_cancellable(df_stream, cpu_executor.clone(), None); + if let Some(h) = abort_handle { + crate::query_tracker::set_abort_handle(context_id, h); + } + if let Some(rt) = cpu_executor.handle() { + crate::query_tracker::set_cpu_runtime_handle(context_id, rt); + } + + let wrapped = datafusion::physical_plan::stream::RecordBatchStreamAdapter::new( + cross_rt_stream.schema(), + cross_rt_stream, + ); let handle = crate::api::QueryStreamHandle::new(wrapped, query_context, None); Box::into_raw(Box::new(handle)) as i64 } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/query_tracker.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/query_tracker.rs index b52acd2cc3093..672ac1dcdfad5 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/query_tracker.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/query_tracker.rs @@ -22,7 +22,7 @@ use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; use dashmap::DashMap; -use log::debug; +use log::{debug, warn}; use once_cell::sync::Lazy; use tokio::task::AbortHandle; use tokio_util::sync::CancellationToken; @@ -172,6 +172,10 @@ pub struct QueryTracker { pub cancellation_token: CancellationToken, /// CPU task abort handle, set after the stream is created. pub abort_handle: OnceLock, + /// Handle to the DedicatedExecutor's tokio runtime. Used by `cancel_query` + /// to flush pending deferred drops (pull_from_input tasks holding GroupValues + /// buffers) after aborting the outer CrossRtStream task. + pub cpu_runtime_handle: OnceLock, /// Nanos since PROCESS_START when cancellation was signalled, or 0 if not cancelled. /// Set atomically via CAS in cancel_query — no lock needed. pub cancelled_at_nanos: AtomicU64, @@ -351,8 +355,32 @@ pub fn snapshot_top_n_by_current(out: &mut [WireQueryMetric]) -> usize { written } -/// Fire the cancellation token for the given context_id. +/// Maximum time the flush will block waiting for the CPU runtime to process +/// deferred drops (pull_from_input tasks holding GroupValues buffers). +const CANCEL_FLUSH_TIMEOUT: Duration = Duration::from_millis(500); + +/// Yields per flush worker. The abort cascade has 3 scheduling levels: +/// Level 1: CrossRtStream CPU task abort → drops CoalescePartitions receiver +/// Level 2: CoalescePartitions' run_input tasks see closed channel → exit → +/// drop PerPartitionStream → Arc refcount hits 0 +/// Level 3: SpawnedTask::drop aborts pull_from_input → drops GroupValues +/// Each level needs at least one scheduling round per task. With +/// target_partitions = N, levels 2 and 3 each have N tasks. +/// 32 yields per worker covers up to 32 partitions per level on a single worker. +const FLUSH_YIELDS_PER_WORKER: usize = 32; + +/// Number of flush tasks to spawn. Spawning across multiple workers ensures +/// the woken tasks (which may land on different workers' queues) are processed +/// in parallel rather than serialized through one worker's yields. +const FLUSH_WORKER_COUNT: usize = 4; + +/// Fire the cancellation token for the given context_id and abort the CPU task. /// No-op for unknown or already-completed queries. +/// +/// Note: this does NOT flush the runtime — the abort cascade only completes +/// after the `QueryStreamHandle` is dropped (via `stream_close`). Call +/// [`flush_cpu_runtime`] after `stream_close` to ensure deferred drops are +/// processed and GroupValues buffers are freed. pub fn cancel_query(context_id: i64) { if let Some(tracker) = QUERY_REGISTRY.get(&context_id) { tracker.cancellation_token.cancel(); @@ -360,13 +388,73 @@ pub fn cancel_query(context_id: i64) { handle.abort(); } let nanos = PROCESS_START.elapsed().as_nanos() as u64; - tracker.cancelled_at_nanos.compare_exchange(0, nanos, Ordering::Release, Ordering::Relaxed).ok(); + tracker + .cancelled_at_nanos + .compare_exchange(0, nanos, Ordering::Release, Ordering::Relaxed) + .ok(); + } +} + +/// Flush the CPU runtime for the given context_id, giving tokio workers +/// scheduling opportunities to process deferred drops from the abort cascade. +/// +/// Call this AFTER `stream_close` has dropped the `QueryStreamHandle` (which +/// drops the JoinSet, releasing aborted task futures for collection). The flush +/// spawns lightweight tasks that yield repeatedly, ensuring workers wake up and +/// process the pending drops (pull_from_input futures → GroupValues buffers). +/// +/// No-op if no runtime handle is registered or the query is unknown. +pub fn flush_cpu_runtime(context_id: i64) { + let rt_handle = QUERY_REGISTRY + .get(&context_id) + .and_then(|tracker| tracker.cpu_runtime_handle.get().cloned()); + + if let Some(handle) = rt_handle { + flush_cpu_runtime_with_handle(&handle, context_id); + } +} + +/// Flush variant that takes the runtime handle directly — used by `stream_close` +/// which must extract the handle before dropping the tracker (drop removes it +/// from the registry). +pub fn flush_cpu_runtime_with_handle(handle: &tokio::runtime::Handle, context_id: i64) { + let (tx, rx) = std::sync::mpsc::sync_channel(FLUSH_WORKER_COUNT); + for _ in 0..FLUSH_WORKER_COUNT { + let tx = tx.clone(); + handle.spawn(async move { + for _ in 0..FLUSH_YIELDS_PER_WORKER { + tokio::task::yield_now().await; + } + let _ = tx.send(()); + }); + } + drop(tx); + for _ in 0..FLUSH_WORKER_COUNT { + if rx.recv_timeout(CANCEL_FLUSH_TIMEOUT).is_err() { + warn!( + "flush_cpu_runtime({}): timed out after {}ms", + context_id, + CANCEL_FLUSH_TIMEOUT.as_millis() + ); + break; + } } } +/// Extract the CPU runtime handle from the tracker, removing it. Used by +/// `stream_close` to grab the handle before the tracker is dropped (which +/// removes it from the registry). +pub fn take_cpu_runtime_handle(context_id: i64) -> Option { + QUERY_REGISTRY + .get(&context_id) + .and_then(|tracker| tracker.cpu_runtime_handle.get().cloned()) +} + /// Clone the cancellation token for the given context_id, if registered. pub fn get_cancellation_token(context_id: i64) -> Option { - QUERY_REGISTRY.get(&context_id).map(|t| t.cancellation_token.clone()) + QUERY_REGISTRY + .get(&context_id) + .map(|t| t.cancellation_token.clone()) } /// Store the CPU task's AbortHandle for the given context_id. @@ -376,6 +464,14 @@ pub fn set_abort_handle(context_id: i64, handle: AbortHandle) { } } +/// Store the CPU runtime handle for the given context_id so that +/// `cancel_query` can flush deferred drops on that runtime. +pub fn set_cpu_runtime_handle(context_id: i64, handle: tokio::runtime::Handle) { + if let Some(tracker) = QUERY_REGISTRY.get(&context_id) { + tracker.cpu_runtime_handle.set(handle).ok(); + } +} + /// Counts queries currently running past the cancellation threshold, by type. /// Returns (shard_current, coordinator_current). /// @@ -420,7 +516,11 @@ impl QueryTrackingContext { /// disabled and `memory_pool()` returns `None`. pub fn new(context_id: i64, global_pool: Arc, query_type: QueryType) -> Self { if context_id == 0 { - return Self { tracker: None, phantom_reservation: None, phantom_corrector: None }; + return Self { + tracker: None, + phantom_reservation: None, + phantom_corrector: None, + }; } let query_pool = Arc::new(QueryMemoryPool::new(global_pool)); let tracker = Arc::new(QueryTracker { @@ -430,6 +530,7 @@ impl QueryTrackingContext { memory_pool: query_pool, cancellation_token: CancellationToken::new(), abort_handle: OnceLock::new(), + cpu_runtime_handle: OnceLock::new(), cancelled_at_nanos: AtomicU64::new(0), completed: AtomicBool::new(false), wall_nanos: std::sync::atomic::AtomicU64::new(0), @@ -459,10 +560,11 @@ impl QueryTrackingContext { /// Apply pending phantom correction from the self-correcting budget. pub fn apply_pending_phantom_correction(&mut self) { - let (corrector, reservation) = match (&self.phantom_corrector, &mut self.phantom_reservation) { - (Some(c), Some(r)) => (c, r), - _ => return, - }; + let (corrector, reservation) = + match (&self.phantom_corrector, &mut self.phantom_reservation) { + (Some(c), Some(r)) => (c, r), + _ => return, + }; let delta = corrector.take_pending_delta(); if delta == 0 { return; @@ -513,7 +615,8 @@ impl Drop for QueryTrackingContext { // If this query was cancelled and ran past the threshold, bump the total counter. let cancelled_nanos = tracker.cancelled_at_nanos.load(Ordering::Acquire); if cancelled_nanos > 0 { - let elapsed_since_cancel = PROCESS_START.elapsed().as_nanos() as u64 - cancelled_nanos; + let elapsed_since_cancel = + PROCESS_START.elapsed().as_nanos() as u64 - cancelled_nanos; if elapsed_since_cancel >= cancel_stats_threshold().as_nanos() as u64 { match tracker.query_type { QueryType::Shard => { @@ -534,7 +637,7 @@ mod tests { use super::*; use datafusion::execution::memory_pool::GreedyMemoryPool; use std::thread; - use std::time::Duration; + use std::time::{Duration, Instant}; fn make_global_pool(limit: usize) -> Arc { Arc::new(GreedyMemoryPool::new(limit)) @@ -795,11 +898,19 @@ mod tests { let ctx = QueryTrackingContext::new(ctx_id, global, QueryType::Shard); cancel_query(ctx_id); - let first = QUERY_REGISTRY.get(&ctx_id).unwrap().cancelled_at_nanos.load(Ordering::Relaxed); + let first = QUERY_REGISTRY + .get(&ctx_id) + .unwrap() + .cancelled_at_nanos + .load(Ordering::Relaxed); thread::sleep(Duration::from_millis(10)); cancel_query(ctx_id); - let second = QUERY_REGISTRY.get(&ctx_id).unwrap().cancelled_at_nanos.load(Ordering::Relaxed); + let second = QUERY_REGISTRY + .get(&ctx_id) + .unwrap() + .cancelled_at_nanos + .load(Ordering::Relaxed); // Second cancel should not overwrite the first timestamp assert_eq!(first, second); @@ -838,7 +949,8 @@ mod tests { let coord_id = 60_006; let shard_ctx = QueryTrackingContext::new(shard_id, Arc::clone(&global), QueryType::Shard); - let coord_ctx = QueryTrackingContext::new(coord_id, Arc::clone(&global), QueryType::Coordinator); + let coord_ctx = + QueryTrackingContext::new(coord_id, Arc::clone(&global), QueryType::Coordinator); cancel_query(shard_id); cancel_query(coord_id); @@ -858,6 +970,91 @@ mod tests { drop(coord_ctx); } + // ----------------------------------------------------------------------- + // Flush-on-cancel tests + // ----------------------------------------------------------------------- + + #[test] + fn test_cancel_query_flushes_deferred_drops() { + use std::sync::atomic::AtomicBool; + + // Tracks whether the captured state inside the spawned future was dropped. + struct DropSentinel(Arc); + impl Drop for DropSentinel { + fn drop(&mut self) { + self.0.store(true, Ordering::Release); + } + } + + let global = make_global_pool(10_000); + // Unique id: the QUERY_REGISTRY is process-wide and tests run in parallel, so this must not + // collide with any other test's id (70_001 collides with test_top_n_picks_highest_current_bytes). + let ctx_id = 80_001; + let ctx = QueryTrackingContext::new(ctx_id, global, QueryType::Shard); + + // Build a dedicated executor with its own tokio runtime. + let mut builder = tokio::runtime::Builder::new_multi_thread(); + builder.worker_threads(2).enable_all(); + let exec = crate::executor::DedicatedExecutor::new("test-flush", builder, 2); + + // Store the runtime handle in the tracker (mirrors production path). + if let Some(rt) = exec.handle() { + set_cpu_runtime_handle(ctx_id, rt); + } + + // Spawn a task that holds the sentinel and blocks forever at an await. + let dropped = Arc::new(AtomicBool::new(false)); + let sentinel = DropSentinel(Arc::clone(&dropped)); + + let (abort_handle, _join_fut) = exec.spawn_with_abort_handle(async move { + let _hold = sentinel; // captured in the future's state + // Block forever — only abort can end this. + futures::future::pending::<()>().await; + }); + + if let Some(h) = abort_handle { + set_abort_handle(ctx_id, h); + } + + // Small delay to let the task actually park at the pending().await + thread::sleep(Duration::from_millis(10)); + + // Verify not yet dropped + assert!( + !dropped.load(Ordering::Acquire), + "sentinel should be alive before cancel" + ); + + // cancel_query aborts the task (marks it cancelled in the runtime) + cancel_query(ctx_id); + + // The abort is async — the task future may not be dropped yet. + // flush_cpu_runtime gives the runtime scheduling opportunities to + // process the abort and drop the future (freeing the sentinel). A single + // flush is best-effort: it spawns a fixed number of yield tasks and + // returns once they finish, which under heavy parallel test load (a + // CPU-starved CI box running 1000+ tests against a 2-worker runtime) can + // race the runtime actually polling and dropping the aborted task. Poll + // the flush until the deferred drop is observed, bounded by a timeout so + // a genuine regression (flush never processes the drop) still fails. + let deadline = Instant::now() + Duration::from_secs(10); + while !dropped.load(Ordering::Acquire) && Instant::now() < deadline { + flush_cpu_runtime(ctx_id); + if dropped.load(Ordering::Acquire) { + break; + } + thread::sleep(Duration::from_millis(5)); + } + + // After flushing, the sentinel must have been dropped. + assert!( + dropped.load(Ordering::Acquire), + "sentinel must be dropped after flush — deferred drop was not processed" + ); + + drop(ctx); + exec.join_blocking(); + } // ----------------------------------------------------------------------- // Top-N snapshot tests diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/relabel_exec.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/relabel_exec.rs index d2d69fba319cc..e8a1adf105738 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/relabel_exec.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/relabel_exec.rs @@ -113,7 +113,11 @@ impl ExecutionPlan for RelabelExec { Ok(Self::try_new(new_input, target_schema)? as Arc) } - fn execute(&self, partition: usize, context: Arc) -> Result { + fn execute( + &self, + partition: usize, + context: Arc, + ) -> Result { let input_stream = self.input.execute(partition, context)?; let target_schema = Arc::clone(&self.target_schema); let target_schema_for_stream = Arc::clone(&target_schema); @@ -121,7 +125,10 @@ impl ExecutionPlan for RelabelExec { let target = Arc::clone(&target_schema); async move { relabel_batch(&batch, &target) } }); - Ok(Box::pin(RecordBatchStreamAdapter::new(target_schema_for_stream, mapped))) + Ok(Box::pin(RecordBatchStreamAdapter::new( + target_schema_for_stream, + mapped, + ))) } } @@ -154,7 +161,9 @@ fn validate_bit_compatible(src_schema: &SchemaRef, target_schema: &SchemaRef) -> .zip(target_schema.fields().iter()) .enumerate() { - if src_f.data_type() != dst_f.data_type() && !is_bit_compatible(src_f.data_type(), dst_f.data_type()) { + if src_f.data_type() != dst_f.data_type() + && !is_bit_compatible(src_f.data_type(), dst_f.data_type()) + { return Err(DataFusionError::Internal(format!( "RelabelExec column {} ('{}') has non-bit-compatible types: {:?} → {:?} \ (use a LogicalPlan-level Cast for this conversion)", @@ -225,7 +234,8 @@ mod tests { #[test] fn relabel_uint64_to_int64_shares_buffer() { let src: ArrayRef = Arc::new(UInt64Array::from(vec![1u64, 2, 3])); - let target_schema: SchemaRef = Arc::new(Schema::new(vec![Field::new("x", DataType::Int64, true)])); + let target_schema: SchemaRef = + Arc::new(Schema::new(vec![Field::new("x", DataType::Int64, true)])); let batch = RecordBatch::try_new( Arc::new(Schema::new(vec![Field::new("x", DataType::UInt64, true)])), vec![src], @@ -241,7 +251,8 @@ mod tests { #[test] fn relabel_skips_no_op_when_schemas_match() { let src: ArrayRef = Arc::new(Int64Array::from(vec![10i64, 20])); - let same_schema: SchemaRef = Arc::new(Schema::new(vec![Field::new("y", DataType::Int64, false)])); + let same_schema: SchemaRef = + Arc::new(Schema::new(vec![Field::new("y", DataType::Int64, false)])); let batch = RecordBatch::try_new(Arc::clone(&same_schema), vec![Arc::clone(&src)]).unwrap(); let out = relabel_batch(&batch, &same_schema).unwrap(); // Same Arc — the no-op branch in relabel_batch is the test target. diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/runtime_manager.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/runtime_manager.rs index 9b61cc1f2d172..9db08a7407391 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/runtime_manager.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/runtime_manager.rs @@ -5,8 +5,7 @@ * this file be licensed under the Apache-2.0 license or a * compatible open source license. */ -use crate::executor::DedicatedExecutor; -use crate::io::register_io_runtime; +use crate::executor::{ConcurrencyGate, DedicatedExecutor}; use log::info; use std::sync::Arc; use tokio::runtime::{Builder, Runtime}; @@ -33,27 +32,31 @@ impl RuntimeManager { .expect("Failed to create IO runtime"), ); - register_io_runtime(Some(io_runtime.handle().clone())); + // Publish the IO runtime handle in native_bridge_common so the separate + // native-repository-* crates (s3/gcs/azure) can install a + // SpawnedReqwestConnector that runs HTTP IO on this runtime. + // Initialization order: DataFusionService starts this plugin first, then + // native-repository-s3/gcs/azure read the handle at object-store build time. + // The handle is always available before any store is constructed. + native_bridge_common::io_runtime::set_io_handle(io_runtime.handle().clone()); let io_monitor = RuntimeMonitor::new(&io_runtime.handle()); - let io_handle = io_runtime.handle().clone(); let mut cpu_runtime_builder = Builder::new_multi_thread(); cpu_runtime_builder .worker_threads(cpu_threads) .thread_name("datafusion-cpu") - .enable_all() - .on_thread_start(move || { - register_io_runtime(Some(io_handle.clone())); - }); + .enable_all(); // Fragment executor concurrency gate: limits concurrent partition tasks from shard scans. let datanode_max_concurrent = (cpu_threads as f64 * datanode_multiplier).max(1.0) as usize; - let cpu_executor = DedicatedExecutor::new("datafusion-cpu", cpu_runtime_builder, datanode_max_concurrent); + let cpu_executor = DedicatedExecutor::new( + "datafusion-cpu", + cpu_runtime_builder, + datanode_max_concurrent, + ); - let cpu_monitor = cpu_executor - .handle() - .map(|h| RuntimeMonitor::new(&h)); + let cpu_monitor = cpu_executor.handle().map(|h| RuntimeMonitor::new(&h)); Self { io_runtime, @@ -69,6 +72,9 @@ impl RuntimeManager { pub fn shutdown(&self) { info!("Shutting down RuntimeManager"); + // Clear the published IO handle so a torn-down runtime is never handed + // out to a remote object-store builder after shutdown. + native_bridge_common::io_runtime::clear_io_handle(); self.cpu_executor.join_blocking(); } } @@ -112,17 +118,4 @@ mod tests { mgr.cpu_executor.shutdown(); std::mem::forget(mgr); } - - #[tokio::test] - async fn test_io_runtime_registered_on_cpu_threads() { - let mgr = test_mgr(); - let has_io = mgr - .cpu_executor() - .spawn(async { crate::io::IO_RUNTIME.with_borrow(|h| h.is_some()) }) - .await - .unwrap(); - assert!(has_io); - mgr.cpu_executor.shutdown(); - std::mem::forget(mgr); - } } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/schema_coerce.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/schema_coerce.rs index 77a23e25d659d..3fcb1c3704ca4 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/schema_coerce.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/schema_coerce.rs @@ -102,11 +102,7 @@ pub fn coerce_inferred_schema(schema: SchemaRef) -> SchemaRef { if !schema_needs_coerce(&schema) { return schema; } - let rewritten_fields: Vec = schema - .fields() - .iter() - .map(|f| rewrite_field(f)) - .collect(); + let rewritten_fields: Vec = schema.fields().iter().map(|f| rewrite_field(f)).collect(); Arc::new(Schema::new_with_metadata( rewritten_fields, schema.metadata().clone(), @@ -114,7 +110,10 @@ pub fn coerce_inferred_schema(schema: SchemaRef) -> SchemaRef { } fn schema_needs_coerce(schema: &Schema) -> bool { - schema.fields().iter().any(|f| contains_incompatible(f.data_type())) + schema + .fields() + .iter() + .any(|f| contains_incompatible(f.data_type())) } fn contains_incompatible(dt: &DataType) -> bool { @@ -125,7 +124,9 @@ fn contains_incompatible(dt: &DataType) -> bool { } DataType::Map(f, _) => contains_incompatible(f.data_type()), DataType::Struct(fields) => fields.iter().any(|f| contains_incompatible(f.data_type())), - DataType::Union(fields, _) => fields.iter().any(|(_, f)| contains_incompatible(f.data_type())), + DataType::Union(fields, _) => fields + .iter() + .any(|(_, f)| contains_incompatible(f.data_type())), DataType::Dictionary(_, value_type) => contains_incompatible(value_type), _ => false, } @@ -133,8 +134,7 @@ fn contains_incompatible(dt: &DataType) -> bool { fn rewrite_field(field: &Field) -> Field { let new_type = rewrite_data_type(field.data_type()); - Field::new(field.name(), new_type, field.is_nullable()) - .with_metadata(field.metadata().clone()) + Field::new(field.name(), new_type, field.is_nullable()).with_metadata(field.metadata().clone()) } fn rewrite_data_type(dt: &DataType) -> DataType { @@ -169,16 +169,24 @@ pub fn append_missing_nullable(registered: &Schema, expected: &Schema) -> Option for ef in expected.fields() { if registered.field_with_name(ef.name()).is_err() { added.push( - Field::new(ef.name(), ef.data_type().clone(), true).with_metadata(ef.metadata().clone()), + Field::new(ef.name(), ef.data_type().clone(), true) + .with_metadata(ef.metadata().clone()), ); } } if added.is_empty() { return None; } - let mut fields: Vec = registered.fields().iter().map(|f| f.as_ref().clone()).collect(); + let mut fields: Vec = registered + .fields() + .iter() + .map(|f| f.as_ref().clone()) + .collect(); fields.extend(added); - Some(Arc::new(Schema::new_with_metadata(fields, registered.metadata().clone()))) + Some(Arc::new(Schema::new_with_metadata( + fields, + registered.metadata().clone(), + ))) } #[cfg(test)] @@ -199,7 +207,8 @@ mod tests { Field::new("alias", DataType::Utf8, false), ]); - let merged = append_missing_nullable(®istered, &expected).expect("alias missing → augmented"); + let merged = + append_missing_nullable(®istered, &expected).expect("alias missing → augmented"); assert_eq!(merged.fields().len(), 3); let alias = merged.field_with_name("alias").unwrap(); assert_eq!(alias.data_type(), &DataType::Utf8); @@ -249,15 +258,21 @@ mod tests { ])); let before = Arc::as_ptr(&schema); let out = coerce_inferred_schema(schema); - assert_eq!(Arc::as_ptr(&out), before, "unchanged schema must not reallocate"); + assert_eq!( + Arc::as_ptr(&out), + before, + "unchanged schema must not reallocate" + ); } #[test] fn nested_list_of_binary_view_gets_rewritten() { let inner = Field::new("item", DataType::BinaryView, true); - let schema = Arc::new(Schema::new(vec![ - Field::new("xs", DataType::List(Arc::new(inner)), true), - ])); + let schema = Arc::new(Schema::new(vec![Field::new( + "xs", + DataType::List(Arc::new(inner)), + true, + )])); let out = coerce_inferred_schema(schema); match out.field(0).data_type() { DataType::List(f) => assert_eq!(f.data_type(), &DataType::Binary), @@ -268,9 +283,11 @@ mod tests { #[test] fn nested_list_of_uint64_gets_rewritten() { let inner = Field::new("item", DataType::UInt64, true); - let schema = Arc::new(Schema::new(vec![ - Field::new("xs", DataType::List(Arc::new(inner)), true), - ])); + let schema = Arc::new(Schema::new(vec![Field::new( + "xs", + DataType::List(Arc::new(inner)), + true, + )])); let out = coerce_inferred_schema(schema); match out.field(0).data_type() { DataType::List(f) => assert_eq!(f.data_type(), &DataType::Int64), diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/scoped_index_optimizer.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/scoped_index_optimizer.rs new file mode 100644 index 0000000000000..642cf6b32ca39 --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/scoped_index_optimizer.rs @@ -0,0 +1,455 @@ +/* + * 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. + */ + +//! Physical optimizer rule that installs the scoped page-index reader factory on +//! **every** parquet scan in the plan — provider-agnostic. +//! +//! # Why a rule (not a TableProvider) +//! +//! The scoped page-index loader is a property of *how we read parquet*, not of +//! *which TableProvider* produced the scan. Wiring it into a specific provider +//! leaves other scan paths on DataFusion's default reader, which loads the full +//! all-column page index every query and caches none of it. +//! +//! This rule walks the physical plan, finds each parquet `DataSourceExec`, reads +//! the predicate already pushed onto its `ParquetSource`, derives the predicate +//! columns, and swaps in a [`ScopedPageIndexReaderFactory`] scoped to those +//! columns. It runs after DataFusion's own optimizers (which is when filter +//! pushdown has populated `ParquetSource::predicate`), so it works uniformly for +//! `ListingTable`, `ShardTableProvider`, and any future parquet provider. +//! +//! # Replace, do NOT skip-if-present +//! +//! DataFusion's `ParquetFormat::create_physical_plan` ALWAYS pre-installs its own +//! `CachedParquetFileReaderFactory` (the full all-column page-index loader). That +//! is exactly the factory we want to replace, so this rule does not skip a scan +//! just because a factory is already set. (A skip-if-present guard was the +//! original bug that made the end-to-end listing scan never use the scoped +//! reader.) The indexed path does not run this rule — it uses its own executor. +//! +//! # No-op cases (left exactly as DataFusion would run them) +//! +//! - A `DataSourceExec` that isn't parquet — skipped. +//! - A parquet scan with no predicate, or whose predicate references no file +//! columns — skipped (nothing to scope; the opener loads on demand as today). + +use std::sync::Arc; + +use datafusion::common::config::ConfigOptions; +use datafusion::common::tree_node::{Transformed, TreeNode}; +use datafusion::common::Result; +use datafusion::datasource::physical_plan::{FileSource, ParquetSource}; +use datafusion::datasource::source::DataSourceExec; +use datafusion::execution::cache::cache_manager::FileMetadataCache; +use datafusion::physical_expr::utils::collect_columns; +use datafusion::physical_optimizer::PhysicalOptimizerRule; +use datafusion::physical_plan::ExecutionPlan; +use datafusion_datasource::file_scan_config::{FileScanConfig, FileScanConfigBuilder}; +use object_store::ObjectStore; + +use crate::scoped_page_index_reader::ScopedPageIndexReaderFactory; + +/// Installs the scoped page-index reader factory on parquet scans. +/// +/// Carries the object store and shared metadata cache because a +/// `PhysicalOptimizerRule` has no access to the session; the caller constructs +/// it from the query's `RuntimeEnv`. +#[derive(Debug)] +pub struct ScopedPageIndexOptimizer { + store: Arc, + metadata_cache: Arc, +} + +impl ScopedPageIndexOptimizer { + pub fn new(store: Arc, metadata_cache: Arc) -> Self { + Self { + store, + metadata_cache, + } + } +} + +impl PhysicalOptimizerRule for ScopedPageIndexOptimizer { + fn optimize( + &self, + plan: Arc, + _config: &ConfigOptions, + ) -> Result> { + let rewritten = plan.transform_up(|node| { + let Some(dse) = node.downcast_ref::() else { + return Ok(Transformed::no(node)); + }; + let Some(config) = dse.data_source().as_ref().downcast_ref::() else { + return Ok(Transformed::no(node)); + }; + let Some(parquet) = (config.file_source().as_ref() as &dyn std::any::Any) + .downcast_ref::() + else { + return Ok(Transformed::no(node)); + }; + + let file_schema = config.file_schema(); + let predicate = parquet.filter(); + + // Predicate column NAMES — empty when there's no pushed-down filter. + let mut predicate_names: Vec = predicate + .as_ref() + .map(|p| { + let mut names: Vec = collect_columns(p) + .into_iter() + .map(|c| c.name().to_string()) + .filter(|n| file_schema.index_of(n).is_ok()) + .collect(); + names.sort(); + names.dedup(); + names + }) + .unwrap_or_default(); + + // Projected column NAMES — the file-schema columns this scan actually READS. + // + // We must derive these from the projection's underlying column references, NOT from + // the projected output field names. With expression push-down the projected schema can + // contain computed fields (e.g. `CASE WHEN status = 200 ...`) whose names are not in the + // file schema; those expressions still read the base column (`status`). Matching output + // field names against the file schema would drop `status`, leaving it without a real + // OffsetIndex — and a column that is read but only has the single-page placeholder OI + // corrupts the page decode ("provided output is too small for the decompressed data"). + // `ProjectionExprs::column_indices()` walks every projection expression and returns the + // referenced file-schema column indices, which is exactly the read set we must scope to. + let num_file_cols = file_schema.fields().len(); + let projection_names: Vec = match config.file_source().projection() { + Some(proj) => proj + .column_indices() + .into_iter() + .filter(|&i| i < num_file_cols) + .map(|i| file_schema.field(i).name().to_string()) + .collect(), + // No projection pushed → the scan reads every column. + None => Vec::new(), + }; + + // Only scope when there's something to scope to. A full-schema scan with no predicate + // gains nothing from scoping — skip it. A None projection (read-all) or a projection + // that already covers every column is not a strict subset. + let is_projected = + !projection_names.is_empty() && projection_names.len() < num_file_cols; + if predicate_names.is_empty() && !is_projected { + return Ok(Transformed::no(node)); + } + // Pass empty projection when the scan reads all columns — the factory + // will build all-column OffsetIndex (existing behavior). + let projection_names = if is_projected { + projection_names + } else { + Vec::new() + }; + + // Build the scoped factory and reinstall the source. The predicate is + // retained for parity but not used for RG scoping (Step 1 builds an + // all-row-group, column-scoped page index — see the reader's docs). + let factory = Arc::new(ScopedPageIndexReaderFactory::new( + Arc::clone(&self.store), + Arc::clone(&self.metadata_cache), + predicate_names, + projection_names, + predicate, + Arc::clone(file_schema), + )); + let new_source = parquet.clone().with_parquet_file_reader_factory(factory); + let new_config = FileScanConfigBuilder::from(config.clone()) + .with_source(Arc::new(new_source)) + .build(); + let new_dse: Arc = DataSourceExec::from_data_source(new_config); + Ok(Transformed::yes(new_dse)) + })?; + Ok(rewritten.data) + } + + fn name(&self) -> &str { + "ScopedPageIndexOptimizer" + } + + /// We swap a reader factory only; the scan's output schema is unchanged. + fn schema_check(&self) -> bool { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cache::page_index; + use crate::parquet_page_cache::{clear_scoped_cache_for_test, scoped_cache_stats}; + use arrow::datatypes::{DataType, Field, Schema}; + use datafusion::execution::cache::DefaultFilesMetadataCache; + use datafusion::execution::object_store::ObjectStoreUrl; + use datafusion::logical_expr::Operator; + use datafusion::physical_expr::expressions::{lit, BinaryExpr, Column}; + use datafusion::physical_expr::PhysicalExpr; + use datafusion_datasource::table_schema::TableSchema; + use object_store::memory::InMemory; + + fn schema() -> Arc { + Arc::new(Schema::new(vec![ + Field::new("a", DataType::Int32, false), + Field::new("b", DataType::Int32, false), + ])) + } + + fn deps() -> (Arc, Arc) { + ( + Arc::new(InMemory::new()), + Arc::new(DefaultFilesMetadataCache::new(64 * 1024 * 1024)), + ) + } + + fn datasource_exec(parquet: ParquetSource) -> Arc { + let config = + FileScanConfigBuilder::new(ObjectStoreUrl::local_filesystem(), Arc::new(parquet)) + .build(); + DataSourceExec::from_data_source(config) + } + + fn predicate_on_a() -> Arc { + Arc::new(BinaryExpr::new( + Arc::new(Column::new("a", 0)), + Operator::Gt, + lit(5i32), + )) + } + + fn parquet_for(sch: &Arc) -> ParquetSource { + ParquetSource::new(TableSchema::new(sch.clone(), vec![])) + } + + fn get_factory(plan: &Arc) -> Option { + let dse = plan.downcast_ref::()?; + let cfg = + (dse.data_source().as_ref() as &dyn std::any::Any).downcast_ref::()?; + let pq = + (cfg.file_source().as_ref() as &dyn std::any::Any).downcast_ref::()?; + Some(pq.parquet_file_reader_factory().is_some()) + } + + #[test] + fn installs_factory_when_predicate_present() { + let sch = schema(); + let (store, cache) = deps(); + let parquet = parquet_for(&sch).with_predicate(predicate_on_a()); + let plan = datasource_exec(parquet); + assert_eq!( + get_factory(&plan), + Some(false), + "precondition: no factory yet" + ); + + let rule = ScopedPageIndexOptimizer::new(store, cache); + let out = rule.optimize(plan, &ConfigOptions::default()).unwrap(); + assert_eq!( + get_factory(&out), + Some(true), + "optimizer must install a scoped reader factory when a predicate is present" + ); + } + + #[test] + fn noop_without_predicate_or_projection() { + let sch = schema(); + let (store, cache) = deps(); + // No predicate, no pushed projection — nothing to scope. + let plan = datasource_exec(parquet_for(&sch)); + let rule = ScopedPageIndexOptimizer::new(store, cache); + let out = rule.optimize(plan, &ConfigOptions::default()).unwrap(); + assert_eq!( + get_factory(&out), + Some(false), + "no predicate and no projection → nothing to scope → no factory installed" + ); + } + + /// A projection-only scan (no predicate pushed down) still needs a scoped + /// OffsetIndex so the parquet reader fetches only matched-row pages instead + /// of whole column chunks. The factory must be installed from the projected + /// schema even when `parquet.filter()` is `None`. + #[test] + fn installs_factory_for_projection_only_scan() { + use datafusion::parquet::arrow::ProjectionMask; + use datafusion_datasource::file_scan_config::FileScanConfigBuilder; + + let sch = schema(); // fields: a(0), b(1) + let (store, cache) = deps(); + + // Project only column `a` — no predicate. + let parquet = parquet_for(&sch); + let config = + FileScanConfigBuilder::new(ObjectStoreUrl::local_filesystem(), Arc::new(parquet)) + .with_projection(Some(vec![0])) // project `a` only + .build(); + let plan = DataSourceExec::from_data_source(config); + + let rule = ScopedPageIndexOptimizer::new(store, cache); + let out = rule.optimize(plan, &ConfigOptions::default()).unwrap(); + assert_eq!( + get_factory(&out), + Some(true), + "projection-only scan must get a scoped factory for OffsetIndex scoping" + ); + } + + /// The rule REPLACES an already-installed factory when a predicate is present + /// (DataFusion's `ParquetFormat` always pre-installs its own). + #[test] + fn replaces_existing_default_factory() { + let sch = schema(); + let (store, cache) = deps(); + let pre = Arc::new(ScopedPageIndexReaderFactory::new( + Arc::clone(&store), + Arc::clone(&cache), + vec!["a".to_string()], + vec!["a".to_string()], + None, + sch.clone(), + )); + let parquet = parquet_for(&sch) + .with_predicate(predicate_on_a()) + .with_parquet_file_reader_factory(pre); + let plan = datasource_exec(parquet); + assert_eq!( + get_factory(&plan), + Some(true), + "precondition: a factory is present" + ); + + let rule = ScopedPageIndexOptimizer::new(store, cache); + let out = rule + .optimize(Arc::clone(&plan), &ConfigOptions::default()) + .unwrap(); + assert_eq!( + get_factory(&out), + Some(true), + "scoped factory present after rule" + ); + assert!( + !Arc::ptr_eq(&plan, &out), + "rule must rewrite the scan to install the scoped factory, replacing the default" + ); + } + + /// End-to-end through a real `SessionContext` + stock `ListingTable`: write a + /// parquet file, register it, plan `SELECT s0 WHERE n1 >= k`, apply the rule, + /// execute, and assert (a) results are correct and (b) the shared scoped + /// page-index cache filled — proving the rule installs a working scoped reader + /// on the vanilla listing path. + #[tokio::test] + async fn end_to_end_listing_scan_fills_scoped_cache() { + use arrow::array::{Int32Array, StringArray}; + use arrow::record_batch::RecordBatch; + use datafusion::datasource::file_format::parquet::ParquetFormat; + use datafusion::datasource::listing::{ + ListingOptions, ListingTable, ListingTableConfig, ListingTableUrl, + }; + use datafusion::parquet::arrow::ArrowWriter; + use datafusion::parquet::file::properties::{EnabledStatistics, WriterProperties}; + use datafusion::prelude::SessionContext; + use futures::StreamExt; + + // Serialize on the shared guard — this asserts on the global cache. + let _g = page_index::SCOPED_CACHE_TEST_GUARD.lock().unwrap(); + crate::cache::page_index::clear_scoped_cache_for_test(); + + let sch = Arc::new(Schema::new(vec![ + Field::new("n0", DataType::Int32, false), + Field::new("n1", DataType::Int32, false), + Field::new("s0", DataType::Utf8, false), + Field::new("s1", DataType::Utf8, false), + ])); + const ROWS: i32 = 4096; + let n0: Vec = (0..ROWS).collect(); + let n1: Vec = (0..ROWS).collect(); + let s0: Vec = (0..ROWS) + .map(|r| format!("s0_{r:06}_padding_padding")) + .collect(); + let s1: Vec = (0..ROWS) + .map(|r| format!("s1_{r:06}_padding_padding")) + .collect(); + let batch = RecordBatch::try_new( + sch.clone(), + vec![ + Arc::new(Int32Array::from(n0)), + Arc::new(Int32Array::from(n1)), + Arc::new(StringArray::from(s0)), + Arc::new(StringArray::from(s1)), + ], + ) + .unwrap(); + + let dir = std::env::temp_dir().join(format!("scoped_e2e_{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let file_path = dir.join("data.parquet"); + { + let props = WriterProperties::builder() + .set_data_page_row_count_limit(256) + .set_write_batch_size(256) + .set_statistics_enabled(EnabledStatistics::Page) + .build(); + let f = std::fs::File::create(&file_path).unwrap(); + let mut w = ArrowWriter::try_new(f, sch.clone(), Some(props)).unwrap(); + w.write(&batch).unwrap(); + w.close().unwrap(); + } + + let ctx = SessionContext::new(); + let store: Arc = Arc::new(object_store::local::LocalFileSystem::new()); + let table_url = + ListingTableUrl::parse(format!("file://{}", dir.to_str().unwrap())).unwrap(); + ctx.register_object_store(table_url.as_ref(), Arc::clone(&store)); + let listing_options = ListingOptions::new(Arc::new(ParquetFormat::new())) + .with_file_extension(".parquet") + .with_collect_stat(true); + let resolved = listing_options + .infer_schema(&ctx.state(), &table_url) + .await + .unwrap(); + let config = ListingTableConfig::new(table_url.clone()) + .with_listing_options(listing_options) + .with_schema(resolved); + let provider = Arc::new(ListingTable::try_new(config).unwrap()); + ctx.register_table("t", provider).unwrap(); + + // n1 >= 4080 keeps rows 4080..4096 (16 rows); project the non-predicate s0. + let df = ctx.sql("SELECT s0 FROM t WHERE n1 >= 4080").await.unwrap(); + let physical = df.create_physical_plan().await.unwrap(); + + let metadata_cache = ctx.runtime_env().cache_manager.get_file_metadata_cache(); + let rule = ScopedPageIndexOptimizer::new(Arc::clone(&store), metadata_cache); + let physical = rule.optimize(physical, &ConfigOptions::default()).unwrap(); + + let mut stream = + datafusion::physical_plan::execute_stream(physical, ctx.task_ctx()).unwrap(); + let mut rows = 0usize; + while let Some(b) = stream.next().await { + rows += b.unwrap().num_rows(); + } + + assert_eq!(rows, 16, "predicate n1>=4080 must keep 16 rows"); + + let stats = scoped_cache_stats(); + assert!( + stats.entries >= 1 && stats.used_bytes > 0, + "scoped cache must have filled on the listing path: {stats:?}" + ); + assert!( + stats.misses >= 1, + "first scan must register a scoped-cache miss: {stats:?}" + ); + + clear_scoped_cache_for_test(); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/scoped_page_index_reader.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/scoped_page_index_reader.rs new file mode 100644 index 0000000000000..19d1c62a49ff7 --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/scoped_page_index_reader.rs @@ -0,0 +1,419 @@ +/* + * 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. + */ + +//! Scoped page-index reader factory for the **listing-table** scan path. +//! +//! # Why this exists +//! +//! The listing-table path (`ShardTableProvider` / vanilla `ListingTable`) uses +//! DataFusion's default reader factory, so when page pruning is enabled the +//! `ParquetOpener` loads the **entire** page index (`ColumnIndex` + `OffsetIndex` +//! for *every* column) of each surviving file, every query, and caches none of +//! it. On wide schemas the `ColumnIndex` (per-page string min/max) dominates the +//! native heap. +//! +//! This factory closes that gap using the unified scoped cache +//! ([`crate::cache::page_index`]). The seam is DataFusion's +//! [`ParquetFileReaderFactory`]: the `ParquetOpener` asks the reader for metadata +//! via `get_metadata`, and — per `opener::load_page_index` — if the returned +//! `ParquetMetaData` *already* carries a page index, the opener uses it and skips +//! the full, all-column load. So our reader's `get_metadata`: +//! +//! 1. loads footer-only metadata (shared metadata-cache hit — see +//! [`crate::indexed_table::parquet_bridge::load_parquet_metadata`]), then +//! 2. augments it with a page index scoped to the predicate columns via +//! [`crate::cache::page_index::load_scoped_page_index`] +//! (real `ColumnIndex` for predicate columns, real `OffsetIndex` for all +//! columns), and +//! 3. returns that augmented metadata. +//! +//! The scoped `(file, predicate-columns)` cache is shared with the indexed path, +//! so repeated queries reuse the decoded index across both scan paths. +//! +//! # Why all row groups (no RG scoping here) +//! +//! `ScopedPageIndexOptimizer` only swaps the reader factory; DataFusion still +//! selects which row groups to scan via its OWN RG-statistics pruning, and its +//! page-pruner + reader then dereference `column_index[rg][col]` / +//! `offset_index[rg][col]` for *its* chosen RGs — a set independent of anything +//! we could compute here. Leaving a placeholder entry on an RG DataFusion still +//! touches panics (`page_row_counts.first().unwrap()` on an empty `OffsetIndex`), +//! and its page-index gate is per-FILE (both indexes must be `Some`), so a +//! partial page index would lie to it. So the page index is built for ALL row +//! groups, column-scoped only — heap stays bounded because the heavy +//! `ColumnIndex` is scoped to predicate columns and only the cheap all-column +//! `OffsetIndex` spans every RG (and that is required for correctness at read +//! time anyway). +//! +//! # Fallback +//! +//! If there are no predicate columns, or scoped augmentation fails for a file +//! (no page index, decode/IO error), `get_metadata` returns the footer-only +//! metadata and the opener loads the page index on demand exactly as today — +//! correct, just without the scoping benefit for that file. Never a wrong result. + +use std::sync::Arc; + +use arrow::datatypes::SchemaRef; +use datafusion::datasource::physical_plan::parquet::{ + ParquetFileMetrics, ParquetFileReaderFactory, +}; +use datafusion::execution::cache::cache_manager::FileMetadataCache; +use datafusion::parquet::arrow::arrow_reader::ArrowReaderOptions; +use datafusion::parquet::arrow::async_reader::AsyncFileReader; +use datafusion::parquet::errors::ParquetError; +use datafusion::parquet::file::metadata::ParquetMetaData; +use datafusion::physical_expr::PhysicalExpr; +use datafusion::physical_plan::metrics::ExecutionPlanMetricsSet; +use datafusion_datasource::PartitionedFile; +use futures::future::BoxFuture; +use futures::FutureExt; +use object_store::{ObjectStore, ObjectStoreExt}; +use prost::bytes::Bytes; + +use crate::cache::page_index::{ + load_scoped_page_index_cols, resolve_predicate_parquet_columns_pair, +}; +use crate::indexed_table::parquet_bridge::load_parquet_metadata_with_meta; + +/// A [`ParquetFileReaderFactory`] that, on `get_metadata`, returns metadata whose +/// page index is scoped to the query's predicate columns. Data reads go straight +/// to the object store. +/// +/// Carries predicate column *names* + the file schema rather than pre-resolved +/// parquet indices: the reader resolves names → parquet leaf indices per file via +/// the same `resolve_predicate_parquet_columns` the indexed path uses, robust to +/// schema evolution across files (a column absent from one file is just skipped). +#[derive(Debug)] +pub struct ScopedPageIndexReaderFactory { + store: Arc, + metadata_cache: Arc, + /// File-column names referenced by the query predicate. Empty means "no + /// scoping" — `get_metadata` returns footer-only and the opener loads the + /// page index on demand as usual. + predicate_column_names: Arc>, + /// File-column names this scan PROJECTS (reads). Used to scope the + /// OffsetIndex to `predicate ∪ projection` instead of all columns. Empty = + /// fall back to all-column offsets (old behavior). + projection_column_names: Arc>, + /// The physical predicate (if any). Retained in the constructor signature for + /// parity with the indexed path, but intentionally NOT used for RG scoping + /// here (see module docs). + #[allow(dead_code)] + predicate: Option>, + /// File schema (no partition columns), for per-file column resolution. + file_schema: SchemaRef, +} + +impl ScopedPageIndexReaderFactory { + pub fn new( + store: Arc, + metadata_cache: Arc, + predicate_column_names: Vec, + projection_column_names: Vec, + predicate: Option>, + file_schema: SchemaRef, + ) -> Self { + Self { + store, + metadata_cache, + predicate_column_names: Arc::new(predicate_column_names), + projection_column_names: Arc::new(projection_column_names), + predicate, + file_schema, + } + } +} + +impl ParquetFileReaderFactory for ScopedPageIndexReaderFactory { + fn create_reader( + &self, + partition_index: usize, + file: PartitionedFile, + _metadata_size_hint: Option, + metrics: &ExecutionPlanMetricsSet, + ) -> datafusion::common::Result> { + let file_metrics = + ParquetFileMetrics::new(partition_index, file.object_meta.location.as_ref(), metrics); + Ok(Box::new(ScopedPageIndexReader { + store: Arc::clone(&self.store), + metadata_cache: Arc::clone(&self.metadata_cache), + predicate_column_names: Arc::clone(&self.predicate_column_names), + projection_column_names: Arc::clone(&self.projection_column_names), + file_schema: Arc::clone(&self.file_schema), + location: file.object_meta.location.clone(), + // Carry the listing's ObjectMeta so `get_metadata` resolves the footer + // from cache without a per-read `head()` syscall (see + // `load_parquet_metadata_with_meta`). + object_meta: file.object_meta.clone(), + metrics: file_metrics, + })) + } +} + +struct ScopedPageIndexReader { + store: Arc, + metadata_cache: Arc, + predicate_column_names: Arc>, + projection_column_names: Arc>, + file_schema: SchemaRef, + location: object_store::path::Path, + /// Listing-snapshot metadata (size + last_modified) for `location`. Used to + /// resolve footer metadata from cache without a `head()` syscall per read. + object_meta: object_store::ObjectMeta, + metrics: ParquetFileMetrics, +} + +impl AsyncFileReader for ScopedPageIndexReader { + fn get_bytes( + &mut self, + range: std::ops::Range, + ) -> BoxFuture<'_, datafusion::parquet::errors::Result> { + self.metrics + .bytes_scanned + .add((range.end - range.start) as usize); + let store = Arc::clone(&self.store); + let location = self.location.clone(); + // IO-runtime dispatch is handled by the store wrapper around the + // registered store, so a plain `.await` already runs on the IO runtime. + async move { + store + .get_range(&location, range) + .await + .map_err(|e| ParquetError::External(Box::new(e))) + } + .boxed() + } + + fn get_byte_ranges( + &mut self, + ranges: Vec>, + ) -> BoxFuture<'_, datafusion::parquet::errors::Result>> { + let total: u64 = ranges.iter().map(|r| r.end - r.start).sum(); + self.metrics.bytes_scanned.add(total as usize); + let store = Arc::clone(&self.store); + let location = self.location.clone(); + async move { + store + .get_ranges(&location, &ranges) + .await + .map_err(|e| ParquetError::External(Box::new(e))) + } + .boxed() + } + + fn get_metadata( + &mut self, + _options: Option<&ArrowReaderOptions>, + ) -> BoxFuture<'_, datafusion::parquet::errors::Result>> { + let store = Arc::clone(&self.store); + let metadata_cache = Arc::clone(&self.metadata_cache); + let predicate_names = Arc::clone(&self.predicate_column_names); + let projection_names = Arc::clone(&self.projection_column_names); + let file_schema = Arc::clone(&self.file_schema); + let location = self.location.clone(); + let object_meta = self.object_meta.clone(); + async move { + // 1. Footer-only metadata (shared metadata-cache hit if pre-seeded). + // Use the listing's ObjectMeta rather than a `head()` — the cache + // validity check only needs size + last_modified, both already known. + let (_schema, _size, footer) = load_parquet_metadata_with_meta( + Arc::clone(&store), + &location, + object_meta, + Arc::clone(&metadata_cache), + ) + .await + .map_err(|e| ParquetError::General(format!("footer metadata {location}: {e}")))?; + + // 2. Resolve predicate + projection names → parquet leaf indices, then + // augment with a column-scoped page index. Gated on either being + // non-empty: a projection-only query still needs a scoped OffsetIndex. + if !predicate_names.is_empty() || !projection_names.is_empty() { + let (parquet_cols, offset_cols) = resolve_predicate_parquet_columns_pair( + &file_schema, + &footer, + &predicate_names, + &projection_names, + ); + if let Some(augmented) = load_scoped_page_index_cols( + &store, + &location, + &footer, + &parquet_cols, + &offset_cols, + ) + .await + { + return Ok(augmented); + } + } + + Ok(footer) + } + .boxed() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use arrow::array::{Int32Array, RecordBatch}; + use arrow::datatypes::{DataType, Field, Schema}; + use datafusion::parquet::arrow::ArrowWriter; + use datafusion::parquet::file::page_index::column_index::ColumnIndexMetaData; + use datafusion::parquet::file::properties::{EnabledStatistics, WriterProperties}; + use datafusion::physical_plan::metrics::ExecutionPlanMetricsSet; + use object_store::memory::InMemory; + use object_store::path::Path as ObjPath; + use object_store::{ObjectStore, ObjectStoreExt, PutPayload}; + + // Shared crate-wide guard so all users of the one process-global scoped cache + // mutually exclude. + use crate::cache::page_index::SCOPED_CACHE_TEST_GUARD as SCOPED_TEST_GUARD; + + /// Two int columns (`price`, `qty`), one row group, four 8-row data pages. + fn two_col_parquet() -> (Bytes, SchemaRef) { + let schema = Arc::new(Schema::new(vec![ + Field::new("price", DataType::Int32, false), + Field::new("qty", DataType::Int32, false), + ])); + let prices: Vec = (0..32).collect(); + let qtys: Vec = (100..132).collect(); + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from(prices)), + Arc::new(Int32Array::from(qtys)), + ], + ) + .unwrap(); + let props = WriterProperties::builder() + .set_max_row_group_size(32) + .set_data_page_row_count_limit(8) + .set_write_batch_size(8) + .set_statistics_enabled(EnabledStatistics::Page) + .build(); + let mut buf: Vec = Vec::new(); + let mut w = ArrowWriter::try_new(&mut buf, schema.clone(), Some(props)).unwrap(); + w.write(&batch).unwrap(); + w.close().unwrap(); + (Bytes::from(buf), schema) + } + + async fn stage(bytes: Bytes) -> (Arc, ObjPath, u64) { + let size = bytes.len() as u64; + let store: Arc = Arc::new(InMemory::new()); + let loc = ObjPath::from("data.parquet"); + store + .put(&loc, PutPayload::from_bytes(bytes)) + .await + .unwrap(); + (store, loc, size) + } + + fn fresh_cache() -> Arc { + Arc::new(crate::cache::MutexFileMetadataCache::new( + datafusion::execution::cache::DefaultFilesMetadataCache::new(64 * 1024 * 1024), + )) + } + + fn metrics() -> ExecutionPlanMetricsSet { + ExecutionPlanMetricsSet::new() + } + + /// The factory's reader must, on `get_metadata`, return metadata whose page + /// index is scoped to the predicate column (`price`) — real ColumnIndex for + /// `price`, NONE placeholder for `qty` — while keeping a REAL OffsetIndex for + /// BOTH columns. Also fills the shared scoped cache. + #[tokio::test] + async fn get_metadata_returns_scoped_page_index() { + let _g = SCOPED_TEST_GUARD.lock().unwrap(); + crate::cache::page_index::clear_scoped_cache_for_test(); + + let (bytes, schema) = two_col_parquet(); + let (store, loc, size) = stage(bytes).await; + let factory = ScopedPageIndexReaderFactory::new( + Arc::clone(&store), + fresh_cache(), + vec!["price".to_string()], + // Project both columns so the OffsetIndex is built for both (this test + // asserts a real OffsetIndex for every column). + vec!["price".to_string(), "qty".to_string()], + None, + schema, + ); + let pf = PartitionedFile::new(loc.as_ref().to_string(), size); + let m = metrics(); + let mut reader = factory.create_reader(0, pf, None, &m).unwrap(); + + let meta = reader.get_metadata(None).await.unwrap(); + let ci = meta + .column_index() + .expect("augmented metadata has column index"); + let oi = meta + .offset_index() + .expect("augmented metadata has offset index"); + assert!( + !matches!(ci[0][0], ColumnIndexMetaData::NONE), + "predicate col (price) must have a real ColumnIndex" + ); + assert!( + matches!(ci[0][1], ColumnIndexMetaData::NONE), + "non-predicate col (qty) ColumnIndex must be a NONE placeholder" + ); + assert!( + !oi[0][0].page_locations().is_empty() && !oi[0][1].page_locations().is_empty(), + "OffsetIndex must be real for every column" + ); + + let stats = crate::cache::page_index::scoped_cache_stats(); + assert!(stats.entries >= 1 && stats.misses >= 1 && stats.used_bytes > 0); + + crate::cache::page_index::clear_scoped_cache_for_test(); + } + + /// No predicate columns → no scoping happens: `get_metadata` returns the + /// footer load as-is and the scoped cache is never touched. + /// + /// Note: we deliberately do NOT assert the returned metadata has no page + /// index. Until the base metadata-cache strip lands (Step 1e), the shared + /// `load_parquet_metadata` still loads the full page index when a metadata + /// cache is present (DataFusion's `PageIndexPolicy::Optional`). The invariant + /// this reader guarantees with no predicate is "no scoping", i.e. the scoped + /// cache stays empty — which holds before and after 1e. + #[tokio::test] + async fn get_metadata_no_predicate_does_not_scope() { + let _g = SCOPED_TEST_GUARD.lock().unwrap(); + crate::cache::page_index::clear_scoped_cache_for_test(); + + let (bytes, schema) = two_col_parquet(); + let (store, loc, size) = stage(bytes).await; + let factory = ScopedPageIndexReaderFactory::new( + Arc::clone(&store), + fresh_cache(), + vec![], + vec![], + None, + schema, + ); + let pf = PartitionedFile::new(loc.as_ref().to_string(), size); + let m = metrics(); + let mut reader = factory.create_reader(0, pf, None, &m).unwrap(); + + let _meta = reader.get_metadata(None).await.unwrap(); + let stats = crate::cache::page_index::scoped_cache_stats(); + assert_eq!( + (stats.entries, stats.misses, stats.hits), + (0, 0, 0), + "no predicate → scoped cache must be untouched" + ); + + crate::cache::page_index::clear_scoped_cache_for_test(); + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/search_stats.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/search_stats.rs index 29338097a598f..e0bdb6f116f16 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/search_stats.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/search_stats.rs @@ -212,7 +212,10 @@ mod tests { inc_bitmap_tree_scan(); let after = snapshot(); assert_eq!(after.listing_table_scan - before.listing_table_scan, 1); - assert_eq!(after.single_collector_scan - before.single_collector_scan, 2); + assert_eq!( + after.single_collector_scan - before.single_collector_scan, + 2 + ); assert_eq!(after.bitmap_tree_scan - before.bitmap_tree_scan, 1); } @@ -222,11 +225,13 @@ mod tests { let metrics_set = ExecutionPlanMetricsSet::new(); let pm = PartitionMetrics::new(&metrics_set, 0); - pm.elapsed_compute.add_duration(std::time::Duration::from_millis(50)); + pm.elapsed_compute + .add_duration(std::time::Duration::from_millis(50)); pm.ffm_collector_calls.add(3); pm.row_groups_processed.add(2); pm.row_groups_skipped.add(1); - pm.prefetch_wait_time.add_duration(std::time::Duration::from_millis(10)); + pm.prefetch_wait_time + .add_duration(std::time::Duration::from_millis(10)); pm.prefetch_wait_count.add(2); accumulate(&pm.into_stream_metrics(None)); diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/session_context.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/session_context.rs index bfae9428738bf..af4bd4254a102 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/session_context.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/session_context.rs @@ -13,7 +13,6 @@ use std::sync::Arc; -use native_bridge_common::log_debug; use datafusion::{ common::DataFusionError, datasource::file_format::parquet::ParquetFormat, @@ -24,14 +23,18 @@ use datafusion::{ execution::memory_pool::MemoryPool, execution::runtime_env::RuntimeEnvBuilder, execution::SessionStateBuilder, + physical_plan::ExecutionPlan, prelude::*, }; use log::error; +use native_bridge_common::log_debug; use object_store::ObjectMeta; use crate::api::{DataFusionRuntime, ShardView}; +use crate::cache::page_index; use crate::datafusion_query_config::DatafusionQueryConfig; use crate::query_tracker::QueryTrackingContext; +use crate::scoped_index_optimizer::ScopedPageIndexOptimizer; /// Opaque handle holding a configured SessionContext between FFM calls. pub struct SessionContextHandle { @@ -56,10 +59,14 @@ pub struct SessionContextHandle { /// Per-query tuning knobs (batch size, partitions, filter strategies, etc.) pub query_config: DatafusionQueryConfig, /// IO runtime handle for bloom filter reads and other async I/O dispatched - /// from CPU executor threads (where the IO_RUNTIME thread-local may not be set). + /// from CPU executor threads. pub io_handle: tokio::runtime::Handle, /// Aggregate execution mode for distributed partial/final stripping. pub(crate) aggregate_mode: crate::agg_mode::Mode, + /// True when the shard Substrait fragment contains a FetchRel (Sort+Limit = TopK). + /// Detected once in `create_session_context` from plan_bytes and reused in + /// `prepare_partial_plan` to apply PartialReduce for CSS correctness. + pub(crate) has_topk: bool, /// Pre-prepared physical plan (set by prepare_partial_plan / prepare_final_plan). pub(crate) prepared_plan: Option>, /// Phantom reservation holding pool capacity for untracked memory. @@ -90,7 +97,9 @@ pub(crate) fn widen_schema_from_plan( inferred: &arrow::datatypes::SchemaRef, ) -> arrow::datatypes::SchemaRef { use datafusion_substrait::extensions::Extensions; - use datafusion_substrait::logical_plan::consumer::{from_substrait_named_struct, DefaultSubstraitConsumer}; + use datafusion_substrait::logical_plan::consumer::{ + from_substrait_named_struct, DefaultSubstraitConsumer, + }; if plan_bytes.is_empty() { return Arc::clone(inferred); @@ -105,8 +114,11 @@ pub(crate) fn widen_schema_from_plan( }; // Cheap gate: if inferred already has every base_schema column, skip. - let have: std::collections::HashSet<&str> = - inferred.fields().iter().map(|f| f.name().as_str()).collect(); + let have: std::collections::HashSet<&str> = inferred + .fields() + .iter() + .map(|f| f.name().as_str()) + .collect(); if base_schema.names.iter().all(|n| have.contains(n.as_str())) { return Arc::clone(inferred); } @@ -123,7 +135,12 @@ pub(crate) fn widen_schema_from_plan( }; let expected = df_schema.as_arrow().clone(); - let force_view = ctx.copied_config().options().execution.parquet.schema_force_view_types; + let force_view = ctx + .copied_config() + .options() + .execution + .parquet + .schema_force_view_types; let expected = if force_view { datafusion::datasource::file_format::parquet::transform_schema_to_view(&expected) } else { @@ -134,7 +151,6 @@ pub(crate) fn widen_schema_from_plan( .unwrap_or_else(|| Arc::clone(inferred)) } - /// Creates a SessionContext with per-query RuntimeEnv and registers the default /// ListingTable provider for parquet scans. pub async unsafe fn create_session_context( @@ -150,7 +166,11 @@ pub async unsafe fn create_session_context( let shard_view = &*(shard_view_ptr as *const ShardView); let global_pool = runtime.runtime_env.memory_pool.clone(); - let query_context = QueryTrackingContext::new(context_id, global_pool.clone(), crate::query_tracker::QueryType::Shard); + let query_context = QueryTrackingContext::new( + context_id, + global_pool.clone(), + crate::query_tracker::QueryType::Shard, + ); let query_memory_pool = query_context .memory_pool() .map(|p| p as Arc); @@ -164,7 +184,8 @@ pub async unsafe fn create_session_context( CachedFileList::new(shard_view.object_metas.as_ref().clone()), ); - let mut runtime_env_builder = crate::query_executor::query_runtime_env_builder(runtime, list_file_cache); + let mut runtime_env_builder = + crate::query_executor::query_runtime_env_builder(runtime, list_file_cache); if let Some(pool) = query_memory_pool { runtime_env_builder = runtime_env_builder.with_memory_pool(pool); @@ -185,26 +206,45 @@ pub async unsafe fn create_session_context( // Acquire memory budget from cached parquet metadata (zero I/O). // On cache miss (first query for this shard), skip — subsequent queries benefit. - let phantom_reservation = try_acquire_budget( - runtime, &global_pool, &shard_view, &query_config, - ); - let effective_partitions = phantom_reservation.as_ref() + let phantom_reservation = try_acquire_budget(runtime, &global_pool, &shard_view, &query_config); + let effective_partitions = phantom_reservation + .as_ref() .map(|b| b.target_partitions) .unwrap_or(query_config.target_partitions); - let effective_batch_size = phantom_reservation.as_ref() + let effective_batch_size = phantom_reservation + .as_ref() .map(|b| b.batch_size) .unwrap_or(query_config.batch_size); let phantom = phantom_reservation.map(|b| b.phantom_reservation); let mut config = SessionConfig::new(); - config.options_mut().execution.parquet.pushdown_filters = query_config.parquet_pushdown_filters; + // Detect TopK once from the Substrait bytes: a FetchRel (Sort+Limit) in a partial-agg + // fragment means OpenSearchTopKRewriter fired. Stored on the handle so prepare_partial_plan + // can apply PartialReduce without re-scanning the physical plan. + let has_topk = has_partial_aggregate && substrait_has_fetch_rel(plan_bytes); + config.options_mut().execution.parquet.pushdown_filters = + query_config.listing_table_pushdown_filters; + // Disable DataFusion's adaptive skip-partial-aggregation when TopK is active. + // If DF abandons partial agg midstream, the partial state sent to the coordinator is + // incomplete — TopK sees wrong group counts and produces incorrect results. + if has_topk { + config + .options_mut() + .execution + .skip_partial_aggregation_probe_ratio_threshold = 1.0; + } config.options_mut().execution.target_partitions = effective_partitions; config.options_mut().execution.batch_size = effective_batch_size; // When the index has `index.sort.field`, ask DataFusion to use the sort-aware // file-group partitioner so `output_ordering` can propagate from the scan. if !shard_view.sort_fields.is_empty() { - config.options_mut().execution.split_file_groups_by_statistics = true; + config + .options_mut() + .execution + .split_file_groups_by_statistics = true; } + // LC session config is applied only when LC is actually engaged (inside + // parquet_bridge.rs), not globally. let mut state_builder = SessionStateBuilder::new() .with_config(config) @@ -216,17 +256,23 @@ pub async unsafe fn create_session_context( datafusion::physical_optimizer::optimizer::PhysicalOptimizer::new().rules }); - // For ListingTable query strategy: - // 1. Add ProjectRowIdAnalyzer (logical) — ensures __row_id__ survives pruning. - // 2. Add ProjectRowIdOptimizer (physical) — computes __row_id__ + row_base. - if query_config.query_strategy == crate::datafusion_query_config::QueryStrategy::ListingTable { - state_builder = state_builder - .with_analyzer_rule( - Arc::new(crate::project_row_id_analyzer::ProjectRowIdAnalyzer::new()) - ) - .with_physical_optimizer_rule( - Arc::new(crate::project_row_id_optimizer::ProjectRowIdOptimizer) - ); + // Install the scoped page-index reader factory on every parquet scan. + // Also, this SHOULD be the last optimizer to see all projections / predicates + if page_index::is_scoped_page_index_enabled() { + state_builder = + state_builder.with_physical_optimizer_rule(Arc::new(ScopedPageIndexOptimizer::new( + Arc::clone(&shard_view.store), + runtime.runtime_env.cache_manager.get_file_metadata_cache(), + ))); + } + // Only the physical optimizer (LocalModeLiquidCacheOptimizer) is applied. + // It wraps ParquetSource with LiquidParquetSource for decoded-batch caching. + // The LineageOptimizer (logical) is excluded — it adds O(nodes*exprs) + // planning overhead that causes 2x regression on string-heavy queries. + if crate::liquid_cache::LiquidOnlyRuntime::is_enabled_globally() { + if let Some(ref optimizer) = runtime.liquid_cache_optimizer { + state_builder = state_builder.with_physical_optimizer_rule(optimizer.clone()); + } } let state = state_builder.build(); @@ -253,7 +299,9 @@ pub async unsafe fn create_session_context( .with_collect_stat(true) .with_target_partitions(effective_partitions); - if let Some(sort_exprs) = build_file_sort_order(&shard_view.sort_fields, &shard_view.sort_orders) { + if let Some(sort_exprs) = + build_file_sort_order(&shard_view.sort_fields, &shard_view.sort_orders) + { listing_options = listing_options.with_file_sort_order(vec![sort_exprs]); } @@ -270,6 +318,26 @@ pub async unsafe fn create_session_context( table_name.to_string() }; + // Pre-warm the metadata cache footer-only before infer_schema fires. + // infer_schema calls DFParquetMetadata::fetch_metadata with PageIndexPolicy::Optional + // on a cache miss — fetching full page index bytes. By pre-warming here with + // PageIndexPolicy::Skip via load_parquet_metadata, every infer_schema call becomes + // a cache hit and never touches the page index bytes. + // Cache key is meta.location (Path) — same key infer_schema uses. + // Empty shard: loop is a no-op; infer_schema is also skipped below. + { + let metadata_cache = runtime.runtime_env.cache_manager.get_file_metadata_cache(); + for meta in shard_view.object_metas.as_ref() { + let _ = crate::indexed_table::parquet_bridge::load_parquet_metadata_with_meta( + Arc::clone(&shard_view.store), + &meta.location, + meta.clone(), + Arc::clone(&metadata_cache), + ) + .await; + } + } + // Empty shard: skip infer_schema (errors on zero files); widen_schema_from_plan // below populates columns from the substrait base_schema. let inferred: arrow::datatypes::SchemaRef = if shard_view.object_metas.is_empty() { @@ -323,20 +391,21 @@ pub async unsafe fn create_session_context( .with_cache(stats_cache), ); - ctx.register_table(register_name.as_str(), provider).map_err(|e| { - error!( - "create_session_context: failed to register table '{}': {}", - register_name, e - ); - e - })?; + ctx.register_table(register_name.as_str(), provider) + .map_err(|e| { + error!( + "create_session_context: failed to register table '{}': {}", + register_name, e + ); + e + })?; log_debug!( "create_session_context: registered table '{}' with file_sort_order_keys={}", register_name, shard_view.sort_fields.len() ); - error!( + log_debug!( "create_session_context: successfully registered table '{}', table_name_len={}", table_name, table_name.len() @@ -355,6 +424,7 @@ pub async unsafe fn create_session_context( query_config, io_handle: tokio::runtime::Handle::current(), aggregate_mode: crate::agg_mode::Mode::Default, + has_topk, prepared_plan: None, phantom_reservation: phantom, }; @@ -386,7 +456,16 @@ pub async unsafe fn create_session_context_indexed( query_config: DatafusionQueryConfig, plan_bytes: &[u8], ) -> Result { - let ptr = create_session_context(runtime_ptr, shard_view_ptr, table_name, context_id, has_partial_aggregate, query_config, plan_bytes).await?; + let ptr = create_session_context( + runtime_ptr, + shard_view_ptr, + table_name, + context_id, + has_partial_aggregate, + query_config, + plan_bytes, + ) + .await?; // Augment with indexed config. The delegation marker UDFs (index_filter, delegation_possible) // are now registered for every session by udf::register_all (via create_session_context above); @@ -425,13 +504,18 @@ pub async fn prepare_partial_plan( let logical_plan = from_substrait_plan(&handle.ctx.state(), &plan).await?; let dataframe = handle.ctx.execute_logical_plan(logical_plan).await?; let physical_plan = dataframe.create_physical_plan().await?; + // Strip first on the raw physical plan so `force_aggregate_mode(Partial)` can find the // Final/Partial pair without a RelabelExec wrapper at the root pre-empting the walk. // Then derive `target_schema` and wrap with RelabelExec from the stripped plan's actual // output (state-suffixed Binary for HLL Partial vs. Int64 cardinality for Final.evaluate) // — otherwise RelabelExec would carry the pre-strip type tag (e.g. Int64) and fail with // "non-bit-compatible types: Binary → Int64" when wrapping the stripped Partial. - let stripped = crate::agg_mode::apply_aggregate_mode(physical_plan, crate::agg_mode::Mode::Partial)?; + let stripped = crate::agg_mode::apply_aggregate_mode( + physical_plan, + crate::agg_mode::Mode::Partial, + handle.has_topk, + )?; let target_schema = crate::schema_coerce::coerce_inferred_schema(stripped.schema()); let stripped = crate::relabel_exec::wrap_if_relabel_needed(stripped, target_schema)?; @@ -439,6 +523,61 @@ pub async fn prepare_partial_plan( Ok(()) } +/// Returns true if the Substrait plan bytes contain a FetchRel (Sort+Limit node). +/// A FetchRel in a shard fragment means `OpenSearchTopKRewriter` inserted a per-shard +/// Sort+Limit — TopK is active. Used in `create_session_context` to detect TopK before +/// the DataFusion physical plan is built, so the result can be stored on the handle and +/// reused in `prepare_partial_plan` without re-scanning the physical plan. +/// +/// Single-shard (SINGLE aggregate mode) never has `has_partial_aggregate=true` so this +/// function is only called for multi-shard partial-aggregate fragments. +/// +/// # Upgrade path note +/// This detection avoids adding a new boolean field to the Java→Rust FFI surface +/// (which would break wire compatibility with older nodes during rolling upgrades — +/// old coordinators serialising `PartialAggregateInstructionNode` without the field +/// would be misread by new data nodes). The Substrait plan bytes are already part of +/// the existing wire contract and do not change format. +/// +/// TODO: Once AnalyticsCore supports a versioned flag/hint mechanism, replace this +/// Substrait scan with an explicit flag passed through the instruction pipeline. +/// That would be cleaner and avoid re-parsing the plan bytes, but requires a +/// backward-compatible flag delivery path that does not exist today. +fn substrait_has_fetch_rel(plan_bytes: &[u8]) -> bool { + use prost::Message; + use substrait::proto::rel::RelType; + + fn rel_has_fetch(rel: &substrait::proto::Rel) -> bool { + match rel.rel_type.as_ref() { + Some(RelType::Fetch(f)) => f.count_mode.is_some(), + Some(RelType::Sort(s)) => s.input.as_ref().map_or(false, |r| rel_has_fetch(r)), + Some(RelType::Project(p)) => p.input.as_ref().map_or(false, |r| rel_has_fetch(r)), + Some(RelType::Filter(f)) => f.input.as_ref().map_or(false, |r| rel_has_fetch(r)), + Some(RelType::Aggregate(a)) => a.input.as_ref().map_or(false, |r| rel_has_fetch(r)), + // TODO: enumerate remaining rel types explicitly and panic on unknown ones. + Some(other) => { + native_bridge_common::log_debug!( + "substrait_has_fetch_rel: {:?} — no TopK fetch", + std::mem::discriminant(other) + ); + false + } + None => false, + } + } + + let Ok(plan) = substrait::proto::Plan::decode(plan_bytes) else { + return false; + }; + plan.relations.iter().any(|pr| match pr.rel_type.as_ref() { + Some(substrait::proto::plan_rel::RelType::Root(rr)) => { + rr.input.as_ref().map_or(false, |r| rel_has_fetch(r)) + } + Some(substrait::proto::plan_rel::RelType::Rel(r)) => rel_has_fetch(r), + None => false, + }) +} + /// Attempt to acquire a memory budget using cached parquet metadata. /// Returns None on cache miss or if the budget system is not configured. fn try_acquire_budget( @@ -447,20 +586,25 @@ fn try_acquire_budget( shard_view: &ShardView, config: &DatafusionQueryConfig, ) -> Option { - use datafusion::execution::cache::CacheAccessor; use datafusion::datasource::physical_plan::parquet::metadata::CachedParquetMetaData; + use datafusion::execution::cache::CacheAccessor; use parquet::arrow::parquet_to_arrow_schema; let first_meta = shard_view.object_metas.first()?; let cache = runtime.runtime_env.cache_manager.get_file_metadata_cache(); let cached = cache.get(&first_meta.location)?; - let cached_parquet = cached.file_metadata.as_any().downcast_ref::()?; + let cached_parquet = cached + .file_metadata + .as_any() + .downcast_ref::()?; let parquet_meta = cached_parquet.parquet_metadata(); let schema = parquet_to_arrow_schema( parquet_meta.file_metadata().schema_descr(), parquet_meta.file_metadata().key_value_metadata(), - ).ok().map(Arc::new)?; + ) + .ok() + .map(Arc::new)?; crate::query_budget::acquire_budget_from_metadata( pool, @@ -468,7 +612,8 @@ fn try_acquire_budget( parquet_meta, config.target_partitions, config.batch_size, - ).ok() + ) + .ok() } /// Build a per-file sort-order declaration for `ListingOptions::with_file_sort_order`. @@ -540,9 +685,7 @@ mod tests { #[tokio::test] async fn test_widen_schema_noop_when_plan_empty() { let ctx = SessionContext::new(); - let schema = Arc::new(Schema::new(vec![ - Field::new("a", DataType::Int64, true), - ])); + let schema = Arc::new(Schema::new(vec![Field::new("a", DataType::Int64, true)])); let result = widen_schema_from_plan(&ctx, &[], "t", &schema); assert_eq!(result.fields().len(), 1); assert_eq!(result.field(0).name(), "a"); @@ -562,11 +705,16 @@ mod tests { vec![Arc::new(Int64Array::from(vec![1i64]))], ) .expect("batch"); - let table = MemTable::try_new(Arc::clone(®istered_schema), vec![vec![batch]]).expect("memtable"); + let table = + MemTable::try_new(Arc::clone(®istered_schema), vec![vec![batch]]).expect("memtable"); ctx.register_table("t", Arc::new(table)).expect("register"); // Build a substrait plan with a Read rel pointing at "t" — base_schema.names = ["a"]. - let logical = ctx.sql("SELECT a FROM t").await.expect("sql").into_unoptimized_plan(); + let logical = ctx + .sql("SELECT a FROM t") + .await + .expect("sql") + .into_unoptimized_plan(); let plan = to_substrait_plan(&logical, &ctx.state()).expect("substrait plan"); let mut plan_bytes = Vec::new(); plan.encode(&mut plan_bytes).expect("encode"); @@ -578,7 +726,10 @@ mod tests { ])); let result = widen_schema_from_plan(&ctx, &plan_bytes, "t", &inferred); // Must return inferred unchanged (Arc::clone, so pointer-equal). - assert!(Arc::ptr_eq(&result, &inferred), "subset gate must short-circuit to inferred"); + assert!( + Arc::ptr_eq(&result, &inferred), + "subset gate must short-circuit to inferred" + ); } /// Empty-shard case: a shard with zero parquet files yields an empty inferred schema, but the @@ -593,9 +744,14 @@ mod tests { Field::new("a", DataType::Int64, true), Field::new("b", DataType::Utf8, true), ])); - let table = MemTable::try_new(Arc::clone(®istered_schema), vec![vec![]]).expect("memtable"); + let table = + MemTable::try_new(Arc::clone(®istered_schema), vec![vec![]]).expect("memtable"); ctx.register_table("t", Arc::new(table)).expect("register"); - let logical = ctx.sql("SELECT a, b FROM t").await.expect("sql").into_unoptimized_plan(); + let logical = ctx + .sql("SELECT a, b FROM t") + .await + .expect("sql") + .into_unoptimized_plan(); let plan = to_substrait_plan(&logical, &ctx.state()).expect("substrait plan"); let mut plan_bytes = Vec::new(); plan.encode(&mut plan_bytes).expect("encode"); @@ -604,7 +760,11 @@ mod tests { let inferred = Arc::new(Schema::empty()); let result = widen_schema_from_plan(&ctx, &plan_bytes, "t", &inferred); - assert_eq!(result.fields().len(), 2, "all base_schema columns must be appended"); + assert_eq!( + result.fields().len(), + 2, + "all base_schema columns must be appended" + ); for name in ["a", "b"] { let f = result.field_with_name(name).expect("column present"); assert!(f.is_nullable(), "appended column {name} must be nullable"); @@ -617,7 +777,9 @@ mod tests { .with_config(SessionConfig::new()) .with_runtime_env(Arc::new(runtime_env)) .with_default_features() - .with_physical_optimizer_rules(crate::agg_mode::physical_optimizer_rules_without_combine()) + .with_physical_optimizer_rules( + crate::agg_mode::physical_optimizer_rules_without_combine(), + ) .build(); let ctx = SessionContext::new_with_state(state); @@ -641,7 +803,8 @@ mod tests { let table_path = datafusion::datasource::listing::ListingTableUrl::parse("file:///tmp") .expect("table_path"); let global_pool = ctx.runtime_env().memory_pool.clone(); - let query_context = QueryTrackingContext::new(0, global_pool, crate::query_tracker::QueryType::Shard); + let query_context = + QueryTrackingContext::new(0, global_pool, crate::query_tracker::QueryType::Shard); let handle = SessionContextHandle { ctx, @@ -656,6 +819,7 @@ mod tests { query_config: crate::datafusion_query_config::DatafusionQueryConfig::test_default(), io_handle: tokio::runtime::Handle::current(), aggregate_mode: Mode::Default, + has_topk: false, prepared_plan: None, phantom_reservation: None, }; @@ -696,7 +860,12 @@ mod tests { use datafusion::execution::cache::file_statistics_cache::DefaultFileStatisticsCache; use datafusion::parquet::arrow::ArrowWriter; - fn write_parquet(dir: &std::path::Path, name: &str, schema: SchemaRef, cols: Vec>) { + fn write_parquet( + dir: &std::path::Path, + name: &str, + schema: SchemaRef, + cols: Vec>, + ) { let file = std::fs::File::create(dir.join(name)).unwrap(); let batch = RecordBatch::try_new(Arc::clone(&schema), cols).unwrap(); let mut writer = ArrowWriter::try_new(file, schema, None).unwrap(); @@ -711,15 +880,24 @@ mod tests { Field::new("a", DataType::Int64, true), Field::new("b", DataType::Utf8, true), ])); - write_parquet(dir.path(), "narrow.parquet", Arc::clone(&narrow), vec![Arc::new(Int64Array::from(vec![1i64]))]); + write_parquet( + dir.path(), + "narrow.parquet", + Arc::clone(&narrow), + vec![Arc::new(Int64Array::from(vec![1i64]))], + ); write_parquet( dir.path(), "wide.parquet", Arc::clone(&wide), - vec![Arc::new(Int64Array::from(vec![2i64])), Arc::new(StringArray::from(vec!["x"]))], + vec![ + Arc::new(Int64Array::from(vec![2i64])), + Arc::new(StringArray::from(vec!["x"])), + ], ); - let table_url = ListingTableUrl::parse(format!("file://{}", dir.path().to_str().unwrap())).unwrap(); + let table_url = + ListingTableUrl::parse(format!("file://{}", dir.path().to_str().unwrap())).unwrap(); // Shared, runtime-global stats cache — the crux of the bug. let stats_cache = Arc::new(DefaultFileStatisticsCache::default()); @@ -732,9 +910,19 @@ mod tests { let narrow_cfg = ListingTableConfig::new(table_url.clone()) .with_listing_options(narrow_opts) .with_schema(Arc::clone(&narrow)); - let narrow_tbl = Arc::new(ListingTable::try_new(narrow_cfg).unwrap().with_cache(Some(stats_cache.clone()))); + let narrow_tbl = Arc::new( + ListingTable::try_new(narrow_cfg) + .unwrap() + .with_cache(Some(stats_cache.clone())), + ); ctx.register_table("t_narrow", narrow_tbl).unwrap(); - let _ = ctx.sql("SELECT a FROM t_narrow").await.unwrap().collect().await.unwrap(); + let _ = ctx + .sql("SELECT a FROM t_narrow") + .await + .unwrap() + .collect() + .await + .unwrap(); // 2. WIDENED read reusing the SAME cache. This is what create_session_context does after // widen_schema_from_plan. The fix sets collect_stat(false) because the schema was widened; @@ -745,7 +933,11 @@ mod tests { let widened_cfg = ListingTableConfig::new(table_url) .with_listing_options(widened_opts) .with_schema(Arc::clone(&wide)); - let widened_tbl = Arc::new(ListingTable::try_new(widened_cfg).unwrap().with_cache(Some(stats_cache))); + let widened_tbl = Arc::new( + ListingTable::try_new(widened_cfg) + .unwrap() + .with_cache(Some(stats_cache)), + ); ctx.register_table("t_wide", widened_tbl).unwrap(); let rows = ctx @@ -765,13 +957,17 @@ mod tests { /// another shard's store and failed with "No such file or directory". #[tokio::test] async fn test_per_query_object_store_registry_is_isolated() { - use datafusion::execution::object_store::{DefaultObjectStoreRegistry, ObjectStoreRegistry}; + use datafusion::execution::object_store::{ + DefaultObjectStoreRegistry, ObjectStoreRegistry, + }; use object_store::memory::InMemory; use object_store::ObjectStore; use url::Url; // Simulates the single shared global runtime_env (DataFusionRuntime.runtime_env). - let shared = RuntimeEnvBuilder::new().build().expect("shared runtime env"); + let shared = RuntimeEnvBuilder::new() + .build() + .expect("shared runtime env"); // Two per-query runtime envs, each derived the way create_session_context derives them: // from the shared env but with a fresh object-store registry. @@ -803,8 +999,193 @@ mod tests { // Each env resolves to its OWN store, and the two are independent: registering in one env // does not leak into the other. - assert!(Arc::ptr_eq(&got_a, &store_a), "env_a must resolve to its own store"); - assert!(Arc::ptr_eq(&got_b, &store_b), "env_b must resolve to its own store"); - assert!(!Arc::ptr_eq(&got_a, &got_b), "per-query stores must be independent across queries"); + assert!( + Arc::ptr_eq(&got_a, &store_a), + "env_a must resolve to its own store" + ); + assert!( + Arc::ptr_eq(&got_b, &store_b), + "env_b must resolve to its own store" + ); + assert!( + !Arc::ptr_eq(&got_a, &got_b), + "per-query stores must be independent across queries" + ); + } + + #[test] + fn test_skip_partial_agg_disabled_when_has_topk() { + // skip_partial must be disabled (1.0) when TopK is active — if DF abandons partial + // agg midstream the partial state is incomplete and TopK sees wrong group counts. + let mut config = SessionConfig::new(); + let has_topk = true; + if has_topk { + config + .options_mut() + .execution + .skip_partial_aggregation_probe_ratio_threshold = 1.0; + } + assert_eq!( + config + .options() + .execution + .skip_partial_aggregation_probe_ratio_threshold, + 1.0, + "skip_partial must be disabled (1.0) when TopK is active" + ); + } + + #[test] + fn test_skip_partial_agg_default_when_no_topk() { + // When has_topk=false, skip_partial retains DF default (0.8) — no perf regression + // for non-TopK multi-shard queries. + let config = SessionConfig::new(); + assert_eq!( + config + .options() + .execution + .skip_partial_aggregation_probe_ratio_threshold, + 0.8, + "non-TopK queries must retain DF default threshold" + ); + } + + #[test] + fn test_substrait_has_fetch_rel_empty() { + assert!(!substrait_has_fetch_rel(&[]), "empty bytes → false"); + } + + #[test] + fn test_substrait_has_fetch_rel_with_fetch() { + use prost::Message; + use substrait::proto::expression::literal::LiteralType; + use substrait::proto::expression::{Literal, RexType}; + use substrait::proto::rel::RelType; + use substrait::proto::{ + fetch_rel, plan_rel, Expression, FetchRel, Plan, PlanRel, Rel, SortRel, + }; + + // Build: FetchRel(count=10) wrapping SortRel — same as what DataFusion Substrait + // producer emits for Sort(fetch=10, ...) from OpenSearchTopKRewriter. + let sort_rel = Box::new(Rel { + rel_type: Some(RelType::Sort(Box::new(SortRel { + common: None, + input: None, + sorts: vec![], + advanced_extension: None, + }))), + }); + let fetch_rel = Box::new(Rel { + rel_type: Some(RelType::Fetch(Box::new(FetchRel { + common: None, + input: Some(sort_rel), + offset_mode: None, + count_mode: Some(fetch_rel::CountMode::CountExpr(Box::new(Expression { + rex_type: Some(RexType::Literal(Literal { + nullable: false, + type_variation_reference: 0, + literal_type: Some(LiteralType::I64(10)), + })), + }))), + advanced_extension: None, + }))), + }); + let plan = Plan { + relations: vec![PlanRel { + rel_type: Some(plan_rel::RelType::Rel(*fetch_rel)), + }], + ..Default::default() + }; + let bytes = plan.encode_to_vec(); + assert!(substrait_has_fetch_rel(&bytes), "FetchRel(count=10) → true"); + } + + #[test] + fn test_substrait_has_fetch_rel_with_fetch_no_count_mode() { + use prost::Message; + use substrait::proto::rel::RelType; + use substrait::proto::{plan_rel, FetchRel, Plan, PlanRel, Rel}; + + // FetchRel exists but count_mode is None — not a real limit, should not trigger TopK. + let fetch_rel = Box::new(Rel { + rel_type: Some(RelType::Fetch(Box::new(FetchRel { + common: None, + input: None, + offset_mode: None, + count_mode: None, + advanced_extension: None, + }))), + }); + let plan = Plan { + relations: vec![PlanRel { + rel_type: Some(plan_rel::RelType::Rel(*fetch_rel)), + }], + ..Default::default() + }; + let bytes = plan.encode_to_vec(); + assert!( + !substrait_has_fetch_rel(&bytes), + "FetchRel without count_mode → false" + ); + } + + #[test] + fn test_substrait_has_fetch_rel_without_fetch() { + use prost::Message; + use substrait::proto::rel::RelType; + use substrait::proto::{plan_rel, Plan, PlanRel, Rel, SortRel}; + + // Sort without fetch → no FetchRel → false + let sort_rel = Box::new(Rel { + rel_type: Some(RelType::Sort(Box::new(SortRel { + common: None, + input: None, + sorts: vec![], + advanced_extension: None, + }))), + }); + let plan = Plan { + relations: vec![PlanRel { + rel_type: Some(plan_rel::RelType::Rel(*sort_rel)), + }], + ..Default::default() + }; + let bytes = plan.encode_to_vec(); + assert!( + !substrait_has_fetch_rel(&bytes), + "SortRel without FetchRel → false" + ); + } + + /// A Join rel at the root — exercises the `Some(other)` arm that logs and returns false. + /// Shard fragments never have Join above a TopK FetchRel, so this correctly returns false. + #[test] + fn test_substrait_has_fetch_rel_join_returns_false() { + use prost::Message; + use substrait::proto::rel::RelType; + use substrait::proto::{plan_rel, JoinRel, Plan, PlanRel, Rel}; + + let join_rel = Box::new(Rel { + rel_type: Some(RelType::Join(Box::new(JoinRel { + common: None, + left: None, + right: None, + r#type: 0, + expression: None, + post_join_filter: None, + advanced_extension: None, + }))), + }); + let plan = Plan { + relations: vec![PlanRel { + rel_type: Some(plan_rel::RelType::Rel(*join_rel)), + }], + ..Default::default() + }; + let bytes = plan.encode_to_vec(); + assert!( + !substrait_has_fetch_rel(&bytes), + "Join rel → false (no TopK in shard fragment with Join)" + ); } } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/shard_table_provider.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/shard_table_provider.rs index 02f541edc2227..17c7f8d3d9227 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/shard_table_provider.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/shard_table_provider.rs @@ -13,8 +13,8 @@ use std::sync::Arc; use async_trait::async_trait; use datafusion::arrow::datatypes::{DataType, Field, Schema, SchemaRef}; use datafusion::catalog::{Session, TableProvider}; -use datafusion::common::{Result, ScalarValue, Statistics}; use datafusion::common::stats::Precision; +use datafusion::common::{Result, ScalarValue, Statistics}; use datafusion::datasource::physical_plan::ParquetSource; use datafusion::datasource::source::DataSourceExec; use datafusion::datasource::TableType; @@ -44,7 +44,10 @@ impl ShardTableProvider { let mut fields: Vec> = config.file_schema.fields().iter().cloned().collect(); fields.push(Arc::new(Field::new("row_base", DataType::Int64, true))); let table_schema = Arc::new(Schema::new(fields)); - Self { table_schema, config } + Self { + table_schema, + config, + } } } @@ -58,8 +61,12 @@ impl std::fmt::Debug for ShardTableProvider { #[async_trait] impl TableProvider for ShardTableProvider { - fn schema(&self) -> SchemaRef { self.table_schema.clone() } - fn table_type(&self) -> TableType { TableType::Base } + fn schema(&self) -> SchemaRef { + self.table_schema.clone() + } + fn table_type(&self) -> TableType { + TableType::Base + } fn supports_filters_pushdown( &self, @@ -82,7 +89,10 @@ impl TableProvider for ShardTableProvider { "ShardTableProvider: files not ordered by row_base — ProjectRowIdOptimizer would compute wrong global IDs" ); let num_file_cols = self.config.file_schema.fields().len(); - let partitioned_files: Vec = self.config.files.iter() + let partitioned_files: Vec = self + .config + .files + .iter() .map(|file_info| { let mut pf = PartitionedFile::from(file_info.object_meta.clone()); pf.partition_values = vec![ScalarValue::Int64(Some(file_info.row_base))]; @@ -111,11 +121,9 @@ impl TableProvider for ShardTableProvider { let parquet_source = ParquetSource::new(table_schema); - let mut builder = FileScanConfigBuilder::new( - self.config.store_url.clone(), - Arc::new(parquet_source), - ) - .with_file_groups(file_groups); + let mut builder = + FileScanConfigBuilder::new(self.config.store_url.clone(), Arc::new(parquet_source)) + .with_file_groups(file_groups); // Always include the row_base partition column (index = num_file_cols) // so ProjectRowIdOptimizer can compute __row_id__ + row_base. @@ -142,5 +150,7 @@ impl TableProvider for ShardTableProvider { Ok(DataSourceExec::from_data_source(file_scan_config)) } - fn statistics(&self) -> Option { None } + fn statistics(&self) -> Option { + None + } } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/spill_e2e_test.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/spill_e2e_test.rs new file mode 100644 index 0000000000000..61e311c804459 --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/spill_e2e_test.rs @@ -0,0 +1,204 @@ +/* + * 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. + */ + +//! End-to-end disk-spill test for the analytics-backend-datafusion engine. +//! +//! Proves the full spill flow works through the *production* memory pool +//! ([`crate::memory::DynamicLimitPool`]) and a real on-disk [`DiskManager`]: +//! a high-cardinality `GROUP BY` over enough rows, run under a memory pool too +//! small to hold the hash table, must spill to disk **and still return correct +//! results**. +//! +//! How spill is observed: DataFusion records `SpillCount` / `SpilledBytes` +//! metrics on the operators that spill (the same signal DataFusion's own +//! aggregate spill tests assert on). We walk the executed physical plan tree +//! and sum those metrics; a non-zero `spill_count` is proof spill happened. +//! +//! Determinism: `DynamicLimitPool` only consults process RSS / the jemalloc +//! override when its limit is >= 16 MiB (see `memory_guard::should_override` +//! and the `skip_for_small_pools` unit test). With a sub-16 MiB limit it is a +//! pure size-bounded pool, so spill is forced deterministically by data volume +//! alone — no dependency on the test host's live RSS. + +#[cfg(test)] +mod tests { + use std::path::Path; + use std::sync::Arc; + + use arrow::datatypes::{DataType, Field, Schema}; + use arrow_array::{Int64Array, RecordBatch, StringArray}; + use datafusion::execution::context::SessionContext; + use datafusion::execution::disk_manager::{DiskManagerBuilder, DiskManagerMode}; + use datafusion::execution::memory_pool::MemoryPool; + use datafusion::execution::runtime_env::RuntimeEnvBuilder; + use datafusion::execution::SessionStateBuilder; + use datafusion::physical_plan::{collect, ExecutionPlan}; + use datafusion::prelude::{ParquetReadOptions, SessionConfig}; + use parquet::arrow::ArrowWriter; + use tempfile::TempDir; + + use crate::memory::DynamicLimitPool; + + /// Rows of test data. Large enough that the GROUP BY hash table dwarfs the + /// tiny memory pool, guaranteeing a spill. + const NUM_ROWS: usize = 500_000; + const NUM_FILES: usize = 4; + /// Pool limit kept below 16 MiB so the pool is a pure size-bounded pool + /// (no RSS gate / jemalloc override) — see module docs. 12 MiB is small + /// enough that the high-cardinality hash table can't stay resident (forcing + /// spill) but large enough that the spill machinery's own working reservations + /// can complete rather than erroring with ResourcesExhausted. + const POOL_LIMIT_BYTES: usize = 12 * 1024 * 1024; + + /// Writes `NUM_ROWS` rows across `NUM_FILES` parquet files. `id` is unique + /// (the high-cardinality GROUP BY key); `host` is low-cardinality filler so + /// each row carries a bit of payload. + fn write_parquet_data(dir: &Path) { + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int64, false), + Field::new("host", DataType::Utf8, true), + ])); + + let rows_per_file = NUM_ROWS / NUM_FILES; + for file_idx in 0..NUM_FILES { + let start = file_idx * rows_per_file; + let ids: Vec = (start..start + rows_per_file).map(|i| i as i64).collect(); + let hosts: Vec = ids.iter().map(|i| format!("host-{:04}", i % 100)).collect(); + + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int64Array::from(ids)), + Arc::new(StringArray::from( + hosts.iter().map(|s| s.as_str()).collect::>(), + )), + ], + ) + .unwrap(); + + let path = dir.join(format!("data_{}.parquet", file_idx)); + let file = std::fs::File::create(&path).unwrap(); + let mut writer = ArrowWriter::try_new(file, schema.clone(), None).unwrap(); + writer.write(&batch).unwrap(); + writer.close().unwrap(); + } + } + + /// Recursively sums the `SpillCount` metric across every operator in the + /// executed plan tree (the spilling operator may be nested under others). + fn total_spill_count(plan: &dyn ExecutionPlan) -> usize { + let here = plan.metrics().and_then(|m| m.spill_count()).unwrap_or(0); + here + plan + .children() + .iter() + .map(|child| total_spill_count(child.as_ref())) + .sum::() + } + + /// Same as [`total_spill_count`] for the `SpilledBytes` metric. + fn total_spilled_bytes(plan: &dyn ExecutionPlan) -> usize { + let here = plan.metrics().and_then(|m| m.spilled_bytes()).unwrap_or(0); + here + plan + .children() + .iter() + .map(|child| total_spilled_bytes(child.as_ref())) + .sum::() + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn group_by_spills_to_disk_and_returns_correct_results() { + let data_dir = TempDir::new().unwrap(); + write_parquet_data(data_dir.path()); + + let spill_dir = TempDir::new().unwrap(); + + // Build a RuntimeEnv that mirrors production: the real DynamicLimitPool + // (deliberately tiny) plus an on-disk DiskManager rooted at spill_dir. + let pool: Arc = Arc::new(DynamicLimitPool::new(POOL_LIMIT_BYTES).0); + let runtime_env = RuntimeEnvBuilder::new() + .with_memory_pool(pool) + .with_disk_manager_builder(DiskManagerBuilder::default().with_mode( + DiskManagerMode::Directories(vec![spill_dir.path().to_path_buf()]), + )) + .build() + .unwrap(); + assert!( + runtime_env.disk_manager.tmp_files_enabled(), + "precondition: spill must be enabled for this test to be meaningful" + ); + + // Small batches + a couple of partitions keep per-operator memory low so + // the aggregator is pushed to spill rather than failing outright. + let mut config = SessionConfig::new(); + config.options_mut().execution.target_partitions = 2; + config.options_mut().execution.batch_size = 1024; + + let state = SessionStateBuilder::new() + .with_config(config) + .with_runtime_env(Arc::new(runtime_env)) + .with_default_features() + .build(); + let ctx = SessionContext::new_with_state(state); + + ctx.register_parquet( + "t", + data_dir.path().to_str().unwrap(), + ParquetReadOptions::default(), + ) + .await + .unwrap(); + + // High-cardinality GROUP BY: one group per (unique) id. The hash table + // holds ~NUM_ROWS groups — far more than the 4 MiB pool can keep + // resident — so the grouped aggregation must spill. + let df = ctx + .sql("SELECT id, COUNT(*) AS c FROM t GROUP BY id") + .await + .unwrap(); + let plan = df.create_physical_plan().await.unwrap(); + let batches = collect(plan.clone(), ctx.task_ctx()).await.unwrap(); + + // ── Correctness: spilling must not change the answer ── + let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum(); + assert_eq!( + total_rows, NUM_ROWS, + "every unique id must yield exactly one group" + ); + + // Every group's COUNT(*) must be 1 (ids are unique), so the counts sum + // to NUM_ROWS. This catches data loss/duplication across the spill cycle. + let mut count_sum: i64 = 0; + for b in &batches { + let counts = b + .column(1) + .as_any() + .downcast_ref::() + .expect("COUNT(*) column is Int64"); + for i in 0..counts.len() { + assert_eq!(counts.value(i), 1, "each unique id appears exactly once"); + count_sum += counts.value(i); + } + } + assert_eq!(count_sum as usize, NUM_ROWS, "counts must sum to row count"); + + // ── Spill actually happened ── + let spill_count = total_spill_count(plan.as_ref()); + let spilled_bytes = total_spilled_bytes(plan.as_ref()); + assert!( + spill_count > 0, + "expected the grouped aggregation to spill to disk under a {}MiB pool, \ + but SpillCount across the plan was 0", + POOL_LIMIT_BYTES / (1024 * 1024) + ); + assert!( + spilled_bytes > 0, + "spill happened (spill_count={}) but SpilledBytes was 0", + spill_count + ); + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/stats.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/stats.rs index 2fcec9ce4d86e..3ed5ac95bd9da 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/stats.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/stats.rs @@ -5,7 +5,7 @@ //! Stats packing helpers for the FFM `df_stats()` function. //! //! Packs Tokio runtime metrics and per-operation task monitor metrics -//! into a `#[repr(C)]` `DfStatsBuffer` struct (600 bytes) for efficient +//! into a `#[repr(C)]` `DfStatsBuffer` struct (680 bytes) for efficient //! transfer across the FFM boundary. //! //! ## Struct layout @@ -20,7 +20,7 @@ //! | `plan_setup` | `TaskMonitorRepr` | 5 × i64 | //! | `fragment_executor_gate` | `PartitionGateRepr` | 8 × i64 | //! | `adaptive_budget` | `AdaptiveBudgetRepr` | 2 × i64 | -//! | `cache_stats` | `CacheStatsRepr` | 10 × i64 (2 × 5: metadata + statistics caches) | +//! | `cache_stats` | `CacheStatsRepr` | 20 × i64 (4 × 5: metadata, statistics, column_index, offset_index) | //! | `search_stats` | `SearchStatsRepr` | 17 × i64 | use tokio::runtime::Handle; @@ -92,6 +92,8 @@ pub struct CacheGroupRepr { pub struct CacheStatsRepr { pub metadata_cache: CacheGroupRepr, pub statistics_cache: CacheGroupRepr, + pub column_index_cache: CacheGroupRepr, + pub offset_index_cache: CacheGroupRepr, } impl Default for CacheGroupRepr { @@ -111,6 +113,8 @@ impl Default for CacheStatsRepr { Self { metadata_cache: CacheGroupRepr::default(), statistics_cache: CacheGroupRepr::default(), + column_index_cache: CacheGroupRepr::default(), + offset_index_cache: CacheGroupRepr::default(), } } } @@ -156,19 +160,19 @@ pub struct AdaptiveBudgetRepr { pub rejections: i64, } -const _: () = assert!(std::mem::size_of::() == 9 * 8); -const _: () = assert!(std::mem::size_of::() == 5 * 8); -const _: () = assert!(std::mem::size_of::() == 8 * 8); -const _: () = assert!(std::mem::size_of::() == 2 * 8); -const _: () = assert!(std::mem::size_of::() == 5 * 8); -const _: () = assert!(std::mem::size_of::() == 10 * 8); -const _: () = assert!(std::mem::size_of::() == 17 * 8); -const _: () = assert!(std::mem::size_of::() == 75 * 8); +const _: () = assert!(size_of::() == 9 * 8); +const _: () = assert!(size_of::() == 5 * 8); +const _: () = assert!(size_of::() == 8 * 8); +const _: () = assert!(size_of::() == 2 * 8); +const _: () = assert!(size_of::() == 5 * 8); +const _: () = assert!(size_of::() == 20 * 8); +const _: () = assert!(size_of::() == 17 * 8); +const _: () = assert!(size_of::() == 85 * 8); pub mod layout { use super::*; - pub const BUFFER_BYTE_SIZE: usize = std::mem::size_of::(); - const _: () = assert!(BUFFER_BYTE_SIZE == 600); + pub const BUFFER_BYTE_SIZE: usize = size_of::(); + const _: () = assert!(BUFFER_BYTE_SIZE == 680); } /// Snapshot a `RuntimeMonitor` and return a populated `RuntimeMetricsRepr`. @@ -216,7 +220,6 @@ pub fn pack_runtime_metrics(_monitor: &RuntimeMonitor, handle: &Handle) -> Runti } } - /// Snapshot a `TaskMonitor` and return a populated `TaskMonitorRepr`. /// /// | Field | Source | @@ -289,6 +292,9 @@ pub fn pack_cache_stats(mgr: &CustomCacheManager) -> CacheStatsRepr { .get_memory_consumed_by_type(crate::cache::CACHE_TYPE_STATS) .unwrap_or(0) as i64; + let ci = crate::cache::page_index::column_index_cache_stats(); + let oi = crate::cache::page_index::offset_index_cache_stats(); + CacheStatsRepr { metadata_cache: CacheGroupRepr { hit_count: mgr.metadata_cache_hit_count() as i64, @@ -304,6 +310,20 @@ pub fn pack_cache_stats(mgr: &CustomCacheManager) -> CacheStatsRepr { memory_bytes: statistics_memory, size_limit_bytes: mgr.statistics_cache_size_limit() as i64, }, + column_index_cache: CacheGroupRepr { + hit_count: ci.hits as i64, + miss_count: ci.misses as i64, + entry_count: ci.entries as i64, + memory_bytes: ci.used_bytes as i64, + size_limit_bytes: ci.limit_bytes as i64, + }, + offset_index_cache: CacheGroupRepr { + hit_count: oi.hits as i64, + miss_count: oi.misses as i64, + entry_count: oi.entries as i64, + memory_bytes: oi.used_bytes as i64, + size_limit_bytes: oi.limit_bytes as i64, + }, } } @@ -311,8 +331,8 @@ pub fn pack_cache_stats(mgr: &CustomCacheManager) -> CacheStatsRepr { mod tests { use super::*; use crate::task_monitors::{ - coordinator_reduce_monitor, query_execution_monitor, - stream_next_monitor, plan_setup_monitor, + coordinator_reduce_monitor, plan_setup_monitor, query_execution_monitor, + stream_next_monitor, }; #[test] @@ -364,7 +384,11 @@ mod tests { assert_eq!(result, 42); let tm = pack_task_monitor(monitor); - assert!(tm.total_poll_duration_ms >= 0, "total_poll_duration should be >= 0, got {}", tm.total_poll_duration_ms); + assert!( + tm.total_poll_duration_ms >= 0, + "total_poll_duration should be >= 0, got {}", + tm.total_poll_duration_ms + ); } #[tokio::test] @@ -396,12 +420,24 @@ mod tests { search_stats: crate::search_stats::snapshot(), }; - assert_eq!(layout::BUFFER_BYTE_SIZE, 600); - assert!(buf.io_runtime.workers_count > 0, "IO runtime workers_count should be > 0, got {}", buf.io_runtime.workers_count); - assert!(buf.fragment_executor_gate.max_permits > 0, "fragment_executor_gate max_permits should be > 0, got {}", buf.fragment_executor_gate.max_permits); + assert_eq!(layout::BUFFER_BYTE_SIZE, 680); + assert!( + buf.io_runtime.workers_count > 0, + "IO runtime workers_count should be > 0, got {}", + buf.io_runtime.workers_count + ); + assert!( + buf.fragment_executor_gate.max_permits > 0, + "fragment_executor_gate max_permits should be > 0, got {}", + buf.fragment_executor_gate.max_permits + ); if mgr.cpu_monitor.is_some() { - assert!(buf.cpu_runtime.workers_count > 0, "CPU runtime workers_count should be > 0, got {}", buf.cpu_runtime.workers_count); + assert!( + buf.cpu_runtime.workers_count > 0, + "CPU runtime workers_count should be > 0, got {}", + buf.cpu_runtime.workers_count + ); } mgr.cpu_executor.shutdown(); @@ -411,9 +447,9 @@ mod tests { #[test] fn test_df_stats_buffer_too_small() { // Verify that the buffer size assertion holds - assert_eq!(std::mem::size_of::(), 600); - assert_eq!(layout::BUFFER_BYTE_SIZE, 600); - // A buffer smaller than 600 bytes should be rejected by df_stats. + assert_eq!(size_of::(), 680); + assert_eq!(layout::BUFFER_BYTE_SIZE, 680); + // A buffer smaller than 680 bytes should be rejected by df_stats. // We can't call df_stats directly without a runtime manager, // but we verify the constant is correct. assert!(layout::BUFFER_BYTE_SIZE > 0); @@ -437,8 +473,8 @@ mod tests { fn test_pack_cache_stats_reflects_underlying_counters() { use std::sync::Arc; - use datafusion::execution::cache::DefaultFilesMetadataCache; use datafusion::execution::cache::CacheAccessor; + use datafusion::execution::cache::DefaultFilesMetadataCache; use object_store::path::Path; use crate::cache::MutexFileMetadataCache; @@ -446,9 +482,9 @@ mod tests { use crate::eviction_policy::PolicyType; use crate::statistics_cache::CustomStatisticsCache; - let metadata_cache = Arc::new(MutexFileMetadataCache::new( - DefaultFilesMetadataCache::new(50 * 1024 * 1024), - )); + let metadata_cache = Arc::new(MutexFileMetadataCache::new(DefaultFilesMetadataCache::new( + 50 * 1024 * 1024, + ))); let stats_cache = Arc::new(CustomStatisticsCache::new( PolicyType::Lru, 10 * 1024 * 1024, @@ -461,7 +497,9 @@ mod tests { assert!(metadata_cache.get(&p).is_none()); assert!(stats_cache.get(&p).is_none()); } - assert!(metadata_cache.get(&Path::from("/test/missing/extra.parquet")).is_none()); + assert!(metadata_cache + .get(&Path::from("/test/missing/extra.parquet")) + .is_none()); let mut mgr = CustomCacheManager::new(); mgr.set_file_metadata_cache(metadata_cache); diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/task_monitors.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/task_monitors.rs index e4b661df276d7..bc82651720d5b 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/task_monitors.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/task_monitors.rs @@ -24,7 +24,15 @@ static QUERY_EXECUTION_MONITOR: Lazy = Lazy::new(TaskMonitor::new); static STREAM_NEXT_MONITOR: Lazy = Lazy::new(TaskMonitor::new); static PLAN_SETUP_MONITOR: Lazy = Lazy::new(TaskMonitor::new); -pub fn coordinator_reduce_monitor() -> &'static TaskMonitor { &COORDINATOR_REDUCE_MONITOR } -pub fn query_execution_monitor() -> &'static TaskMonitor { &QUERY_EXECUTION_MONITOR } -pub fn stream_next_monitor() -> &'static TaskMonitor { &STREAM_NEXT_MONITOR } -pub fn plan_setup_monitor() -> &'static TaskMonitor { &PLAN_SETUP_MONITOR } +pub fn coordinator_reduce_monitor() -> &'static TaskMonitor { + &COORDINATOR_REDUCE_MONITOR +} +pub fn query_execution_monitor() -> &'static TaskMonitor { + &QUERY_EXECUTION_MONITOR +} +pub fn stream_next_monitor() -> &'static TaskMonitor { + &STREAM_NEXT_MONITOR +} +pub fn plan_setup_monitor() -> &'static TaskMonitor { + &PLAN_SETUP_MONITOR +} diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/tiered_storage_integration_tests.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/tiered_storage_integration_tests.rs new file mode 100644 index 0000000000000..3208d81a4327f --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/tiered_storage_integration_tests.rs @@ -0,0 +1,2058 @@ +/* + * 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. + */ + +//! Integration tests: TieredObjectStore + TieredBlockCache with real Parquet files. +//! +//! Uses `#[test]` (not `#[tokio::test]`) because FoyerCache::new() internally +//! calls block_on() and panics if called inside an existing tokio runtime. + +use std::sync::Arc; + +use arrow::datatypes::{DataType, Field, Schema}; +use arrow_array::{Int64Array, RecordBatch, StringArray}; +use object_store::local::LocalFileSystem; +use object_store::path::Path; +use object_store::{ObjectStore, ObjectStoreExt}; +use parquet::arrow::ArrowWriter; +use parquet::file::properties::WriterProperties; +use tempfile::TempDir; + +use opensearch_block_cache::foyer::foyer_cache::FoyerCache; +use opensearch_block_cache::range_cache::range_cache_key; +use opensearch_block_cache::tiered_block_cache::TieredBlockCache; +use opensearch_block_cache::traits::BlockCache; + +use opensearch_tiered_storage::registry::traits::FileRegistry; +use opensearch_tiered_storage::registry::TieredStorageRegistry; +use opensearch_tiered_storage::tiered_object_store::TieredObjectStore; +use opensearch_tiered_storage::types::{FileLocation, TieredFileEntry}; + +// ── Helpers ───────────────────────────────────────────────────────────────────── + +const BLOCK_SIZE: usize = 1 * 1024 * 1024; +const DISK_BYTES: usize = 8 * 1024 * 1024; +const BUFFER_POOL: usize = 8 * 1024 * 1024; +const SUBMIT_QUEUE: usize = 8 * 1024 * 1024; + +fn create_tiered_cache( + data_dir: &std::path::Path, + meta_dir: &std::path::Path, +) -> Arc { + let data_cache = Arc::new(FoyerCache::new( + DISK_BYTES, + data_dir, + BLOCK_SIZE, + BUFFER_POOL, + SUBMIT_QUEUE, + "auto", + 0, + 0.0, + 0, + false, + )); + let metadata_cache = Arc::new(FoyerCache::new( + DISK_BYTES, + meta_dir, + BLOCK_SIZE, + BUFFER_POOL, + SUBMIT_QUEUE, + "auto", + 0, + 0.0, + 0, + false, + )); + Arc::new(TieredBlockCache::new(data_cache, metadata_cache)) +} + +fn create_store( + parquet_dir: &std::path::Path, + cache: Arc, + path_str: &str, + file_size: u64, +) -> Arc { + let local: Arc = + Arc::new(LocalFileSystem::new_with_prefix(parquet_dir).unwrap()); + let registry = Arc::new(TieredStorageRegistry::new()); + let store = TieredObjectStore::new(registry, local).with_cache(cache as Arc); + let store = Arc::new(store); + store.registry().register( + path_str, + TieredFileEntry::with_size(FileLocation::Local, None, file_size), + ); + store +} + +#[allow(deprecated)] +fn write_test_parquet(dir: &std::path::Path, filename: &str, num_row_groups: usize) -> u64 { + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int64, false), + Field::new("name", DataType::Utf8, false), + ])); + + let file_path = dir.join(filename); + let file = std::fs::File::create(&file_path).unwrap(); + let props = WriterProperties::builder() + .set_max_row_group_row_count(Some(100)) + .build(); + let mut writer = ArrowWriter::try_new(file, schema.clone(), Some(props)).unwrap(); + + for rg in 0..num_row_groups { + let offset = (rg * 100) as i64; + let ids: Vec = (offset..offset + 100).collect(); + let names: Vec = ids.iter().map(|i| format!("name_{}", i)).collect(); + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int64Array::from(ids)), + Arc::new(StringArray::from(names)), + ], + ) + .unwrap(); + writer.write(&batch).unwrap(); + } + + writer.close().unwrap(); + std::fs::metadata(&file_path).unwrap().len() +} + +/// Shared runtime for test async blocks (created lazily, not inside FoyerCache::new). +fn block_on(f: F) -> F::Output { + static RT: std::sync::OnceLock = std::sync::OnceLock::new(); + RT.get_or_init(|| tokio::runtime::Runtime::new().unwrap()) + .block_on(f) +} + +/// Compute the single global page index range matching parquet crate's `range_for_page_index()`. +/// +/// Folds ALL columns across ALL row groups into a single contiguous range +/// encompassing all column_index and offset_index data. Returns `None` if the +/// file has no page index metadata. +fn compute_global_page_index_range( + metadata: &parquet::file::metadata::ParquetMetaData, +) -> Option> { + metadata + .row_groups() + .iter() + .flat_map(|rg| rg.columns().iter()) + .fold(None::>, |acc, col| { + let acc = if let (Some(offset), Some(length)) = + (col.column_index_offset(), col.column_index_length()) + { + let start = offset as u64; + let end = start + length as u64; + match acc { + Some(a) => Some(a.start.min(start)..a.end.max(end)), + None => Some(start..end), + } + } else { + acc + }; + if let (Some(offset), Some(length)) = + (col.offset_index_offset(), col.offset_index_length()) + { + let start = offset as u64; + let end = start + length as u64; + match acc { + Some(a) => Some(a.start.min(start)..a.end.max(end)), + None => Some(start..end), + } + } else { + acc + } + }) +} + +/// Compute merged column index and offset index ranges per row group. +/// +/// Returns two optional ranges per RG: (column_index_range, offset_index_range). +fn compute_per_rg_page_index_ranges( + rg: &parquet::file::metadata::RowGroupMetaData, +) -> (Option>, Option>) { + let col_idx_range = rg + .columns() + .iter() + .fold(None::>, |acc, col| { + if let (Some(offset), Some(length)) = + (col.column_index_offset(), col.column_index_length()) + { + let start = offset as u64; + let end = start + length as u64; + match acc { + Some(a) => Some(a.start.min(start)..a.end.max(end)), + None => Some(start..end), + } + } else { + acc + } + }); + + let off_idx_range = rg + .columns() + .iter() + .fold(None::>, |acc, col| { + if let (Some(offset), Some(length)) = + (col.offset_index_offset(), col.offset_index_length()) + { + let start = offset as u64; + let end = start + length as u64; + match acc { + Some(a) => Some(a.start.min(start)..a.end.max(end)), + None => Some(start..end), + } + } else { + acc + } + }); + + (col_idx_range, off_idx_range) +} + +/// Set up a DataFusion session with the store registered and a ListingTable for the given file. +/// +/// Returns the SessionContext and the inferred schema. The table is registered as `table_name`. +async fn setup_df_session( + store: Arc, + file_path: &str, + table_name: &str, + schema: Option>, +) -> (datafusion::prelude::SessionContext, Arc) { + use datafusion::datasource::file_format::parquet::ParquetFormat; + use datafusion::datasource::listing::{ + ListingOptions, ListingTable, ListingTableConfig, ListingTableUrl, + }; + use datafusion::prelude::*; + + let ctx = SessionContext::new(); + let url = url::Url::parse("file://").unwrap(); + ctx.runtime_env() + .register_object_store(&url, store.clone() as Arc); + + let table_url = ListingTableUrl::parse(&format!("file:///{}", file_path)).unwrap(); + let format = Arc::new(ParquetFormat::default()); + let listing_options = ListingOptions::new(format).with_file_extension(".parquet"); + + let schema = match schema { + Some(s) => s, + None => listing_options + .infer_schema(&ctx.state(), &table_url) + .await + .unwrap(), + }; + + let config = ListingTableConfig::new(table_url) + .with_listing_options(listing_options) + .with_schema(schema.clone()); + let table = ListingTable::try_new(config).unwrap(); + ctx.register_table(table_name, Arc::new(table)).unwrap(); + + (ctx, schema) +} + +// ── Tests ─────────────────────────────────────────────────────────────────────── + +/// Helper: simulates warmup — reads bytes via local FS and puts into metadata cache. +fn warmup_metadata( + cache: &TieredBlockCache, + parquet_dir: &std::path::Path, + filename: &str, + start: u64, + end: u64, +) -> bytes::Bytes { + let file_path = parquet_dir.join(filename); + let file_bytes = std::fs::read(&file_path).unwrap(); + let range_bytes = bytes::Bytes::copy_from_slice(&file_bytes[start as usize..end as usize]); + let key = range_cache_key(filename, start, end); + cache.put_metadata(&key, range_bytes.clone()); + range_bytes +} + +#[test] +fn metadata_routed_to_metadata_cache_data_to_data_cache() { + let parquet_dir = TempDir::new().unwrap(); + let data_dir = TempDir::new().unwrap(); + let meta_dir = TempDir::new().unwrap(); + + let file_size = write_test_parquet(parquet_dir.path(), "test.parquet", 3); + let cache = create_tiered_cache(data_dir.path(), meta_dir.path()); + let store = create_store(parquet_dir.path(), cache.clone(), "test.parquet", file_size); + + let path = Path::from("test.parquet"); + let footer_start = file_size.saturating_sub(8 * 1024); + + // Warmup: explicitly put metadata into metadata cache + let footer = warmup_metadata( + &cache, + parquet_dir.path(), + "test.parquet", + footer_start, + file_size, + ); + + block_on(async { + // Metadata read via get_range → get_opts → cache probe HIT + let footer_from_cache = store + .get_range(&path, footer_start..file_size) + .await + .unwrap(); + assert_eq!(footer_from_cache, footer); + + // Confirm in metadata cache, NOT data cache + let footer_key = range_cache_key("test.parquet", footer_start, file_size); + assert!( + cache.metadata_cache().get(&footer_key).await.is_some(), + "footer must be in metadata cache" + ); + assert!( + cache.data_cache().get(&footer_key).await.is_none(), + "footer must NOT be in data cache" + ); + + // Data read (get_ranges) → data cache + let data = store.get_ranges(&path, &[0u64..4096]).await.unwrap(); + assert_eq!(data[0].len(), 4096); + + // Confirm data in data cache, NOT metadata cache + let data_key = range_cache_key("test.parquet", 0, 4096); + assert!( + cache.data_cache().get(&data_key).await.is_some(), + "data must be in data cache" + ); + assert!( + cache.metadata_cache().get(&data_key).await.is_none(), + "data must NOT be in metadata cache" + ); + }); +} + +#[test] +fn metadata_survives_restart_via_foyer_recovery() { + let parquet_dir = TempDir::new().unwrap(); + let data_dir = TempDir::new().unwrap(); + let meta_dir = TempDir::new().unwrap(); + + let file_size = write_test_parquet(parquet_dir.path(), "restart.parquet", 2); + let path = Path::from("restart.parquet"); + let footer_start = file_size.saturating_sub(8 * 1024); + + // Session 1: warmup puts metadata into metadata cache + let original_bytes = { + let cache = create_tiered_cache(data_dir.path(), meta_dir.path()); + warmup_metadata( + &cache, + parquet_dir.path(), + "restart.parquet", + footer_start, + file_size, + ) + }; + + // Session 2: new instances, same SSD dirs — Foyer recovers + { + let cache = create_tiered_cache(data_dir.path(), meta_dir.path()); + let store = create_store( + parquet_dir.path(), + cache.clone(), + "restart.parquet", + file_size, + ); + block_on(async { + // get_range → get_opts → cache probe → HIT (recovered from SSD) + let bytes = store + .get_range(&path, footer_start..file_size) + .await + .unwrap(); + assert_eq!(bytes, original_bytes, "metadata must survive restart"); + }); + } +} + +#[test] +fn evict_prefix_clears_both_caches_on_shard_delete() { + let parquet_dir = TempDir::new().unwrap(); + let data_dir = TempDir::new().unwrap(); + let meta_dir = TempDir::new().unwrap(); + + let file_size = write_test_parquet(parquet_dir.path(), "delete.parquet", 2); + let cache = create_tiered_cache(data_dir.path(), meta_dir.path()); + let store = create_store( + parquet_dir.path(), + cache.clone(), + "delete.parquet", + file_size, + ); + let path = Path::from("delete.parquet"); + let footer_start = file_size.saturating_sub(8 * 1024); + + // Warmup metadata + populate data + warmup_metadata( + &cache, + parquet_dir.path(), + "delete.parquet", + footer_start, + file_size, + ); + + block_on(async { + let _data = store.get_ranges(&path, &[0u64..4096]).await.unwrap(); + + let footer_key = range_cache_key("delete.parquet", footer_start, file_size); + let data_key = range_cache_key("delete.parquet", 0, 4096); + assert!(cache.metadata_cache().get(&footer_key).await.is_some()); + assert!(cache.data_cache().get(&data_key).await.is_some()); + + // Shard delete + store.evict_path("delete.parquet"); + + assert!( + cache.metadata_cache().get(&footer_key).await.is_none(), + "metadata must be evicted" + ); + assert!( + cache.data_cache().get(&data_key).await.is_none(), + "data must be evicted" + ); + }); +} + +/// Proves metadata is served from cache, not local FS: delete the local file +/// after warmup, then read via store — must succeed from cache. +#[test] +fn metadata_served_from_ssd_not_local_fs() { + let parquet_dir = TempDir::new().unwrap(); + let data_dir = TempDir::new().unwrap(); + let meta_dir = TempDir::new().unwrap(); + + let file_size = write_test_parquet(parquet_dir.path(), "ssd_only.parquet", 2); + let cache = create_tiered_cache(data_dir.path(), meta_dir.path()); + let store = create_store( + parquet_dir.path(), + cache.clone(), + "ssd_only.parquet", + file_size, + ); + let path = Path::from("ssd_only.parquet"); + let footer_start = file_size.saturating_sub(8 * 1024); + + // Warmup puts metadata into metadata cache + let original = warmup_metadata( + &cache, + parquet_dir.path(), + "ssd_only.parquet", + footer_start, + file_size, + ); + + // Delete local file — force subsequent reads to come from cache only + std::fs::remove_file(parquet_dir.path().join("ssd_only.parquet")).unwrap(); + + block_on(async { + // Read via store — local FS is gone, must succeed from metadata SSD cache + let from_cache = store + .get_range(&path, footer_start..file_size) + .await + .unwrap(); + assert_eq!( + from_cache, original, + "must serve from SSD cache after local deletion" + ); + }); +} + +/// Fill data cache beyond capacity, verify metadata is untouched. +#[test] +fn data_pressure_does_not_evict_metadata() { + let parquet_dir = TempDir::new().unwrap(); + let data_dir = TempDir::new().unwrap(); + let meta_dir = TempDir::new().unwrap(); + + let file_size = write_test_parquet(parquet_dir.path(), "pressure.parquet", 5); + + let data_cache = Arc::new(FoyerCache::new( + 2 * 1024 * 1024, + data_dir.path(), + BLOCK_SIZE, + BUFFER_POOL, + SUBMIT_QUEUE, + "auto", + 0, + 0.0, + 0, + false, + )); + let metadata_cache = Arc::new(FoyerCache::new( + DISK_BYTES, + meta_dir.path(), + BLOCK_SIZE, + BUFFER_POOL, + SUBMIT_QUEUE, + "auto", + 0, + 0.0, + 0, + false, + )); + let cache = Arc::new(TieredBlockCache::new(data_cache, metadata_cache)); + let store = create_store( + parquet_dir.path(), + cache.clone(), + "pressure.parquet", + file_size, + ); + let path = Path::from("pressure.parquet"); + let footer_start = file_size.saturating_sub(8 * 1024); + + // Warmup + let footer = warmup_metadata( + &cache, + parquet_dir.path(), + "pressure.parquet", + footer_start, + file_size, + ); + let footer_key = range_cache_key("pressure.parquet", footer_start, file_size); + + block_on(async { + // Fill data cache to trigger eviction + for i in 0..20 { + let start = (i * 1024) as u64; + let end = start + 102400; + if end <= file_size { + let _ = store.get_ranges(&path, &[start..end]).await; + } + } + + // Metadata untouched + assert!( + cache.metadata_cache().get(&footer_key).await.is_some(), + "metadata must survive data cache LRU pressure" + ); + let footer_after = store + .get_range(&path, footer_start..file_size) + .await + .unwrap(); + assert_eq!(footer_after, footer); + }); +} + +/// Suffix fetch resolves to correct absolute range and hits metadata cache. +#[test] +fn suffix_fetch_resolves_and_hits_cache() { + let parquet_dir = TempDir::new().unwrap(); + let data_dir = TempDir::new().unwrap(); + let meta_dir = TempDir::new().unwrap(); + + let file_size = write_test_parquet(parquet_dir.path(), "suffix.parquet", 2); + let cache = create_tiered_cache(data_dir.path(), meta_dir.path()); + let store = create_store( + parquet_dir.path(), + cache.clone(), + "suffix.parquet", + file_size, + ); + let path = Path::from("suffix.parquet"); + + let suffix_size = 4096u64; + let expected_start = file_size - suffix_size; + + // Warmup: put with absolute range key + warmup_metadata( + &cache, + parquet_dir.path(), + "suffix.parquet", + expected_start, + file_size, + ); + + block_on(async { + // Suffix fetch should resolve to same absolute key and hit cache + use object_store::GetOptions; + let opts = GetOptions { + range: Some(object_store::GetRange::Suffix(suffix_size)), + ..Default::default() + }; + let result = store.get_opts(&path, opts).await.unwrap(); + let suffix_bytes = result.bytes().await.unwrap(); + + // Must match what we put via absolute range + let bounded_bytes = store + .get_range(&path, expected_start..file_size) + .await + .unwrap(); + assert_eq!( + suffix_bytes, bounded_bytes, + "suffix fetch must resolve to same bytes as bounded range" + ); + }); +} + +/// Multiple concurrent reads of same metadata range — all succeed with same bytes. +#[test] +fn concurrent_metadata_reads_are_safe() { + let parquet_dir = TempDir::new().unwrap(); + let data_dir = TempDir::new().unwrap(); + let meta_dir = TempDir::new().unwrap(); + + let file_size = write_test_parquet(parquet_dir.path(), "concurrent.parquet", 2); + let cache = create_tiered_cache(data_dir.path(), meta_dir.path()); + let store = create_store( + parquet_dir.path(), + cache.clone(), + "concurrent.parquet", + file_size, + ); + let path = Path::from("concurrent.parquet"); + let footer_start = file_size.saturating_sub(8 * 1024); + + // Warmup + let expected = warmup_metadata( + &cache, + parquet_dir.path(), + "concurrent.parquet", + footer_start, + file_size, + ); + + block_on(async { + let mut handles = Vec::new(); + for _ in 0..10 { + let store = store.clone(); + let path = path.clone(); + handles.push(tokio::spawn(async move { + store + .get_range(&path, footer_start..file_size) + .await + .unwrap() + })); + } + + let results: Vec<_> = futures::future::join_all(handles) + .await + .into_iter() + .map(|r| r.unwrap()) + .collect(); + + for (i, result) in results.iter().enumerate() { + assert_eq!( + result, &expected, + "concurrent read {} must match warmup bytes", + i + ); + } + }); +} + +/// A range read via get_range (single) and get_ranges (multi with one element) +/// must NOT create duplicate cache entries — verify they use compatible paths. +#[test] +fn get_range_and_get_ranges_share_same_cache_key() { + let parquet_dir = TempDir::new().unwrap(); + let data_dir = TempDir::new().unwrap(); + let meta_dir = TempDir::new().unwrap(); + + let file_size = write_test_parquet(parquet_dir.path(), "keyshare.parquet", 2); + let cache = create_tiered_cache(data_dir.path(), meta_dir.path()); + let store = create_store( + parquet_dir.path(), + cache.clone(), + "keyshare.parquet", + file_size, + ); + let path = Path::from("keyshare.parquet"); + + block_on(async { + // Read first 4KB via get_ranges (data path → data_cache) + let via_ranges = store.get_ranges(&path, &[0u64..4096]).await.unwrap(); + + // Read same range via get_range (metadata path → metadata_cache) + let via_range = store.get_range(&path, 0u64..4096).await.unwrap(); + + // Both must return same bytes + assert_eq!( + via_ranges[0], via_range, + "get_range and get_ranges must return same bytes" + ); + + // The key is the same regardless of path + let key = range_cache_key("keyshare.parquet", 0, 4096); + + // get_ranges puts in data_cache, get_range puts in metadata_cache + // One or both should have it — important thing is bytes are correct + let in_data = cache.data_cache().get(&key).await; + let in_meta = cache.metadata_cache().get(&key).await; + assert!( + in_data.is_some() || in_meta.is_some(), + "range must be cached in at least one tier" + ); + }); +} + +/// When metadata cache is full, reads still succeed via local FS / S3. +/// Foyer may drop entries that exceed capacity (LRU hasn't run yet). +/// The system degrades gracefully — no panics, no errors. +#[test] +fn metadata_cache_full_reads_still_succeed_via_local_fs() { + let parquet_dir = TempDir::new().unwrap(); + let data_dir = TempDir::new().unwrap(); + let meta_dir = TempDir::new().unwrap(); + + // Write a large parquet file + let file_size = write_test_parquet(parquet_dir.path(), "breach.parquet", 10); + + // Tiny metadata cache (1MB disk, 1MB block) — will fill quickly + let data_cache = Arc::new(FoyerCache::new( + DISK_BYTES, + data_dir.path(), + BLOCK_SIZE, + BUFFER_POOL, + SUBMIT_QUEUE, + "auto", + 0, + 0.0, + 0, + false, + )); + let metadata_cache = Arc::new(FoyerCache::new( + 1 * 1024 * 1024, + meta_dir.path(), + BLOCK_SIZE, + BUFFER_POOL, + SUBMIT_QUEUE, + "auto", + 0, + 0.0, + 0, + false, + )); + let cache = Arc::new(TieredBlockCache::new(data_cache, metadata_cache)); + let store = create_store( + parquet_dir.path(), + cache.clone(), + "breach.parquet", + file_size, + ); + let path = Path::from("breach.parquet"); + + block_on(async { + // Read multiple ranges — even with small metadata cache, reads succeed + // (served from local FS since get_opts doesn't auto-populate) + let mut all_bytes = Vec::new(); + for i in 0..10 { + let start = (i * 1024) as u64; + let end = start + 4096; + if end <= file_size { + let bytes = store.get_range(&path, start..end).await.unwrap(); + all_bytes.push((start, end, bytes)); + } + } + + // All reads succeed — no panics, no errors regardless of cache state + assert!( + !all_bytes.is_empty(), + "reads must succeed regardless of metadata cache pressure" + ); + + // Repeated reads also succeed (from local FS — get_opts does not auto-populate cache) + for (start, end, original) in &all_bytes { + let bytes = store.get_range(&path, *start..*end).await.unwrap(); + assert_eq!( + &bytes, original, + "repeated read of {}..{} must return same bytes", + start, end + ); + } + }); +} + +/// DataFusion reads Parquet through the TieredObjectStore, executing a real SQL query. +#[test] +fn datafusion_query_through_tiered_store() { + let parquet_dir = TempDir::new().unwrap(); + let data_dir = TempDir::new().unwrap(); + let meta_dir = TempDir::new().unwrap(); + + let file_size = write_test_parquet(parquet_dir.path(), "query.parquet", 2); + let cache = create_tiered_cache(data_dir.path(), meta_dir.path()); + let store = create_store( + parquet_dir.path(), + cache.clone(), + "query.parquet", + file_size, + ); + + block_on(async { + let (ctx, _schema) = + setup_df_session(store.clone(), "query.parquet", "test_table", None).await; + + let df = ctx + .sql("SELECT id, name FROM test_table WHERE id < 5 ORDER BY id") + .await + .unwrap(); + let batches = df.collect().await.unwrap(); + + assert!(!batches.is_empty()); + let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum(); + assert_eq!(total_rows, 5, "WHERE id < 5 should return 5 rows"); + }); +} + +/// Warmup puts metadata via store.put_metadata(), then DataFusion query reads +/// metadata from metadata_cache (never-evict) and data from data_cache. +/// After warmup, the local file is deleted to prove all reads come from cache. +#[test] +fn warmup_put_metadata_then_datafusion_query_from_cache() { + let parquet_dir = TempDir::new().unwrap(); + let data_dir = TempDir::new().unwrap(); + let meta_dir = TempDir::new().unwrap(); + + let file_size = write_test_parquet(parquet_dir.path(), "warm.parquet", 2); + let cache = create_tiered_cache(data_dir.path(), meta_dir.path()); + let store = create_store(parquet_dir.path(), cache.clone(), "warm.parquet", file_size); + + block_on(async { + // ── Warmup: read metadata through store, then promote to metadata_cache ── + // First, let DataFusion do a full query to discover what ranges it needs. + // This populates data_cache with all metadata + data ranges. + let (ctx, schema) = setup_df_session(store.clone(), "warm.parquet", "t", None).await; + + let batches = ctx + .sql("SELECT id FROM t WHERE id < 5 ORDER BY id") + .await + .unwrap() + .collect() + .await + .unwrap(); + assert_eq!(batches.iter().map(|b| b.num_rows()).sum::(), 5); + + // Step 2: Now promote the footer range to metadata_cache. + let footer_start = file_size.saturating_sub(64 * 1024); + let footer_key = range_cache_key("warm.parquet", footer_start, file_size); + if let Some(footer_bytes) = cache.get(&footer_key).await { + store.put_metadata("warm.parquet", &[footer_start..file_size], &[footer_bytes]); + } + + // Verify metadata is now in metadata_cache + assert!( + cache.metadata_cache().get(&footer_key).await.is_some(), + "footer must be in metadata_cache after put_metadata" + ); + + // ── Delete local file — all subsequent reads must come from cache ──── + std::fs::remove_file(parquet_dir.path().join("warm.parquet")).unwrap(); + + // ── Query again: must succeed entirely from cache ──────────────────── + let (ctx2, _) = setup_df_session(store.clone(), "warm.parquet", "t", Some(schema)).await; + + let batches2 = ctx2 + .sql("SELECT id FROM t WHERE id < 5 ORDER BY id") + .await + .unwrap() + .collect() + .await + .unwrap(); + assert_eq!( + batches2.iter().map(|b| b.num_rows()).sum::(), + 5, + "query after file deletion must succeed from cache (metadata in metadata_cache)" + ); + }); +} + +/// Key alignment test: warmup via put_metadata, then DataFusion query hits cache. +/// +/// Strategy: +/// 1. Run DataFusion query (cold start) — all reads go to local FS and populate +/// data_cache via get_opts/get_ranges. Query succeeds. +/// 2. Delete local file. +/// 3. Run same query again — must succeed entirely from cache (data_cache populated +/// by step 1). This proves the keys produced by DataFusion's read path are the +/// same keys stored in the cache — key alignment is correct. +/// +/// This validates that get_opts (metadata path) and get_ranges (data path) produce +/// consistent, deterministic cache keys that are found on subsequent probes. +#[test] +fn datafusion_query_succeeds_from_cache_after_local_file_deleted() { + let parquet_dir = TempDir::new().unwrap(); + let data_dir = TempDir::new().unwrap(); + let meta_dir = TempDir::new().unwrap(); + + let file_size = write_test_parquet(parquet_dir.path(), "align.parquet", 2); + let cache = create_tiered_cache(data_dir.path(), meta_dir.path()); + let store = create_store( + parquet_dir.path(), + cache.clone(), + "align.parquet", + file_size, + ); + + block_on(async { + // ── Query 1: cold start, file on local FS ──────────────────────────── + let (ctx, schema) = setup_df_session(store.clone(), "align.parquet", "t", None).await; + + let batches = ctx + .sql("SELECT id FROM t WHERE id < 3 ORDER BY id") + .await + .unwrap() + .collect() + .await + .unwrap(); + let rows1: usize = batches.iter().map(|b| b.num_rows()).sum(); + assert_eq!(rows1, 3, "query 1 must return 3 rows"); + + // ── Delete local file — cache is the only source now ───────────────── + std::fs::remove_file(parquet_dir.path().join("align.parquet")).unwrap(); + + // ── Query 2: same query, file gone — must succeed from cache ───────── + let (ctx2, _) = setup_df_session(store.clone(), "align.parquet", "t", Some(schema)).await; + + let batches2 = ctx2 + .sql("SELECT id FROM t WHERE id < 3 ORDER BY id") + .await + .unwrap() + .collect() + .await + .unwrap(); + let rows2: usize = batches2.iter().map(|b| b.num_rows()).sum(); + assert_eq!( + rows2, 3, + "query 2 (file deleted) must succeed from cache — proves key alignment" + ); + }); +} + +// ── New correctness-risk integration tests ───────────────────────────────────── + +/// Write a Parquet file with page-level statistics (column index + offset index) +/// enabled. Returns the file size. +#[allow(deprecated)] +fn write_page_indexed_parquet( + dir: &std::path::Path, + filename: &str, + num_row_groups: usize, + num_columns: usize, +) -> u64 { + let mut fields: Vec = Vec::new(); + for i in 0..num_columns { + fields.push(Field::new(format!("col_{}", i), DataType::Int64, false)); + } + let schema = Arc::new(Schema::new(fields)); + + let file_path = dir.join(filename); + let file = std::fs::File::create(&file_path).unwrap(); + + // Enable page-level statistics by setting write_page_index(true) + // and small max_row_group_size so we get multiple row groups. + let props = WriterProperties::builder() + .set_max_row_group_row_count(Some(100)) + .set_column_index_truncate_length(Some(64)) + .set_write_batch_size(50) // force multiple pages per row group + .build(); + + let mut writer = ArrowWriter::try_new(file, schema.clone(), Some(props)).unwrap(); + + for rg in 0..num_row_groups { + let offset = (rg * 100) as i64; + let columns: Vec> = (0..num_columns) + .map(|c| { + let vals: Vec = (offset..offset + 100) + .map(|v| v + (c as i64 * 1000)) + .collect(); + Arc::new(Int64Array::from(vals)) as Arc + }) + .collect(); + let batch = RecordBatch::try_new(schema.clone(), columns).unwrap(); + writer.write(&batch).unwrap(); + } + + writer.close().unwrap(); + std::fs::metadata(&file_path).unwrap().len() +} + +/// **Test 1**: Page index key alignment — warmup computes page index ranges from +/// footer metadata and stores them in metadata Foyer. At query time, the same +/// ranges must be found in the cache (key alignment). +/// +/// Strategy: warmup puts page index ranges into metadata Foyer using the same fold +/// logic as `custom_cache_manager.rs`. Then delete the local file. Reading those +/// ranges via the store must succeed from cache — proving key alignment. +#[test] +fn page_index_key_alignment_warmup_matches_query_time() { + use parquet::file::reader::FileReader; + use parquet::file::serialized_reader::SerializedFileReader; + + let parquet_dir = TempDir::new().unwrap(); + let data_dir = TempDir::new().unwrap(); + let meta_dir = TempDir::new().unwrap(); + + // Write a multi-column file with page indexes enabled + let file_size = write_page_indexed_parquet(parquet_dir.path(), "page_idx.parquet", 3, 5); + let cache = create_tiered_cache(data_dir.path(), meta_dir.path()); + let store = create_store( + parquet_dir.path(), + cache.clone(), + "page_idx.parquet", + file_size, + ); + let path = Path::from("page_idx.parquet"); + + // Read footer and compute page index ranges using the shared helper + let file = std::fs::File::open(parquet_dir.path().join("page_idx.parquet")).unwrap(); + let reader = SerializedFileReader::new(file).unwrap(); + let parquet_metadata = reader.metadata(); + + // Compute ONE global merged range — matching parquet crate's range_for_page_index() + let mut index_ranges: Vec> = Vec::new(); + if let Some(r) = compute_global_page_index_range(parquet_metadata) { + index_ranges.push(r); + } + + // Must have a page index range (proves our test file has page indexes) + assert!( + !index_ranges.is_empty(), + "test file must have page index data; got {} ranges", + index_ranges.len() + ); + + // Warmup: read actual bytes and put into metadata Foyer via store.put_metadata() + let file_bytes = std::fs::read(parquet_dir.path().join("page_idx.parquet")).unwrap(); + let range_data: Vec = index_ranges + .iter() + .map(|r| bytes::Bytes::copy_from_slice(&file_bytes[r.start as usize..r.end as usize])) + .collect(); + store.put_metadata("page_idx.parquet", &index_ranges, &range_data); + + // Assert: the global page index range covers ALL individual column offsets + let global_range = &index_ranges[0]; + for rg in parquet_metadata.row_groups() { + for (col_idx, col) in rg.columns().iter().enumerate() { + if let (Some(offset), Some(length)) = + (col.column_index_offset(), col.column_index_length()) + { + let start = offset as u64; + let end = start + length as u64; + assert!( + start >= global_range.start && end <= global_range.end, + "col {} column_index {}..{} must be within global range {}..{}", + col_idx, + start, + end, + global_range.start, + global_range.end + ); + } + if let (Some(offset), Some(length)) = + (col.offset_index_offset(), col.offset_index_length()) + { + let start = offset as u64; + let end = start + length as u64; + assert!( + start >= global_range.start && end <= global_range.end, + "col {} offset_index {}..{} must be within global range {}..{}", + col_idx, + start, + end, + global_range.start, + global_range.end + ); + } + } + } + + // Assert: the exact cache key we expect is in metadata Foyer + let expected_key = range_cache_key("page_idx.parquet", global_range.start, global_range.end); + block_on(async { + assert!( + cache.metadata_cache().get(&expected_key).await.is_some(), + "exact global page index key {}..{} must be in metadata Foyer", + global_range.start, + global_range.end + ); + + // Assert: page index range is NOT in data Foyer (put_metadata goes only to metadata) + assert!( + cache.data_cache().get(&expected_key).await.is_none(), + "page index range must NOT be in data Foyer — only metadata Foyer" + ); + }); + + // Also warmup the footer (last 8KB) + let footer_start = file_size.saturating_sub(8 * 1024); + warmup_metadata( + &cache, + parquet_dir.path(), + "page_idx.parquet", + footer_start, + file_size, + ); + + // Delete local file — all reads must come from metadata cache + std::fs::remove_file(parquet_dir.path().join("page_idx.parquet")).unwrap(); + + block_on(async { + // Read the global page index range via store — must succeed from metadata cache + let result = store + .get_range(&path, global_range.start..global_range.end) + .await; + assert!(result.is_ok(), + "global page index range ({}..{}) must be served from metadata cache after file deletion", + global_range.start, global_range.end); + let bytes = result.unwrap(); + assert_eq!( + bytes, range_data[0], + "page index bytes must match warmup data byte-for-byte" + ); + }); +} + +/// **Test 2**: Concurrent shard warmup does not corrupt shared metadata Foyer. +/// +/// Multiple shards warming up in parallel (different files, same TieredBlockCache) +/// must not interfere. After all complete, each file's metadata is independently +/// correct and retrievable. +#[test] +fn concurrent_shard_warmup_does_not_corrupt() { + let parquet_dir = TempDir::new().unwrap(); + let data_dir = TempDir::new().unwrap(); + let meta_dir = TempDir::new().unwrap(); + + let cache = create_tiered_cache(data_dir.path(), meta_dir.path()); + + // Create 5 parquet files simulating 5 shard warmups + let num_shards = 5; + let mut file_info: Vec<(String, u64, bytes::Bytes)> = Vec::new(); + for i in 0..num_shards { + let filename = format!("shard_{}.parquet", i); + let file_size = write_test_parquet(parquet_dir.path(), &filename, 2 + i); + let footer_start = file_size.saturating_sub(8 * 1024); + // Read footer bytes before spawning tasks + let file_bytes = std::fs::read(parquet_dir.path().join(&filename)).unwrap(); + let footer_bytes = + bytes::Bytes::copy_from_slice(&file_bytes[footer_start as usize..file_size as usize]); + file_info.push((filename, file_size, footer_bytes)); + } + + block_on(async { + // Spawn 5 concurrent warmup tasks + let mut handles = Vec::new(); + for (filename, file_size, footer_bytes) in &file_info { + let cache_clone = cache.clone(); + let filename = filename.clone(); + let file_size = *file_size; + let footer_bytes = footer_bytes.clone(); + handles.push(tokio::spawn(async move { + let footer_start = file_size.saturating_sub(8 * 1024); + let key = range_cache_key(&filename, footer_start, file_size); + cache_clone.put_metadata(&key, footer_bytes); + })); + } + + // Wait for all warmups to complete + for handle in handles { + handle.await.unwrap(); + } + + // put_metadata is fire-and-forget into Foyer's disk-only storage (memory tier = 1 byte); + // insert() returns before the storage flusher persists the entry. Under heavy parallel load + // (full test suite) a get() issued immediately after can miss an entry still queued in the + // write buffer. Drain the flusher first — its documented post-condition is "all previously + // put() entries are on SSD and findable via get()" — so the verification reads are deterministic. + cache.wait_for_flush().await; + + // Verify each file's metadata is independently correct + for (filename, file_size, expected_bytes) in &file_info { + let footer_start = file_size.saturating_sub(8 * 1024); + let key = range_cache_key(filename, footer_start, *file_size); + let cached = cache.metadata_cache().get(&key).await; + assert!( + cached.is_some(), + "metadata for {} must be retrievable after concurrent warmup", + filename + ); + assert_eq!( + cached.unwrap(), + *expected_bytes, + "metadata for {} must not be corrupted by concurrent warmup", + filename + ); + } + }); +} + +/// **Test 3**: Metadata Foyer capacity breach graceful degradation. +/// +/// When metadata Foyer SSD fills, new put_metadata calls may fail silently (Foyer +/// behavior). Reads should still work for entries that survived, and no panics or +/// errors occur — graceful degradation. +#[test] +fn metadata_foyer_capacity_breach_graceful_degradation() { + let parquet_dir = TempDir::new().unwrap(); + let data_dir = TempDir::new().unwrap(); + let meta_dir = TempDir::new().unwrap(); + + // Write a larger file to have enough data to fill cache + let file_size = write_test_parquet(parquet_dir.path(), "overflow.parquet", 10); + + // Create a metadata cache with very small capacity (1MB) + let data_cache = Arc::new(FoyerCache::new( + DISK_BYTES, + data_dir.path(), + BLOCK_SIZE, + BUFFER_POOL, + SUBMIT_QUEUE, + "auto", + 0, + 0.0, + 0, + false, + )); + let metadata_cache = Arc::new(FoyerCache::new( + 1 * 1024 * 1024, + meta_dir.path(), + BLOCK_SIZE, + BUFFER_POOL, + SUBMIT_QUEUE, + "auto", + 0, + 0.0, + 0, + false, + )); + let cache = Arc::new(TieredBlockCache::new(data_cache, metadata_cache)); + let store = create_store( + parquet_dir.path(), + cache.clone(), + "overflow.parquet", + file_size, + ); + let path = Path::from("overflow.parquet"); + + // Read file bytes for warmup + let file_bytes = std::fs::read(parquet_dir.path().join("overflow.parquet")).unwrap(); + + // Put many metadata entries to exceed 1MB capacity. + // Use small chunk sizes that fit within our actual file size. + let mut entries: Vec<(u64, u64, bytes::Bytes)> = Vec::new(); + let chunk_size = 1024u64; // 1KB chunks — small enough for our test file + let num_entries = (file_size / chunk_size).max(1); + for i in 0..num_entries { + let start = i * chunk_size; + let end = ((i + 1) * chunk_size).min(file_size); + if end > start && (end as usize) <= file_bytes.len() { + let data = bytes::Bytes::copy_from_slice(&file_bytes[start as usize..end as usize]); + entries.push((start, end, data.clone())); + let key = range_cache_key("overflow.parquet", start, end); + cache.put_metadata(&key, data); + } + } + + // No panics must have occurred. Now verify graceful degradation. + block_on(async { + let mut hits = 0; + let mut misses = 0; + + for (start, end, expected) in &entries { + let key = range_cache_key("overflow.parquet", *start, *end); + match cache.metadata_cache().get(&key).await { + Some(cached) => { + assert_eq!( + &cached, expected, + "cached metadata at {}..{} must match", + start, end + ); + hits += 1; + } + None => { + misses += 1; + } + } + } + + // Some entries should have survived (at least the most recent ones) + assert!(hits > 0, + "at least some metadata entries must survive capacity pressure (got {} hits, {} misses)", + hits, misses); + + // Reads via store must not error even for ranges that missed metadata cache + // (they fall through to local FS or data cache) + for (start, end, _) in &entries { + let result = store.get_range(&path, *start..*end).await; + assert!(result.is_ok(), + "get_range({}..{}) must succeed (graceful degradation) even under capacity pressure", + start, end); + } + }); +} + +/// **Test 4**: Page index ranges match parquet crate computation. +/// +/// Our warmup code computes page index ranges by folding column_index_offset/length +/// and offset_index_offset/length across columns per RG. This test verifies that +/// the fold produces ranges that cover all column metadata by checking against +/// individual column offsets. +#[test] +fn page_index_ranges_match_parquet_crate_computation() { + use parquet::file::reader::FileReader; + use parquet::file::serialized_reader::SerializedFileReader; + + let parquet_dir = TempDir::new().unwrap(); + + // Write a multi-column file (5 columns, 4 row groups) + let _file_size = write_page_indexed_parquet(parquet_dir.path(), "parity.parquet", 4, 5); + + let file = std::fs::File::open(parquet_dir.path().join("parity.parquet")).unwrap(); + let reader = SerializedFileReader::new(file).unwrap(); + let parquet_metadata = reader.metadata(); + + for (rg_idx, rg) in parquet_metadata.row_groups().iter().enumerate() { + // Compute merged ranges using our shared fold helper + let (col_idx_range, off_idx_range) = compute_per_rg_page_index_ranges(rg); + + // Verify that every individual column's range is fully contained within the merged range + for (col_idx, col) in rg.columns().iter().enumerate() { + if let (Some(offset), Some(length)) = + (col.column_index_offset(), col.column_index_length()) + { + let start = offset as u64; + let end = start + length as u64; + let merged = col_idx_range.as_ref().unwrap(); + assert!( + start >= merged.start && end <= merged.end, + "RG {} col {} column_index range {}..{} must be contained in merged {}..{}", + rg_idx, + col_idx, + start, + end, + merged.start, + merged.end + ); + } + + if let (Some(offset), Some(length)) = + (col.offset_index_offset(), col.offset_index_length()) + { + let start = offset as u64; + let end = start + length as u64; + let merged = off_idx_range.as_ref().unwrap(); + assert!( + start >= merged.start && end <= merged.end, + "RG {} col {} offset_index range {}..{} must be contained in merged {}..{}", + rg_idx, + col_idx, + start, + end, + merged.start, + merged.end + ); + } + } + + // Verify the merged range is tight (start == min offset, end == max offset+length) + if let Some(ref merged) = col_idx_range { + let actual_min = rg + .columns() + .iter() + .filter_map(|c| c.column_index_offset().map(|o| o as u64)) + .min() + .unwrap(); + let actual_max = rg + .columns() + .iter() + .filter_map(|c| { + c.column_index_offset() + .and_then(|o| c.column_index_length().map(|l| o as u64 + l as u64)) + }) + .max() + .unwrap(); + assert_eq!( + merged.start, actual_min, + "RG {} column_index merged start must equal min offset", + rg_idx + ); + assert_eq!( + merged.end, actual_max, + "RG {} column_index merged end must equal max offset+length", + rg_idx + ); + } + + if let Some(ref merged) = off_idx_range { + let actual_min = rg + .columns() + .iter() + .filter_map(|c| c.offset_index_offset().map(|o| o as u64)) + .min() + .unwrap(); + let actual_max = rg + .columns() + .iter() + .filter_map(|c| { + c.offset_index_offset() + .and_then(|o| c.offset_index_length().map(|l| o as u64 + l as u64)) + }) + .max() + .unwrap(); + assert_eq!( + merged.start, actual_min, + "RG {} offset_index merged start must equal min offset", + rg_idx + ); + assert_eq!( + merged.end, actual_max, + "RG {} offset_index merged end must equal max offset+length", + rg_idx + ); + } + } +} + +/// **Test 5**: get_opts probe does NOT pollute metadata Foyer with column data. +/// +/// At query time, column data reads via CachedMetadataReader::get_bytes() go +/// through get_opts. This must NOT put column data bytes into metadata Foyer. +/// Only warmup's explicit put_metadata() should populate metadata Foyer. +#[test] +fn get_opts_probe_does_not_pollute_metadata_foyer() { + let parquet_dir = TempDir::new().unwrap(); + let data_dir = TempDir::new().unwrap(); + let meta_dir = TempDir::new().unwrap(); + + let file_size = write_test_parquet(parquet_dir.path(), "nopollute.parquet", 3); + let cache = create_tiered_cache(data_dir.path(), meta_dir.path()); + let store = create_store( + parquet_dir.path(), + cache.clone(), + "nopollute.parquet", + file_size, + ); + let path = Path::from("nopollute.parquet"); + + // First, put only the footer into metadata cache via explicit warmup + let footer_start = file_size.saturating_sub(8 * 1024); + warmup_metadata( + &cache, + parquet_dir.path(), + "nopollute.parquet", + footer_start, + file_size, + ); + + block_on(async { + // Verify footer IS in metadata cache + let footer_key = range_cache_key("nopollute.parquet", footer_start, file_size); + assert!( + cache.metadata_cache().get(&footer_key).await.is_some(), + "footer must be in metadata cache after warmup" + ); + + // Now read a column data range via get_range (simulates CachedMetadataReader::get_bytes) + // This goes through get_opts path + let data_start = 0u64; + let data_end = 4096u64; + let result = store.get_range(&path, data_start..data_end).await; + assert!(result.is_ok(), "get_range must succeed"); + + // The column data range must NOT be in metadata cache + let data_key = range_cache_key("nopollute.parquet", data_start, data_end); + assert!(cache.metadata_cache().get(&data_key).await.is_none(), + "column data range must NOT be in metadata cache — get_opts must not auto-populate metadata"); + assert!( + cache.data_cache().get(&data_key).await.is_some(), + "get_opts must populate data cache on miss" + ); + + // Contrast: reading via get_ranges DOES populate data cache + let data2 = store.get_ranges(&path, &[100u64..4196]).await.unwrap(); + assert_eq!(data2[0].len(), 4096); + let data2_key = range_cache_key("nopollute.parquet", 100, 4196); + assert!( + cache.data_cache().get(&data2_key).await.is_some(), + "get_ranges must populate data cache" + ); + assert!( + cache.metadata_cache().get(&data2_key).await.is_none(), + "get_ranges must NOT populate metadata cache" + ); + }); +} + +/// **Test 6**: Restart — no S3/local FS reads for previously warmed metadata. +/// +/// On restart, warmup re-runs. Previously warmed metadata (footer + page indexes) +/// should be recovered from Foyer's SSD tier, not re-fetched from local FS. +/// +/// Strategy: warmup puts metadata ranges into metadata Foyer, drop caches, +/// recreate on same dirs. After recovery, verify that the majority of warmed +/// ranges are still available from SSD. Uses the existing `metadata_survives_restart` +/// pattern but with multiple ranges including page indexes. +/// +/// Note: Foyer's disk recovery may not recover every entry (small entries below +/// block alignment may be lost), so we verify that at least the footer and some +/// page index ranges survive — proving the SSD recovery path works. +#[test] +fn restart_no_s3_for_previously_warmed_metadata() { + use parquet::file::reader::FileReader; + use parquet::file::serialized_reader::SerializedFileReader; + + let parquet_dir = TempDir::new().unwrap(); + let data_dir = TempDir::new().unwrap(); + let meta_dir = TempDir::new().unwrap(); + + let file_size = write_page_indexed_parquet(parquet_dir.path(), "restart_meta.parquet", 3, 3); + + // Session 1: warmup puts footer + page index ranges into metadata Foyer + let mut warmed_ranges: Vec> = Vec::new(); + let mut warmed_data: Vec = Vec::new(); + { + let cache = create_tiered_cache(data_dir.path(), meta_dir.path()); + let store = create_store( + parquet_dir.path(), + cache.clone(), + "restart_meta.parquet", + file_size, + ); + + // Read file and compute page index ranges + let file = std::fs::File::open(parquet_dir.path().join("restart_meta.parquet")).unwrap(); + let reader = SerializedFileReader::new(file).unwrap(); + let parquet_metadata = reader.metadata(); + let file_bytes = std::fs::read(parquet_dir.path().join("restart_meta.parquet")).unwrap(); + + // Footer range (last 8KB — large enough to survive Foyer block alignment) + let footer_start = file_size.saturating_sub(8 * 1024); + warmed_ranges.push(footer_start..file_size); + warmed_data.push(bytes::Bytes::copy_from_slice( + &file_bytes[footer_start as usize..file_size as usize], + )); + + // Page index ranges + for rg in parquet_metadata.row_groups() { + let (col_idx_range, off_idx_range) = compute_per_rg_page_index_ranges(rg); + if let Some(r) = col_idx_range { + warmed_data.push(bytes::Bytes::copy_from_slice( + &file_bytes[r.start as usize..r.end as usize], + )); + warmed_ranges.push(r); + } + if let Some(r) = off_idx_range { + warmed_data.push(bytes::Bytes::copy_from_slice( + &file_bytes[r.start as usize..r.end as usize], + )); + warmed_ranges.push(r); + } + } + + assert!( + warmed_ranges.len() >= 2, + "must have footer + at least 1 page index range" + ); + + // Put all ranges into metadata Foyer + store.put_metadata("restart_meta.parquet", &warmed_ranges, &warmed_data); + } + // Session 1 dropped — Foyer flushes to SSD + + // Session 2: new Foyer instances on same directories — should recover from SSD + // NOTE: we do NOT delete the local file here. Instead, we verify recovery by + // probing the metadata cache directly. This avoids flakiness from Foyer's block + // alignment behavior with very small entries. + { + let cache = create_tiered_cache(data_dir.path(), meta_dir.path()); + + block_on(async { + let mut recovered_count = 0; + for (i, (range, expected)) in warmed_ranges.iter().zip(warmed_data.iter()).enumerate() { + let key = range_cache_key("restart_meta.parquet", range.start, range.end); + if let Some(cached) = cache.metadata_cache().get(&key).await { + assert_eq!( + &cached, expected, + "recovered range {} ({}..{}) bytes must match original warmup data", + i, range.start, range.end + ); + recovered_count += 1; + } + } + + // The footer (range 0) must survive — it is the largest entry and most + // critical for restart without S3 calls. + let footer_key = range_cache_key( + "restart_meta.parquet", + warmed_ranges[0].start, + warmed_ranges[0].end, + ); + assert!(cache.metadata_cache().get(&footer_key).await.is_some(), + "footer must survive SSD recovery — this is the primary restart-without-S3 guarantee"); + + // At least the footer should be recovered; page index ranges may or may not + // depending on Foyer's block packing. The key correctness guarantee is that + // recovered data is byte-for-byte correct (verified above). + assert!( + recovered_count >= 1, + "at least the footer must survive SSD recovery (recovered {} of {} ranges)", + recovered_count, + warmed_ranges.len() + ); + }); + } +} + +/// **HIGH-CONFIDENCE TEST**: Full production warmup sequence → delete file → +/// DataFusion query succeeds entirely from cache. +/// +/// This exercises the EXACT production path: +/// 1. Warmup: parse footer, compute page index ranges, put all into metadata Foyer +/// 2. First DataFusion query: reads column data → populates data Foyer +/// 3. Delete the local Parquet file (simulates warm node with no local copy) +/// 4. Second DataFusion query: must succeed from cache alone +/// - Metadata (footer): served from metadata Foyer via get_opts probe +/// - Column data: served from data Foyer via get_ranges probe +/// +/// If this test passes, key alignment is proven for ALL byte ranges across the +/// full stack: warmup → DataFusion → TieredObjectStore → TieredBlockCache → Foyer. +#[test] +fn production_warmup_then_query_from_cache_only() { + use parquet::file::reader::FileReader; + use parquet::file::serialized_reader::SerializedFileReader; + + let parquet_dir = TempDir::new().unwrap(); + let data_dir = TempDir::new().unwrap(); + let meta_dir = TempDir::new().unwrap(); + + let file_size = write_page_indexed_parquet(parquet_dir.path(), "prod.parquet", 3, 4); + let cache = create_tiered_cache(data_dir.path(), meta_dir.path()); + let store = create_store(parquet_dir.path(), cache.clone(), "prod.parquet", file_size); + + // ── Step 1: Production warmup (same logic as warmup_file_with_store) ───── + let file_bytes = std::fs::read(parquet_dir.path().join("prod.parquet")).unwrap(); + let file = std::fs::File::open(parquet_dir.path().join("prod.parquet")).unwrap(); + let reader = SerializedFileReader::new(file).unwrap(); + let parquet_metadata = reader.metadata(); + + // Compute all metadata ranges (footer + page indexes) + let mut metadata_ranges: Vec> = Vec::new(); + let mut metadata_bytes: Vec = Vec::new(); + + // Footer + let footer_start = file_size.saturating_sub(64 * 1024); + metadata_ranges.push(footer_start..file_size); + metadata_bytes.push(bytes::Bytes::copy_from_slice( + &file_bytes[footer_start as usize..file_size as usize], + )); + + // Page/offset indexes per RG + for rg in parquet_metadata.row_groups() { + let (col_idx_range, off_idx_range) = compute_per_rg_page_index_ranges(rg); + if let Some(r) = col_idx_range { + metadata_bytes.push(bytes::Bytes::copy_from_slice( + &file_bytes[r.start as usize..r.end as usize], + )); + metadata_ranges.push(r); + } + if let Some(r) = off_idx_range { + metadata_bytes.push(bytes::Bytes::copy_from_slice( + &file_bytes[r.start as usize..r.end as usize], + )); + metadata_ranges.push(r); + } + } + + // Put metadata into metadata Foyer (production warmup step) + store.put_metadata("prod.parquet", &metadata_ranges, &metadata_bytes); + + // ── Step 2: First DataFusion query (populates data Foyer with column data) ── + block_on(async { + let (ctx, schema) = setup_df_session(store.clone(), "prod.parquet", "prod", None).await; + + // Run query — this reads column data via get_ranges → data Foyer + let batches = ctx + .sql("SELECT col_0, col_1 FROM prod WHERE col_0 < 50") + .await + .unwrap() + .collect() + .await + .unwrap(); + let rows1: usize = batches.iter().map(|b| b.num_rows()).sum(); + assert!(rows1 > 0, "first query must return rows"); + + // ── Step 3: Delete local file ──────────────────────────────────────── + std::fs::remove_file(parquet_dir.path().join("prod.parquet")).unwrap(); + + // ── Step 4: Second query — must succeed entirely from cache ─────────── + let (ctx2, _) = setup_df_session(store.clone(), "prod.parquet", "prod", Some(schema)).await; + + let batches2 = ctx2 + .sql("SELECT col_0, col_1 FROM prod WHERE col_0 < 50") + .await + .unwrap() + .collect() + .await + .unwrap(); + let rows2: usize = batches2.iter().map(|b| b.num_rows()).sum(); + assert_eq!(rows2, rows1, + "second query (file deleted) must return same rows as first — proves full cache correctness"); + }); +} + +/// **HIGH-CONFIDENCE TEST**: Restart with file deleted — Foyer is the ONLY source. +/// +/// Session 1: production warmup + DataFusion query (populates both caches) +/// Session 2: new Foyer instances (same SSD dirs), file deleted → DataFusion query succeeds. +/// +/// This proves that across a full restart (new process, new Foyer instances), +/// the recovered SSD state is sufficient to serve all reads without any +/// local FS or S3 access. +/// +/// Requires: FoyerCache::drop() calls HybridCache::close() to flush partial blocks. +#[test] +fn restart_with_file_deleted_query_succeeds_from_foyer_only() { + let parquet_dir = TempDir::new().unwrap(); + let data_dir = TempDir::new().unwrap(); + let meta_dir = TempDir::new().unwrap(); + + let file_size = write_test_parquet(parquet_dir.path(), "restart_full.parquet", 2); + let expected_rows; + let saved_schema; + + // ── Session 1: warmup + query (populates both caches) ──────────────────── + { + let cache = create_tiered_cache(data_dir.path(), meta_dir.path()); + let store = create_store( + parquet_dir.path(), + cache.clone(), + "restart_full.parquet", + file_size, + ); + + // Warmup: put footer into metadata Foyer + let footer_start = file_size.saturating_sub(64 * 1024); + let file_bytes = std::fs::read(parquet_dir.path().join("restart_full.parquet")).unwrap(); + let footer_bytes = bytes::Bytes::copy_from_slice(&file_bytes[footer_start as usize..]); + store.put_metadata( + "restart_full.parquet", + &[footer_start..file_size], + &[footer_bytes], + ); + + // Run DataFusion query — populates data Foyer with column data + let (rows, schema) = block_on(async { + let (ctx, schema) = + setup_df_session(store.clone(), "restart_full.parquet", "t", None).await; + + let batches = ctx + .sql("SELECT id FROM t WHERE id < 10 ORDER BY id") + .await + .unwrap() + .collect() + .await + .unwrap(); + let rows = batches.iter().map(|b| b.num_rows()).sum::(); + (rows, schema) + }); + expected_rows = rows; + saved_schema = schema; + assert!(expected_rows > 0); + } + // Session 1 dropped — FoyerCache::drop() calls HybridCache::close() which + // flushes partial blocks to SSD. No sleep needed. + + // ── Delete local file between sessions ─────────────────────────────────── + std::fs::remove_file(parquet_dir.path().join("restart_full.parquet")).unwrap(); + + // ── Session 2: new Foyer instances, file gone — query from Foyer only ──── + { + let cache = create_tiered_cache(data_dir.path(), meta_dir.path()); + let store = create_store( + parquet_dir.path(), + cache.clone(), + "restart_full.parquet", + file_size, + ); + + let rows = block_on(async { + // Use saved schema from session 1 (in production, CatalogSnapshot provides this) + let (ctx, _) = setup_df_session( + store.clone(), + "restart_full.parquet", + "t", + Some(saved_schema), + ) + .await; + + let batches = ctx + .sql("SELECT id FROM t WHERE id < 10 ORDER BY id") + .await + .unwrap() + .collect() + .await + .unwrap(); + batches.iter().map(|b| b.num_rows()).sum::() + }); + + assert_eq!( + rows, expected_rows, + "restart query (file deleted, new Foyer instances) must return same rows — \ + proves full lifecycle: warmup → persist → recover → serve from Foyer only" + ); + } +} + +/// get_opts auto-populates data Foyer on miss — repeated single-range reads +/// hit data cache on second access (simulates CachedMetadataReader::get_bytes +/// for column chunks in IndexedExec path). +#[test] +fn get_opts_populates_data_cache_on_miss() { + let parquet_dir = TempDir::new().unwrap(); + let data_dir = TempDir::new().unwrap(); + let meta_dir = TempDir::new().unwrap(); + + let file_size = write_test_parquet(parquet_dir.path(), "indexed.parquet", 3); + let cache = create_tiered_cache(data_dir.path(), meta_dir.path()); + let store = create_store( + parquet_dir.path(), + cache.clone(), + "indexed.parquet", + file_size, + ); + let path = Path::from("indexed.parquet"); + + block_on(async { + let range = 0u64..4096; + + // First read: cache miss → local FS → populate data Foyer + let bytes1 = store.get_range(&path, range.clone()).await.unwrap(); + assert_eq!(bytes1.len(), 4096); + + // Verify: entry is now in data Foyer (not metadata Foyer) + let key = range_cache_key("indexed.parquet", 0, 4096); + assert!( + cache.data_cache().get(&key).await.is_some(), + "first read must populate data Foyer" + ); + assert!( + cache.metadata_cache().get(&key).await.is_none(), + "get_opts must NOT populate metadata Foyer" + ); + + // Second read: hits data Foyer (no local FS needed) + // Delete file to prove it comes from cache + std::fs::remove_file(parquet_dir.path().join("indexed.parquet")).unwrap(); + + let bytes2 = store.get_range(&path, range).await.unwrap(); + assert_eq!( + bytes2, bytes1, + "second read must return same bytes from data Foyer cache" + ); + }); +} + +/// get_opts skips caching for ranges exceeding max_cache_entry_size. +/// The threshold is configurable and dynamically updatable. +#[test] +fn get_opts_skips_caching_for_large_ranges() { + let parquet_dir = TempDir::new().unwrap(); + let data_dir = TempDir::new().unwrap(); + let meta_dir = TempDir::new().unwrap(); + + let file_size = write_test_parquet(parquet_dir.path(), "threshold.parquet", 5); + let cache = create_tiered_cache(data_dir.path(), meta_dir.path()); + let store = create_store( + parquet_dir.path(), + cache.clone(), + "threshold.parquet", + file_size, + ); + let path = Path::from("threshold.parquet"); + + // Set threshold to 2KB — anything larger skips caching + cache.update_max_data_entry_size(2048); + + block_on(async { + // Read 4KB range (> 2KB threshold) — should NOT be cached + let large_range = 0u64..4096.min(file_size); + let bytes = store.get_range(&path, large_range.clone()).await.unwrap(); + assert!(!bytes.is_empty()); + + let large_key = range_cache_key("threshold.parquet", large_range.start, large_range.end); + assert!( + cache.data_cache().get(&large_key).await.is_none(), + "range > threshold must NOT be cached" + ); + + // Read 1KB range (< 2KB threshold) — should be cached + let small_range = 0u64..1024.min(file_size); + let _ = store.get_range(&path, small_range.clone()).await.unwrap(); + + let small_key = range_cache_key("threshold.parquet", small_range.start, small_range.end); + assert!( + cache.data_cache().get(&small_key).await.is_some(), + "range < threshold must be cached" + ); + + // Dynamic update: increase threshold to 8KB — now 4KB is cached + cache.update_max_data_entry_size(8192); + let _ = store.get_range(&path, large_range.clone()).await.unwrap(); + assert!( + cache.data_cache().get(&large_key).await.is_some(), + "after threshold increase, 4KB range must be cached" + ); + }); +} + +// ── Small-file + per-range metadata persistence (prod-gap reproduction) ────── +// +// Production logs (warm node, ~3 KB Parquet file) showed that after warmup the +// metadata tier's key_index.json contained ONLY the whole-file footer key +// (`␟0-`) — the column-index, offset-index, and 8-byte postscript +// keys were missing. These tests pin the property that EVERY warmed range must +// land in the never-evict metadata tier, and isolate the small-file footer +// collapse that triggers the overlap. + +/// Compute the warmup ranges exactly as `custom_cache_manager::warmup_file_with_store`: +/// - CI + OI page-index whole regions (via the real `compute_page_index_range`) +/// - footer: `[size - 64KB, size]` (collapses to `[0, size]` for files < 64KB) +/// - postscript: `[size - 8, size]` +fn production_warmup_ranges( + metadata: &parquet::file::metadata::ParquetMetaData, + file_size: u64, +) -> Vec> { + let mut ranges = crate::cache::custom_cache_manager::compute_page_index_range(metadata); + let footer_start = file_size.saturating_sub(64 * 1024); + ranges.push(footer_start..file_size); + let postscript_start = file_size.saturating_sub(8); + ranges.push(postscript_start..file_size); + ranges +} + +/// In-session per-range persistence: warm a small (< 64 KB) page-indexed file +/// exactly as production does, then assert that EACH warmed range (CI, OI, footer, +/// postscript) is individually present in the metadata tier — not just the +/// whole-file footer key. +#[test] +fn small_file_warmup_persists_every_range_to_metadata_tier() { + use parquet::file::reader::FileReader; + use parquet::file::serialized_reader::SerializedFileReader; + + let parquet_dir = TempDir::new().unwrap(); + let data_dir = TempDir::new().unwrap(); + let meta_dir = TempDir::new().unwrap(); + + // 11 columns × 1 row group → a few-KB file, matching the production shape + // (col_count=11, rg_count=1, size ≈ 3 KB). + let file_size = write_page_indexed_parquet(parquet_dir.path(), "small.parquet", 1, 11); + assert!( + file_size < 64 * 1024, + "fixture must be smaller than the 64KB footer prefetch" + ); + + let cache = create_tiered_cache(data_dir.path(), meta_dir.path()); + let store = create_store( + parquet_dir.path(), + cache.clone(), + "small.parquet", + file_size, + ); + + let file = std::fs::File::open(parquet_dir.path().join("small.parquet")).unwrap(); + let reader = SerializedFileReader::new(file).unwrap(); + let ranges = production_warmup_ranges(reader.metadata(), file_size); + assert_eq!( + ranges.len(), + 4, + "expected CI, OI, footer, postscript ranges" + ); + + // Root-cause quirk: for a sub-64KB file the footer range is the whole file. + assert_eq!( + ranges[2], + 0..file_size, + "small-file footer range collapses to the whole file" + ); + + block_on(async { + let path = Path::from("small.parquet"); + let fetched = store.get_ranges(&path, &ranges).await.unwrap(); + store.put_metadata("small.parquet", &ranges, &fetched); + + for r in &ranges { + let key = range_cache_key("small.parquet", r.start, r.end); + assert!( + cache.metadata_cache().get(&key).await.is_some(), + "warmed range {}..{} ({} bytes) must be in the metadata tier \ + (prod showed only the footer key persisting)", + r.start, + r.end, + r.end - r.start + ); + } + }); +} + +/// Restart variant — closest to the production `key_index.json` evidence. Warm all +/// ranges into the metadata tier, drop the caches (FoyerCache::drop flushes + +/// persists), then recreate on the same SSD dirs and assert EVERY warmed range is +/// recovered byte-for-byte. If the small CI/OI/postscript entries are lost on Foyer +/// SSD recovery while the larger footer survives, this fails on those ranges — +/// reproducing the production symptom. +#[test] +fn small_file_warmed_ranges_survive_restart() { + use parquet::file::reader::FileReader; + use parquet::file::serialized_reader::SerializedFileReader; + + let parquet_dir = TempDir::new().unwrap(); + let data_dir = TempDir::new().unwrap(); + let meta_dir = TempDir::new().unwrap(); + + let file_size = write_page_indexed_parquet(parquet_dir.path(), "small_restart.parquet", 1, 11); + assert!(file_size < 64 * 1024); + + let file = std::fs::File::open(parquet_dir.path().join("small_restart.parquet")).unwrap(); + let reader = SerializedFileReader::new(file).unwrap(); + let ranges = production_warmup_ranges(reader.metadata(), file_size); + + let file_bytes = std::fs::read(parquet_dir.path().join("small_restart.parquet")).unwrap(); + let datas: Vec = ranges + .iter() + .map(|r| bytes::Bytes::copy_from_slice(&file_bytes[r.start as usize..r.end as usize])) + .collect(); + + // Session 1: warm all ranges, then drop → flush to SSD + persist key_index. + { + let cache = create_tiered_cache(data_dir.path(), meta_dir.path()); + let store = create_store( + parquet_dir.path(), + cache.clone(), + "small_restart.parquet", + file_size, + ); + store.put_metadata("small_restart.parquet", &ranges, &datas); + } + + // Session 2: new instances on the same SSD dirs — Foyer recovers from disk. + { + let cache = create_tiered_cache(data_dir.path(), meta_dir.path()); + block_on(async { + for (i, r) in ranges.iter().enumerate() { + let key = range_cache_key("small_restart.parquet", r.start, r.end); + let recovered = cache.metadata_cache().get(&key).await; + assert!( + recovered.is_some(), + "range[{}] {}..{} ({} bytes) must survive Foyer SSD recovery \ + (prod showed only the whole-file footer surviving)", + i, + r.start, + r.end, + r.end - r.start + ); + assert_eq!( + recovered.unwrap(), + datas[i], + "recovered bytes must match the warmed bytes" + ); + } + }); + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udaf/approx_distinct_safe.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udaf/approx_distinct_safe.rs new file mode 100644 index 0000000000000..63292ec4f9170 --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udaf/approx_distinct_safe.rs @@ -0,0 +1,119 @@ +/* + * 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. + */ + +//! Safe `approx_distinct` override — fixes https://github.com/apache/datafusion/pull/21064 +//! by routing Utf8View through consistent &str hashing. +//! +//! TODO: Remove once fixed upstream (likely DataFusion v55/v56). +//! TODO: Evaluate if DF's UDAF extension points (e.g. AccumulatorArgs overrides or +//! custom PhysicalOptimizerRule to inject CastExec) can avoid same-name overrides. + +use datafusion::arrow::array::{Array, ArrayRef, StringArray, StringViewArray}; +use datafusion::arrow::datatypes::{DataType, Field, FieldRef}; +use datafusion::common::{downcast_value, Result}; +use datafusion::execution::context::SessionContext; +use datafusion::functions_aggregate::approx_distinct::approx_distinct_udaf; +use datafusion::logical_expr::function::{AccumulatorArgs, StateFieldsArgs}; +use datafusion::logical_expr::{Accumulator, AggregateUDFImpl, Signature, Volatility}; +use datafusion::scalar::ScalarValue; +use std::sync::Arc; + +pub fn register_all(ctx: &SessionContext) { + ctx.register_udaf(datafusion::logical_expr::AggregateUDF::from( + SafeApproxDistinct::new(), + )); +} + +#[derive(Debug, PartialEq, Eq, Hash)] +struct SafeApproxDistinct { + signature: Signature, +} + +impl SafeApproxDistinct { + fn new() -> Self { + Self { + signature: Signature::any(1, Volatility::Immutable), + } + } +} + +impl AggregateUDFImpl for SafeApproxDistinct { + fn name(&self) -> &str { + "approx_distinct" + } + + fn signature(&self) -> &Signature { + &self.signature + } + + fn return_type(&self, args: &[DataType]) -> Result { + approx_distinct_udaf().inner().return_type(args) + } + + fn state_fields(&self, args: StateFieldsArgs) -> Result> { + approx_distinct_udaf().inner().state_fields(args) + } + + fn accumulator(&self, acc_args: AccumulatorArgs) -> Result> { + let data_type = acc_args.expr_fields[0].data_type(); + if matches!(data_type, DataType::Utf8View) { + // Wrap DF's Utf8 accumulator, feed it materialized &str from StringViewArray + let utf8_args = AccumulatorArgs { + return_field: acc_args.return_field, + schema: acc_args.schema, + ignore_nulls: acc_args.ignore_nulls, + order_bys: acc_args.order_bys, + name: acc_args.name, + is_distinct: acc_args.is_distinct, + exprs: acc_args.exprs, + expr_fields: &[Arc::new(Field::new("x", DataType::Utf8, true))], + is_reversed: acc_args.is_reversed, + }; + let inner = approx_distinct_udaf().inner().accumulator(utf8_args)?; + Ok(Box::new(Utf8ViewToUtf8Accumulator { inner })) + } else { + approx_distinct_udaf().inner().accumulator(acc_args) + } + } + + fn documentation(&self) -> Option<&datafusion::logical_expr::Documentation> { + None + } +} + +/// Wraps DF's Utf8 HLL accumulator, converting Utf8View batches to StringArray on the fly. +#[derive(Debug)] +struct Utf8ViewToUtf8Accumulator { + inner: Box, +} + +impl Accumulator for Utf8ViewToUtf8Accumulator { + fn update_batch(&mut self, values: &[ArrayRef]) -> Result<()> { + let view_array: &StringViewArray = downcast_value!(values[0], StringViewArray); + // Materialize as StringArray — consistent hashing via StringHLLAccumulator + let string_array: StringArray = view_array.iter().collect(); + self.inner + .update_batch(&[Arc::new(string_array) as ArrayRef]) + } + + fn merge_batch(&mut self, states: &[ArrayRef]) -> Result<()> { + self.inner.merge_batch(states) + } + + fn state(&mut self) -> Result> { + self.inner.state() + } + + fn evaluate(&mut self) -> Result { + self.inner.evaluate() + } + + fn size(&self) -> usize { + self.inner.size() + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udaf/internal_pattern.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udaf/internal_pattern.rs index b569b503c442a..d5e54c2ab7379 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udaf/internal_pattern.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udaf/internal_pattern.rs @@ -50,7 +50,9 @@ use datafusion::arrow::datatypes::{DataType, Field, FieldRef, Fields}; use datafusion::common::{exec_err, Result, ScalarValue}; use datafusion::execution::context::SessionContext; use datafusion::logical_expr::function::{AccumulatorArgs, StateFieldsArgs}; -use datafusion::logical_expr::{Accumulator, AggregateUDF, AggregateUDFImpl, Signature, Volatility}; +use datafusion::logical_expr::{ + Accumulator, AggregateUDF, AggregateUDFImpl, Signature, Volatility, +}; use datafusion::physical_expr::expressions::Literal; use crate::patterns::brain::{BrainLogParser, PatternEntry}; @@ -177,7 +179,10 @@ impl AggregateUDFImpl for InternalPatternUdaf { DataType::List(_) | DataType::LargeList(_) | DataType::FixedSizeList(_, _) ); let config = AggConfig::from_args(&acc_args)?; - Ok(Box::new(InternalPatternAccumulator::new(arg0_is_list, config))) + Ok(Box::new(InternalPatternAccumulator::new( + arg0_is_list, + config, + ))) } fn state_fields(&self, args: StateFieldsArgs) -> Result> { @@ -343,7 +348,11 @@ impl InternalPatternAccumulator { } return Ok(()); } - exec_err!("{}: expected string column, got {:?}", NAME, arr.data_type()) + exec_err!( + "{}: expected string column, got {:?}", + NAME, + arr.data_type() + ) } fn append_list(&mut self, arr: &ArrayRef) -> Result<()> { @@ -405,12 +414,7 @@ impl Accumulator for InternalPatternAccumulator { } fn size(&self) -> usize { - std::mem::size_of_val(self) - + self - .buffer - .iter() - .map(|s| s.capacity()) - .sum::() + std::mem::size_of_val(self) + self.buffer.iter().map(|s| s.capacity()).sum::() } fn state(&mut self) -> Result> { @@ -438,7 +442,10 @@ fn sorted_pattern_entries(stats: HashMap) -> Vec@.`) and the tokens map /// captures the extracted variables; otherwise tokens is empty and pattern /// stays raw (`<*>@<*>.<*>`). -fn build_list_struct_scalar(entries: &[PatternEntry], show_numbered_token: bool) -> Result { +fn build_list_struct_scalar( + entries: &[PatternEntry], + show_numbered_token: bool, +) -> Result { let array = build_list_struct_array(entries, show_numbered_token)?; Ok(ScalarValue::List(Arc::new(array))) } @@ -451,7 +458,10 @@ fn empty_list_struct_scalar() -> Result { /// Builds a one-row `ListArray` whose single element is the per-pattern /// StructArray with `entries.len()` rows. -fn build_list_struct_array(entries: &[PatternEntry], show_numbered_token: bool) -> Result { +fn build_list_struct_array( + entries: &[PatternEntry], + show_numbered_token: bool, +) -> Result { let struct_array = build_struct_array(entries, show_numbered_token)?; let offsets = OffsetBuffer::new(vec![0i32, entries.len() as i32].into()); let item_field = Field::new("item", struct_element_type(), true); @@ -559,14 +569,13 @@ mod tests { (0..s.len()) .map(|i| { let sample_inner = sample_logs_col.value(i); - let samples = sample_inner - .as_any() - .downcast_ref::() - .unwrap(); + let samples = sample_inner.as_any().downcast_ref::().unwrap(); PatternEntry { pattern: pattern_col.value(i).to_string(), pattern_count: count_col.value(i) as u64, - sample_logs: (0..samples.len()).map(|j| samples.value(j).to_string()).collect(), + sample_logs: (0..samples.len()) + .map(|j| samples.value(j).to_string()) + .collect(), } }) .collect() @@ -596,7 +605,10 @@ mod tests { ]; let entries = run(&logs, false); let top = &entries[0]; - assert_eq!(top.pattern_count, 5, "expected 5 in top group, got {entries:?}"); + assert_eq!( + top.pattern_count, 5, + "expected 5 in top group, got {entries:?}" + ); } #[test] diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udaf/list_merge.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udaf/list_merge.rs index 7965f653a4989..04e8931978cf9 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udaf/list_merge.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udaf/list_merge.rs @@ -27,7 +27,9 @@ use datafusion::arrow::datatypes::{DataType, Field, FieldRef}; use datafusion::common::{exec_err, Result, ScalarValue}; use datafusion::execution::context::SessionContext; use datafusion::logical_expr::function::{AccumulatorArgs, StateFieldsArgs}; -use datafusion::logical_expr::{Accumulator, AggregateUDF, AggregateUDFImpl, Signature, Volatility}; +use datafusion::logical_expr::{ + Accumulator, AggregateUDF, AggregateUDFImpl, Signature, Volatility, +}; pub fn register_all(ctx: &SessionContext) { ctx.register_udaf(AggregateUDF::from(ListMergeUdaf::new(false))); @@ -44,7 +46,11 @@ pub struct ListMergeUdaf { impl ListMergeUdaf { pub fn new(distinct: bool) -> Self { Self { - name: if distinct { "list_merge_distinct" } else { "list_merge" }, + name: if distinct { + "list_merge_distinct" + } else { + "list_merge" + }, distinct, signature: Signature::any(1, Volatility::Immutable), } @@ -95,10 +101,16 @@ impl AggregateUDFImpl for ListMergeUdaf { .map(|e| e.data_type(acc_args.schema)) .transpose()? .ok_or_else(|| { - datafusion::common::DataFusionError::Execution(format!("{}: missing argument", self.name)) + datafusion::common::DataFusionError::Execution(format!( + "{}: missing argument", + self.name + )) })?; let element_type = list_element_type(&raw_arg0)?; - Ok(Box::new(ListMergeAccumulator::new(element_type, self.distinct))) + Ok(Box::new(ListMergeAccumulator::new( + element_type, + self.distinct, + ))) } fn state_fields(&self, args: StateFieldsArgs) -> Result> { @@ -137,7 +149,10 @@ impl ListMergeAccumulator { } fn current_list(&self) -> ScalarValue { - ScalarValue::List(ScalarValue::new_list_nullable(&self.buf, &self.element_type)) + ScalarValue::List(ScalarValue::new_list_nullable( + &self.buf, + &self.element_type, + )) } } @@ -214,7 +229,13 @@ mod tests { let inner = l.value(0); let typed = inner.as_any().downcast_ref::().unwrap(); (0..typed.len()) - .map(|i| if typed.is_null(i) { None } else { Some(typed.value(i)) }) + .map(|i| { + if typed.is_null(i) { + None + } else { + Some(typed.value(i)) + } + }) .collect() } other => panic!("expected List, got {other:?}"), @@ -226,7 +247,10 @@ mod tests { let mut acc = ListMergeAccumulator::new(DataType::Int32, false); let input = list_of_ints(&[&[Some(1), Some(2)], &[Some(3), Some(4), Some(5)]]); acc.update_batch(&[input]).unwrap(); - assert_eq!(extract_ints(acc.evaluate().unwrap()), vec![Some(1), Some(2), Some(3), Some(4), Some(5)]); + assert_eq!( + extract_ints(acc.evaluate().unwrap()), + vec![Some(1), Some(2), Some(3), Some(4), Some(5)] + ); } #[test] @@ -252,7 +276,10 @@ mod tests { let mut acc = ListMergeAccumulator::new(DataType::Int32, false); acc.update_batch(&[input]).unwrap(); - assert_eq!(extract_ints(acc.evaluate().unwrap()), vec![Some(1), Some(2), Some(3)]); + assert_eq!( + extract_ints(acc.evaluate().unwrap()), + vec![Some(1), Some(2), Some(3)] + ); } #[test] @@ -271,7 +298,9 @@ mod tests { ScalarValue::List(l) => { let inner = l.value(0); let typed = inner.as_any().downcast_ref::().unwrap(); - (0..typed.len()).map(|i| typed.value(i).to_string()).collect::>() + (0..typed.len()) + .map(|i| typed.value(i).to_string()) + .collect::>() } o => panic!("expected list, got {o:?}"), }; diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udaf/mod.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udaf/mod.rs index 1d595e9da61ce..54b408a6b8b53 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udaf/mod.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udaf/mod.rs @@ -12,6 +12,7 @@ use datafusion::execution::context::SessionContext; +pub mod approx_distinct_safe; pub mod internal_pattern; pub mod list_merge; pub mod os_count_distinct; @@ -22,4 +23,5 @@ pub fn register_all(ctx: &SessionContext) { list_merge::register_all(ctx); internal_pattern::register_all(ctx); os_count_distinct::register_all(ctx); + approx_distinct_safe::register_all(ctx); } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udaf/os_count_distinct.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udaf/os_count_distinct.rs index 2ecbd1d4cb2a5..fe914a7bc9d35 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udaf/os_count_distinct.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udaf/os_count_distinct.rs @@ -34,7 +34,9 @@ use datafusion::arrow::datatypes::{DataType, Field, FieldRef}; use datafusion::common::{exec_err, Result, ScalarValue}; use datafusion::execution::context::SessionContext; use datafusion::logical_expr::function::{AccumulatorArgs, StateFieldsArgs}; -use datafusion::logical_expr::{Accumulator, AggregateUDF, AggregateUDFImpl, Signature, Volatility}; +use datafusion::logical_expr::{ + Accumulator, AggregateUDF, AggregateUDFImpl, Signature, Volatility, +}; pub fn register_all(ctx: &SessionContext) { ctx.register_udaf(AggregateUDF::from(OsCountDistinctUdaf::new())); @@ -47,7 +49,9 @@ pub struct OsCountDistinctUdaf { impl OsCountDistinctUdaf { pub fn new() -> Self { - Self { signature: Signature::any(1, Volatility::Immutable) } + Self { + signature: Signature::any(1, Volatility::Immutable), + } } } @@ -109,7 +113,10 @@ impl AggregateUDFImpl for OsCountDistinctUdaf { DataType::List(_) | DataType::LargeList(_) | DataType::FixedSizeList(_, _), ); let element_type = inner_element_type(&raw_arg0); - Ok(Box::new(OsCountDistinctAccumulator::new(element_type, arg0_is_list))) + Ok(Box::new(OsCountDistinctAccumulator::new( + element_type, + arg0_is_list, + ))) } fn state_fields(&self, args: StateFieldsArgs) -> Result> { @@ -142,7 +149,11 @@ pub struct OsCountDistinctAccumulator { impl OsCountDistinctAccumulator { pub fn new(element_type: DataType, arg0_is_list: bool) -> Self { - Self { element_type, seen: HashSet::new(), arg0_is_list } + Self { + element_type, + seen: HashSet::new(), + arg0_is_list, + } } fn add_from_array(&mut self, col: &ArrayRef) -> Result<()> { @@ -300,14 +311,12 @@ mod tests { let mut shard1 = OsCountDistinctAccumulator::new(DataType::Utf8, false); shard1 .update_batch(&[ - Arc::new(StringArray::from(vec![Some("a"), Some("b"), Some("a")])) as ArrayRef + Arc::new(StringArray::from(vec![Some("a"), Some("b"), Some("a")])) as ArrayRef, ]) .unwrap(); let mut shard2 = OsCountDistinctAccumulator::new(DataType::Utf8, false); shard2 - .update_batch(&[ - Arc::new(StringArray::from(vec![Some("b"), Some("c")])) as ArrayRef - ]) + .update_batch(&[Arc::new(StringArray::from(vec![Some("b"), Some("c")])) as ArrayRef]) .unwrap(); let s1: ArrayRef = match shard1.state().unwrap().pop().unwrap() { ScalarValue::List(l) => l, diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udaf/take.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udaf/take.rs index 75fa30e439a3b..b135e05d70d0a 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udaf/take.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udaf/take.rs @@ -39,7 +39,9 @@ use datafusion::arrow::datatypes::{DataType, Field, FieldRef}; use datafusion::common::{exec_err, Result, ScalarValue}; use datafusion::execution::context::SessionContext; use datafusion::logical_expr::function::{AccumulatorArgs, StateFieldsArgs}; -use datafusion::logical_expr::{Accumulator, AggregateUDF, AggregateUDFImpl, Signature, Volatility}; +use datafusion::logical_expr::{ + Accumulator, AggregateUDF, AggregateUDFImpl, Signature, Volatility, +}; use datafusion::physical_expr::expressions::Literal; const DEFAULT_LIMIT: i64 = 10; @@ -134,7 +136,11 @@ impl AggregateUDFImpl for TakeUdaf { ); let element_type = inner_element_type(&raw_arg0); let limit = limit_from_args(&acc_args)?; - Ok(Box::new(TakeAccumulator::new(element_type, limit, arg0_is_list))) + Ok(Box::new(TakeAccumulator::new( + element_type, + limit, + arg0_is_list, + ))) } fn state_fields(&self, args: StateFieldsArgs) -> Result> { @@ -349,7 +355,13 @@ mod tests { let inner = l.value(0); let typed = inner.as_any().downcast_ref::().unwrap(); (0..typed.len()) - .map(|i| if typed.is_null(i) { None } else { Some(typed.value(i)) }) + .map(|i| { + if typed.is_null(i) { + None + } else { + Some(typed.value(i)) + } + }) .collect() } other => panic!("expected list scalar, got {other:?}"), @@ -388,7 +400,12 @@ mod tests { // Simulate Calcite's "literal materialised as a Project column" case: // limit is None at construction, accumulator reads row 0 of arg 1. let mut acc = TakeAccumulator::new(DataType::Int32, None, false); - let values: ArrayRef = Arc::new(Int32Array::from(vec![Some(10), Some(20), Some(30), Some(40)])); + let values: ArrayRef = Arc::new(Int32Array::from(vec![ + Some(10), + Some(20), + Some(30), + Some(40), + ])); let n_col: ArrayRef = Arc::new(Int32Array::from(vec![3, 3, 3, 3])); acc.update_batch(&[values, n_col]).unwrap(); let out = match acc.evaluate().unwrap() { @@ -429,7 +446,13 @@ mod tests { let inner = l.value(0); let typed = inner.as_any().downcast_ref::().unwrap(); (0..typed.len()) - .map(|i| if typed.is_null(i) { None } else { Some(typed.value(i)) }) + .map(|i| { + if typed.is_null(i) { + None + } else { + Some(typed.value(i)) + } + }) .collect::>() } o => panic!("expected list result, got {o:?}"), @@ -496,7 +519,13 @@ mod tests { let inner = l.value(0); let typed = inner.as_any().downcast_ref::().unwrap(); (0..typed.len()) - .map(|i| if typed.is_null(i) { None } else { Some(typed.value(i).to_string()) }) + .map(|i| { + if typed.is_null(i) { + None + } else { + Some(typed.value(i).to_string()) + } + }) .collect::>() } o => panic!("expected list result, got {o:?}"), diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/binary_to_base64.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/binary_to_base64.rs index f866cfbf1d6f5..0011bb1f37b42 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/binary_to_base64.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/binary_to_base64.rs @@ -24,7 +24,8 @@ use datafusion::arrow::datatypes::DataType; use datafusion::common::{exec_err, Result, ScalarValue}; use datafusion::execution::context::SessionContext; use datafusion::logical_expr::{ - ColumnarValue, ScalarFunctionArgs, ScalarUDF, ScalarUDFImpl, Signature, TypeSignature, Volatility, + ColumnarValue, ScalarFunctionArgs, ScalarUDF, ScalarUDFImpl, Signature, TypeSignature, + Volatility, }; pub fn register_all(ctx: &SessionContext) { @@ -82,25 +83,33 @@ impl ScalarUDFImpl for BinaryToBase64Udf { fn invoke_with_args(&self, args: ScalarFunctionArgs) -> Result { if args.args.len() != 1 { - return exec_err!("binary_to_base64 expects exactly 1 argument, got {}", args.args.len()); + return exec_err!( + "binary_to_base64 expects exactly 1 argument, got {}", + args.args.len() + ); } match &args.args[0] { ColumnarValue::Scalar(ScalarValue::Binary(opt)) => Ok(ColumnarValue::Scalar( ScalarValue::Utf8(opt.as_ref().map(|b| STANDARD.encode(b))), )), - ColumnarValue::Scalar(other) => exec_err!("binary_to_base64: expected Binary input, got {other:?}"), + ColumnarValue::Scalar(other) => { + exec_err!("binary_to_base64: expected Binary input, got {other:?}") + } ColumnarValue::Array(arr) => { - let bin = arr - .as_any() - .downcast_ref::() - .ok_or_else(|| { - datafusion::common::DataFusionError::Execution(format!( - "binary_to_base64: expected BinaryArray, got {:?}", - arr.data_type() - )) - })?; + let bin = arr.as_any().downcast_ref::().ok_or_else(|| { + datafusion::common::DataFusionError::Execution(format!( + "binary_to_base64: expected BinaryArray, got {:?}", + arr.data_type() + )) + })?; let out: StringArray = (0..bin.len()) - .map(|i| if bin.is_null(i) { None } else { Some(STANDARD.encode(bin.value(i))) }) + .map(|i| { + if bin.is_null(i) { + None + } else { + Some(STANDARD.encode(bin.value(i))) + } + }) .collect(); Ok(ColumnarValue::Array(Arc::new(out) as ArrayRef)) } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/conv.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/conv.rs index 58961ad5aa0a0..e72e1cc9668d9 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/conv.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/conv.rs @@ -222,12 +222,17 @@ mod tests { config_options: Arc::new(datafusion::config::ConfigOptions::new()), }; - let out = ConvUdf::new().invoke_with_args(args).expect("conv must accept Utf8View"); + let out = ConvUdf::new() + .invoke_with_args(args) + .expect("conv must accept Utf8View"); let arr = match out { ColumnarValue::Array(a) => a, _ => panic!("expected array output"), }; - let s = arr.as_any().downcast_ref::().expect("Utf8 output"); + let s = arr + .as_any() + .downcast_ref::() + .expect("Utf8 output"); assert_eq!(s.value(0), "1011"); // 11 base10 → base2 assert_eq!(s.value(1), "255"); // FF base16 → base10 assert!(s.is_null(2)); // null input → null diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/conversion/numeric_conversion.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/conversion/numeric_conversion.rs index f9ea2c4c0867f..ecb19968be68a 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/conversion/numeric_conversion.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/conversion/numeric_conversion.rs @@ -26,7 +26,8 @@ use datafusion::arrow::datatypes::DataType; use datafusion::common::{exec_err, Result, ScalarValue}; use datafusion::execution::context::SessionContext; use datafusion::logical_expr::{ - ColumnarValue, ScalarFunctionArgs, ScalarUDF, ScalarUDFImpl, Signature, TypeSignature, Volatility, + ColumnarValue, ScalarFunctionArgs, ScalarUDF, ScalarUDFImpl, Signature, TypeSignature, + Volatility, }; use once_cell::sync::Lazy; use regex::Regex; @@ -34,13 +35,27 @@ use regex::Regex; /// Register every conversion UDF (`num`, `auto`, `memk`, `rmcomma`, `rmunit`, /// `dur2sec`, `mstime`) on a session. pub fn register_all(ctx: &SessionContext) { - ctx.register_udf(ScalarUDF::from(NumericConversionUdf::new(NumericConversionFn::Num))); - ctx.register_udf(ScalarUDF::from(NumericConversionUdf::new(NumericConversionFn::Auto))); - ctx.register_udf(ScalarUDF::from(NumericConversionUdf::new(NumericConversionFn::Memk))); - ctx.register_udf(ScalarUDF::from(NumericConversionUdf::new(NumericConversionFn::Rmcomma))); - ctx.register_udf(ScalarUDF::from(NumericConversionUdf::new(NumericConversionFn::Rmunit))); - ctx.register_udf(ScalarUDF::from(NumericConversionUdf::new(NumericConversionFn::Dur2sec))); - ctx.register_udf(ScalarUDF::from(NumericConversionUdf::new(NumericConversionFn::Mstime))); + ctx.register_udf(ScalarUDF::from(NumericConversionUdf::new( + NumericConversionFn::Num, + ))); + ctx.register_udf(ScalarUDF::from(NumericConversionUdf::new( + NumericConversionFn::Auto, + ))); + ctx.register_udf(ScalarUDF::from(NumericConversionUdf::new( + NumericConversionFn::Memk, + ))); + ctx.register_udf(ScalarUDF::from(NumericConversionUdf::new( + NumericConversionFn::Rmcomma, + ))); + ctx.register_udf(ScalarUDF::from(NumericConversionUdf::new( + NumericConversionFn::Rmunit, + ))); + ctx.register_udf(ScalarUDF::from(NumericConversionUdf::new( + NumericConversionFn::Dur2sec, + ))); + ctx.register_udf(ScalarUDF::from(NumericConversionUdf::new( + NumericConversionFn::Mstime, + ))); } /// Which conversion function this UDF instance implements. @@ -167,7 +182,10 @@ impl ScalarUDFImpl for NumericConversionUdf { .map(|opt| opt.and_then(|s| self.kind.apply(s))) .collect(), other => { - return exec_err!("{}: expected Utf8 array, got {other:?}", self.kind.name()); + return exec_err!( + "{}: expected Utf8 array, got {other:?}", + self.kind.name() + ); } }; Ok(ColumnarValue::Array(Arc::new(out) as ArrayRef)) @@ -179,13 +197,13 @@ impl ScalarUDFImpl for NumericConversionUdf { // ── Per-function implementations ─────────────────────────────────────────────────────────── /// Gatekeeper for `num`. -static STARTS_WITH_SIGN_OR_DIGIT: Lazy = Lazy::new(|| Regex::new(r"^[+\-]?[\d.].*").unwrap()); +static STARTS_WITH_SIGN_OR_DIGIT: Lazy = + Lazy::new(|| Regex::new(r"^[+\-]?[\d.].*").unwrap()); /// Captures an optional sign, the numeric body (one of `digits`, `digits.digits`, `digits.`, or `.digits`), /// and an optional exponent. -static LEADING_NUMBER_WITH_UNIT: Lazy = Lazy::new(|| { - Regex::new(r"^([+\-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+\-]?\d+)?)(.*)$").unwrap() -}); +static LEADING_NUMBER_WITH_UNIT: Lazy = + Lazy::new(|| Regex::new(r"^([+\-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+\-]?\d+)?)(.*)$").unwrap()); /// Optional sign, number body, optional unit in {k,m,g} (case-insensitive). Bare number with no unit is accepted. static MEMK: Lazy = Lazy::new(|| Regex::new(r"^([+\-]?\d+\.?\d*)([kmgKMG])?$").unwrap()); @@ -194,14 +212,12 @@ static MEMK: Lazy = Lazy::new(|| Regex::new(r"^([+\-]?\d+\.?\d*)([kmgKMG] static CONTAINS_LETTER: Lazy = Lazy::new(|| Regex::new(r"[a-zA-Z]").unwrap()); /// `[D+]HH:MM:SS` — optional day count prefix separated by `+`. -static DUR2SEC: Lazy = Lazy::new(|| { - Regex::new(r"^(?:(\d+)\+)?(\d{1,2}):(\d{1,2}):(\d{1,2})$").unwrap() -}); +static DUR2SEC: Lazy = + Lazy::new(|| Regex::new(r"^(?:(\d+)\+)?(\d{1,2}):(\d{1,2}):(\d{1,2})$").unwrap()); /// Optional `MM:` prefix, required `SS`, optional `.SSS`. -static MSTIME: Lazy = Lazy::new(|| { - Regex::new(r"^(?:(\d{1,2}):)?(\d{1,2})(?:\.(\d{1,3}))?$").unwrap() -}); +static MSTIME: Lazy = + Lazy::new(|| Regex::new(r"^(?:(\d{1,2}):)?(\d{1,2})(?:\.(\d{1,3}))?$").unwrap()); const MB_TO_KB: f64 = 1024.0; const GB_TO_KB: f64 = 1024.0 * 1024.0; @@ -274,7 +290,12 @@ fn convert_dur2sec(s: &str) -> Option { return Some(n); } let caps = DUR2SEC.captures(s)?; - let days: u64 = caps.get(1).map(|m| m.as_str()).unwrap_or("0").parse().ok()?; + let days: u64 = caps + .get(1) + .map(|m| m.as_str()) + .unwrap_or("0") + .parse() + .ok()?; let hours: u64 = caps.get(2)?.as_str().parse().ok()?; let minutes: u64 = caps.get(3)?.as_str().parse().ok()?; let seconds: u64 = caps.get(4)?.as_str().parse().ok()?; @@ -291,7 +312,12 @@ fn convert_mstime(s: &str) -> Option { return Some(n); } let caps = MSTIME.captures(s)?; - let minutes: u64 = caps.get(1).map(|m| m.as_str()).unwrap_or("0").parse().ok()?; + let minutes: u64 = caps + .get(1) + .map(|m| m.as_str()) + .unwrap_or("0") + .parse() + .ok()?; let seconds: u64 = caps.get(2)?.as_str().parse().ok()?; if seconds >= 60 { return None; @@ -517,7 +543,12 @@ mod tests { NumericConversionFn::Dur2sec, NumericConversionFn::Mstime, ] { - assert_eq!(kind.apply(""), None, "{:?} should return None for empty", kind); + assert_eq!( + kind.apply(""), + None, + "{:?} should return None for empty", + kind + ); assert_eq!( kind.apply(" "), None, diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/conversion/time_conversion.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/conversion/time_conversion.rs index e8b5881738db8..afa0708b92235 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/conversion/time_conversion.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/conversion/time_conversion.rs @@ -6,7 +6,6 @@ * compatible open source license. */ - //! * `ctime(value, format)` — UNIX epoch seconds → formatted time string. Fractional seconds are //! preserved as `nanos` adjustment //! * `mktime(value, format)` — formatted time string → UNIX epoch seconds as double. If the @@ -21,12 +20,17 @@ use datafusion::arrow::datatypes::DataType; use datafusion::common::{exec_err, Result, ScalarValue}; use datafusion::execution::context::SessionContext; use datafusion::logical_expr::{ - ColumnarValue, ScalarFunctionArgs, ScalarUDF, ScalarUDFImpl, Signature, TypeSignature, Volatility, + ColumnarValue, ScalarFunctionArgs, ScalarUDF, ScalarUDFImpl, Signature, TypeSignature, + Volatility, }; pub fn register_all(ctx: &SessionContext) { - ctx.register_udf(ScalarUDF::from(TimeConversionUdf::new(TimeConversionFn::Ctime))); - ctx.register_udf(ScalarUDF::from(TimeConversionUdf::new(TimeConversionFn::Mktime))); + ctx.register_udf(ScalarUDF::from(TimeConversionUdf::new( + TimeConversionFn::Ctime, + ))); + ctx.register_udf(ScalarUDF::from(TimeConversionUdf::new( + TimeConversionFn::Mktime, + ))); } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -70,7 +74,10 @@ pub struct TimeConversionUdf { impl TimeConversionUdf { fn new(kind: TimeConversionFn) -> Self { - Self { kind, signature: signature() } + Self { + kind, + signature: signature(), + } } } @@ -220,9 +227,9 @@ fn extract_scalar_string(column: &ColumnarValue, ctx: &str) -> Result Ok(opt.clone()), ColumnarValue::Scalar(other) => exec_err!("{ctx}: expected Utf8 scalar, got {other:?}"), - ColumnarValue::Array(_) => exec_err!( - "{ctx}: format must be a scalar literal, got an array" - ), + ColumnarValue::Array(_) => { + exec_err!("{ctx}: format must be a scalar literal, got an array") + } } } @@ -253,19 +260,13 @@ mod tests { ctime_default("1066507633"), Some("10/18/2003 20:07:13".to_string()) ); - assert_eq!( - ctime_default("0"), - Some("01/01/1970 00:00:00".to_string()) - ); + assert_eq!(ctime_default("0"), Some("01/01/1970 00:00:00".to_string())); } #[test] fn ctime_formats_epoch_with_default_format() { // 1_700_000_000 = 2023-11-14 22:13:20 UTC. - let out = format_ctime( - "1700000000", - &Some("%m/%d/%Y %H:%M:%S".to_string()), - ); + let out = format_ctime("1700000000", &Some("%m/%d/%Y %H:%M:%S".to_string())); assert_eq!(out, Some("11/14/2023 22:13:20".to_string())); } @@ -313,14 +314,8 @@ mod tests { #[test] fn mktime_default_format_matches() { - assert_eq!( - mktime_default("10/18/2003 20:07:13"), - Some(1_066_507_633.0) - ); - assert_eq!( - mktime_default("01/01/2000 00:00:00"), - Some(946_684_800.0) - ); + assert_eq!(mktime_default("10/18/2003 20:07:13"), Some(1_066_507_633.0)); + assert_eq!(mktime_default("01/01/2000 00:00:00"), Some(946_684_800.0)); assert_eq!(mktime_default("1066473433"), Some(1_066_473_433.0)); } @@ -348,10 +343,7 @@ mod tests { Some(946_684_800.0) ); assert_eq!( - parse_mktime( - "2003-10-18 20:07:13", - &Some("invalid format".to_string()) - ), + parse_mktime("2003-10-18 20:07:13", &Some("invalid format".to_string())), None ); assert_eq!( @@ -371,7 +363,10 @@ mod tests { #[test] fn mktime_rejects_date_only_format() { - assert_eq!(parse_mktime("2023-11-14", &Some("%Y-%m-%d".to_string())), None); + assert_eq!( + parse_mktime("2023-11-14", &Some("%Y-%m-%d".to_string())), + None + ); } #[test] @@ -400,11 +395,7 @@ mod tests { #[test] fn mktime_roundtrips_through_ctime() { - let rendered = format_ctime( - "1700000000", - &Some("%Y-%m-%d %H:%M:%S".to_string()), - ) - .unwrap(); + let rendered = format_ctime("1700000000", &Some("%Y-%m-%d %H:%M:%S".to_string())).unwrap(); let back = parse_mktime(&rendered, &Some("%Y-%m-%d %H:%M:%S".to_string())); assert_eq!(back, Some(1_700_000_000.0)); } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/conversion/tonumber.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/conversion/tonumber.rs index 15dc22f885c96..90af83ad2d2c6 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/conversion/tonumber.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/conversion/tonumber.rs @@ -147,9 +147,14 @@ impl ScalarUDFImpl for ToNumberUdf { }; let values: ValueSource = match (&values_arr_ref, value_col) { (Some(arr), _) => ValueSource::Array(StringArrayView::from_array(arr)?), - (None, ColumnarValue::Scalar( - ScalarValue::Utf8(opt) | ScalarValue::LargeUtf8(opt) | ScalarValue::Utf8View(opt), - )) => ValueSource::Scalar(opt.as_deref()), + ( + None, + ColumnarValue::Scalar( + ScalarValue::Utf8(opt) + | ScalarValue::LargeUtf8(opt) + | ScalarValue::Utf8View(opt), + ), + ) => ValueSource::Scalar(opt.as_deref()), (None, other) => { return exec_err!("tonumber: value expected Utf8, got {other:?}"); } @@ -157,7 +162,10 @@ impl ScalarUDFImpl for ToNumberUdf { let mut builder = Float64Builder::with_capacity(n); for i in 0..n { - match values.at(i).and_then(|s| i64::from_str_radix(s, radix).ok()) { + match values + .at(i) + .and_then(|s| i64::from_str_radix(s, radix).ok()) + { Some(v) => builder.append_value(v as f64), None => builder.append_null(), } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/conversion/tostring.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/conversion/tostring.rs index bfc15210c4a18..e55e8a71b0e6a 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/conversion/tostring.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/conversion/tostring.rs @@ -30,7 +30,8 @@ use datafusion::arrow::datatypes::{DataType, Float64Type, Int64Type}; use datafusion::common::{exec_err, Result, ScalarValue}; use datafusion::execution::context::SessionContext; use datafusion::logical_expr::{ - ColumnarValue, ScalarFunctionArgs, ScalarUDF, ScalarUDFImpl, Signature, TypeSignature, Volatility, + ColumnarValue, ScalarFunctionArgs, ScalarUDF, ScalarUDFImpl, Signature, TypeSignature, + Volatility, }; pub fn register_all(ctx: &SessionContext) { @@ -148,7 +149,9 @@ impl ScalarUDFImpl for ToStringUdf { .collect(); Ok(ColumnarValue::Array(Arc::new(out) as ArrayRef)) } - other => exec_err!("tostring: expected Int64 or Float64 value array, got {other:?}"), + other => { + exec_err!("tostring: expected Int64 or Float64 value array, got {other:?}") + } }, } } @@ -189,8 +192,9 @@ fn format_at(format_col: &ColumnarValue, row: usize) -> Result> { exec_err!("tostring: format must be VARCHAR, got {other:?}") } ColumnarValue::Array(arr) => { - let view = crate::udf::json_common::StringArrayView::from_array(arr) - .map_err(|e| datafusion::common::DataFusionError::Execution(format!("tostring: {e}")))?; + let view = crate::udf::json_common::StringArrayView::from_array(arr).map_err(|e| { + datafusion::common::DataFusionError::Execution(format!("tostring: {e}")) + })?; Ok(view.cell(row).map(|s| s.to_string())) } } @@ -237,7 +241,9 @@ fn format_f64_as(value: f64, format: &str) -> String { // `BigDecimal.valueOf(42.0).toString() == "42.0"` → passed to `NumberFormat` with no // decimals would print `"42"`. let rendered = format!("{value}"); - rendered.strip_suffix(".0").map_or(rendered.clone(), |s| s.to_string()) + rendered + .strip_suffix(".0") + .map_or(rendered.clone(), |s| s.to_string()) } } } @@ -408,7 +414,8 @@ mod tests { #[test] fn binary_matches_biginteger_tostring_2() { - let out = invoke_scalar(ScalarValue::Int64(Some(39225)), DataType::Int64, "binary").unwrap(); + let out = + invoke_scalar(ScalarValue::Int64(Some(39225)), DataType::Int64, "binary").unwrap(); assert_eq!(utf8(out), "1001100100111001"); } @@ -420,7 +427,8 @@ mod tests { #[test] fn commas_integer_doc_example() { - let out = invoke_scalar(ScalarValue::Int64(Some(39225)), DataType::Int64, "commas").unwrap(); + let out = + invoke_scalar(ScalarValue::Int64(Some(39225)), DataType::Int64, "commas").unwrap(); assert_eq!(utf8(out), "39,225"); } @@ -448,13 +456,15 @@ mod tests { #[test] fn duration_seconds_doc_example() { - let out = invoke_scalar(ScalarValue::Int64(Some(6500)), DataType::Int64, "duration").unwrap(); + let out = + invoke_scalar(ScalarValue::Int64(Some(6500)), DataType::Int64, "duration").unwrap(); assert_eq!(utf8(out), "01:48:20"); } #[test] fn duration_bigdecimal_positive_example() { - let out = invoke_scalar(ScalarValue::Int64(Some(3661)), DataType::Int64, "duration").unwrap(); + let out = + invoke_scalar(ScalarValue::Int64(Some(3661)), DataType::Int64, "duration").unwrap(); assert_eq!(utf8(out), "01:01:01"); } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/convert_tz.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/convert_tz.rs index 7e62d252c21b2..eb0856ba4f47d 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/convert_tz.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/convert_tz.rs @@ -131,20 +131,26 @@ impl ScalarUDFImpl for ConvertTzUdf { // Only materialize column-valued tz operands; for scalars the parsed // TzSpec is already in hand. Keep the ArrayRef alive alongside the // view — StringArrayView borrows from the underlying buffer. - let from_arr_ref: Option = if from_scalar.is_none() && matches!(&args.args[1], ColumnarValue::Array(_)) { - Some(args.args[1].clone().into_array(n)?) - } else { - None - }; - let to_arr_ref: Option = if to_scalar.is_none() && matches!(&args.args[2], ColumnarValue::Array(_)) { - Some(args.args[2].clone().into_array(n)?) - } else { - None - }; - let from_array: Option> = - from_arr_ref.as_ref().map(StringArrayView::from_array).transpose()?; - let to_array: Option> = - to_arr_ref.as_ref().map(StringArrayView::from_array).transpose()?; + let from_arr_ref: Option = + if from_scalar.is_none() && matches!(&args.args[1], ColumnarValue::Array(_)) { + Some(args.args[1].clone().into_array(n)?) + } else { + None + }; + let to_arr_ref: Option = + if to_scalar.is_none() && matches!(&args.args[2], ColumnarValue::Array(_)) { + Some(args.args[2].clone().into_array(n)?) + } else { + None + }; + let from_array: Option> = from_arr_ref + .as_ref() + .map(StringArrayView::from_array) + .transpose()?; + let to_array: Option> = to_arr_ref + .as_ref() + .map(StringArrayView::from_array) + .transpose()?; let mut builder = TimestampMillisecondBuilder::with_capacity(n); for i in 0..n { @@ -195,7 +201,9 @@ impl ScalarUDFImpl for ConvertTzUdf { fn scalar_tz(cv: &ColumnarValue) -> Option { if let ColumnarValue::Scalar(sv) = cv { let s = match sv { - ScalarValue::Utf8(opt) | ScalarValue::LargeUtf8(opt) | ScalarValue::Utf8View(opt) => opt.as_deref(), + ScalarValue::Utf8(opt) | ScalarValue::LargeUtf8(opt) | ScalarValue::Utf8View(opt) => { + opt.as_deref() + } _ => None, }; return s.and_then(parse_tz); @@ -294,19 +302,18 @@ fn offset_seconds_at(tz: &TzSpec, naive: &NaiveDateTime) -> Option { /// the input. We reconstruct the instant from `ts_millis` + `from_offset` (since /// `ts_millis` is a wall clock in from_tz), then look up to_tz's offset at that /// instant — DST-correct even across transitions. -fn offset_seconds_at_instant( - tz: &TzSpec, - ts_millis: i64, - from_offset_seconds: i32, -) -> Option { +fn offset_seconds_at_instant(tz: &TzSpec, ts_millis: i64, from_offset_seconds: i32) -> Option { match tz { TzSpec::Offset(o) => Some(*o), TzSpec::Iana(z) => { // instant_utc_millis = wall_millis - from_offset_millis - let instant_millis = - ts_millis.checked_sub((from_offset_seconds as i64) * 1_000)?; + let instant_millis = ts_millis.checked_sub((from_offset_seconds as i64) * 1_000)?; let instant = DateTime::::from_timestamp_millis(instant_millis)?; - Some(z.offset_from_utc_datetime(&instant.naive_utc()).fix().local_minus_utc()) + Some( + z.offset_from_utc_datetime(&instant.naive_utc()) + .fix() + .local_minus_utc(), + ) } } } @@ -478,7 +485,12 @@ mod tests { let udf = ConvertTzUdf::new(); assert!(udf.coerce_types(&[DataType::Utf8]).is_err()); assert!(udf - .coerce_types(&[DataType::Utf8, DataType::Utf8, DataType::Utf8, DataType::Utf8]) + .coerce_types(&[ + DataType::Utf8, + DataType::Utf8, + DataType::Utf8, + DataType::Utf8 + ]) .is_err()); } @@ -486,11 +498,7 @@ mod tests { #[test] fn invoke_nulls_and_bad_tz_propagate() { let udf = ConvertTzUdf::new(); - let ts = TimestampMillisecondArray::from(vec![ - Some(1_704_456_000_000), - None, - Some(0), - ]); + let ts = TimestampMillisecondArray::from(vec![Some(1_704_456_000_000), None, Some(0)]); let from = StringArray::from(vec![ Some("+00:00"), Some("UTC"), diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/crc32.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/crc32.rs index b8875f12d123d..1066979e1f0d8 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/crc32.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/crc32.rs @@ -16,7 +16,8 @@ use datafusion::arrow::datatypes::DataType; use datafusion::common::{exec_err, Result, ScalarValue}; use datafusion::execution::context::SessionContext; use datafusion::logical_expr::{ - ColumnarValue, ScalarFunctionArgs, ScalarUDF, ScalarUDFImpl, Signature, TypeSignature, Volatility, + ColumnarValue, ScalarFunctionArgs, ScalarUDF, ScalarUDFImpl, Signature, TypeSignature, + Volatility, }; pub fn register_all(ctx: &SessionContext) { @@ -178,11 +179,7 @@ mod tests { fn array_input_preserves_null_mask() { let u = udf(); let return_field = Arc::new(Field::new(u.name(), DataType::Int64, true)); - let values: ArrayRef = Arc::new(StringArray::from(vec![ - Some("a"), - None, - Some(""), - ])); + let values: ArrayRef = Arc::new(StringArray::from(vec![Some("a"), None, Some("")])); let args = ScalarFunctionArgs { args: vec![ColumnarValue::Array(values)], arg_fields: vec![Arc::new(Field::new("v", DataType::Utf8, true))], diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/date_format.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/date_format.rs index 014fbf1d661da..712ad921d9025 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/date_format.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/date_format.rs @@ -14,7 +14,9 @@ use std::sync::Arc; use super::udf_identity; use chrono::{TimeZone, Utc}; -use datafusion::arrow::array::{Array, ArrayRef, AsArray, StringBuilder, TimestampMicrosecondArray}; +use datafusion::arrow::array::{ + Array, ArrayRef, AsArray, StringBuilder, TimestampMicrosecondArray, +}; use datafusion::arrow::datatypes::{DataType, TimeUnit}; use datafusion::common::{exec_err, plan_err, Result, ScalarValue}; use datafusion::execution::context::SessionContext; @@ -67,7 +69,11 @@ impl ScalarUDFImpl for DateFormatUdf { DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View => { DataType::Timestamp(TimeUnit::Microsecond, None) } - other => return plan_err!("date_format: arg 0 expected timestamp/date/string, got {other:?}"), + other => { + return plan_err!( + "date_format: arg 0 expected timestamp/date/string, got {other:?}" + ) + } }; let fmt = match &arg_types[1] { DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View => DataType::Utf8, @@ -127,11 +133,19 @@ pub(crate) fn format_dispatch( let fmt_opt = match f_arr.data_type() { DataType::Utf8 => { let a = f_arr.as_string::(); - if a.is_null(i) { None } else { Some(a.value(i).to_string()) } + if a.is_null(i) { + None + } else { + Some(a.value(i).to_string()) + } } DataType::LargeUtf8 => { let a = f_arr.as_string::(); - if a.is_null(i) { None } else { Some(a.value(i).to_string()) } + if a.is_null(i) { + None + } else { + Some(a.value(i).to_string()) + } } other => return exec_err!("{udf}: format array has unexpected type {other:?}"), }; diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/extract.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/extract.rs index 6e15ebeeae604..5b69d624e3144 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/extract.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/extract.rs @@ -78,7 +78,11 @@ impl ScalarUDFImpl for ExtractUdf { DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View => DataType::Utf8, // Time32/Time64: handle directly to avoid a Time→Timestamp cast kernel. DataType::Time32(_) | DataType::Time64(_) => arg_types[1].clone(), - other => return plan_err!("extract: arg 1 expected timestamp/date/time/string, got {other:?}"), + other => { + return plan_err!( + "extract: arg 1 expected timestamp/date/time/string, got {other:?}" + ) + } }; Ok(vec![unit, ts]) } @@ -100,7 +104,9 @@ impl ScalarUDFImpl for ExtractUdf { ScalarValue::Time32Millisecond(v) => v.map(|ms| (ms as i64) * 1_000), ScalarValue::Time64Microsecond(v) => *v, ScalarValue::Time64Nanosecond(v) => v.map(|ns| ns / 1_000), - ScalarValue::Utf8(v) | ScalarValue::LargeUtf8(v) => v.as_deref().and_then(parse_string_to_micros), + ScalarValue::Utf8(v) | ScalarValue::LargeUtf8(v) => { + v.as_deref().and_then(parse_string_to_micros) + } other => return exec_err!("extract: unsupported ts scalar: {other:?}"), }; let out = match (unit_str, micros) { @@ -257,7 +263,9 @@ fn unit_at(array: &ArrayRef, row: usize) -> Result> { fn compute(unit: &str, micros: i64) -> Option { let seconds = micros.div_euclid(1_000_000); let micro_fraction = micros.rem_euclid(1_000_000) as u32; - let dt = Utc.timestamp_opt(seconds, micro_fraction * 1_000).single()?; + let dt = Utc + .timestamp_opt(seconds, micro_fraction * 1_000) + .single()?; extract_for_unit(&unit.to_ascii_uppercase(), dt) } @@ -320,22 +328,38 @@ mod tests { } fn us(y: i32, mo: u32, d: u32, h: u32, mi: u32, s: u32) -> i64 { - Utc.with_ymd_and_hms(y, mo, d, h, mi, s).unwrap().timestamp() * 1_000_000 + Utc.with_ymd_and_hms(y, mo, d, h, mi, s) + .unwrap() + .timestamp() + * 1_000_000 } #[test] fn simple_and_composite_units_on_reference_sample() { // Reference sample (Sunday, 2020 leap year, ISO week 11): for (unit, want) in [ - ("MICROSECOND", 123_456_i64), ("SECOND", 45), ("MINUTE", 30), ("HOUR", 10), - ("DAY", 15), ("MONTH", 3), ("QUARTER", 1), ("YEAR", 2020), - ("DOY", 75), ("WEEK", 11), ("DOW", 7), // 2020-03-15 is a Sunday (ISO DOW=7) - ("DAY_MICROSECOND", 15_103_045_123_456), ("DAY_SECOND", 15_103_045), - ("DAY_MINUTE", 151_030), ("DAY_HOUR", 1510), - ("HOUR_MICROSECOND", 103_045_123_456), ("HOUR_SECOND", 103_045), + ("MICROSECOND", 123_456_i64), + ("SECOND", 45), + ("MINUTE", 30), + ("HOUR", 10), + ("DAY", 15), + ("MONTH", 3), + ("QUARTER", 1), + ("YEAR", 2020), + ("DOY", 75), + ("WEEK", 11), + ("DOW", 7), // 2020-03-15 is a Sunday (ISO DOW=7) + ("DAY_MICROSECOND", 15_103_045_123_456), + ("DAY_SECOND", 15_103_045), + ("DAY_MINUTE", 151_030), + ("DAY_HOUR", 1510), + ("HOUR_MICROSECOND", 103_045_123_456), + ("HOUR_SECOND", 103_045), ("HOUR_MINUTE", 1030), - ("MINUTE_MICROSECOND", 3_045_123_456), ("MINUTE_SECOND", 3045), - ("SECOND_MICROSECOND", 45_123_456), ("YEAR_MONTH", 202_003), + ("MINUTE_MICROSECOND", 3_045_123_456), + ("MINUTE_SECOND", 3045), + ("SECOND_MICROSECOND", 45_123_456), + ("YEAR_MONTH", 202_003), ] { assert_eq!(eval(unit), Some(want), "unit={unit}"); } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/from_unixtime.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/from_unixtime.rs index ebee8e3f95ec1..8510a42380b7e 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/from_unixtime.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/from_unixtime.rs @@ -67,10 +67,7 @@ impl ScalarUDFImpl for FromUnixtimeUdf { fn invoke_with_args(&self, args: ScalarFunctionArgs) -> Result { if args.args.len() != 1 { - return exec_err!( - "from_unixtime expects 1 argument, got {}", - args.args.len() - ); + return exec_err!("from_unixtime expects 1 argument, got {}", args.args.len()); } let n = args.number_rows; @@ -112,7 +109,13 @@ mod tests { #[test] fn rejects_out_of_range_and_non_finite() { - for v in [-0.1, MAX_UNIX_SECONDS_EXCLUSIVE, MAX_UNIX_SECONDS_EXCLUSIVE + 1.0, f64::NAN, f64::INFINITY] { + for v in [ + -0.1, + MAX_UNIX_SECONDS_EXCLUSIVE, + MAX_UNIX_SECONDS_EXCLUSIVE + 1.0, + f64::NAN, + f64::INFINITY, + ] { assert_eq!(to_micros(v), None, "v={v}"); } } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/grok.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/grok.rs index 9cd58ad1026e9..b85b127d0f058 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/grok.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/grok.rs @@ -51,7 +51,9 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::LazyLock; -use datafusion::arrow::array::{Array, ArrayRef, MapBuilder, MapFieldNames, StringArray, StringBuilder}; +use datafusion::arrow::array::{ + Array, ArrayRef, MapBuilder, MapFieldNames, StringArray, StringBuilder, +}; use datafusion::arrow::datatypes::{DataType, Field}; use datafusion::common::{plan_err, ScalarValue}; use datafusion::error::{DataFusionError, Result}; @@ -87,8 +89,9 @@ static GROK_REFERENCE: LazyLock = LazyLock::new(|| { /// Matches the synthetic `(?` capture-group openers in a resolved grok /// regex — ported from `GrokUtils.NAMED_REGEX`. Used to recover the group order /// for output, mirroring `GrokUtils.getNameGroups`. -static NAMED_GROUP: LazyLock = - LazyLock::new(|| Regex::new(r"\(\?<([a-zA-Z][a-zA-Z0-9]*)>").expect("NAMED_GROUP is a valid regex")); +static NAMED_GROUP: LazyLock = LazyLock::new(|| { + Regex::new(r"\(\?<([a-zA-Z][a-zA-Z0-9]*)>").expect("NAMED_GROUP is a valid regex") +}); /// Parsed dictionary: pattern name → definition. Built once. static DICTIONARY: LazyLock> = LazyLock::new(|| { @@ -375,9 +378,10 @@ impl ScalarUDFImpl for GrokUdf { // fancy-regex returns Result>; a runtime regex // error (e.g. backtrack-limit) surfaces as a DataFusion error // rather than a silent miss. - resolved.regex.captures(input.value(i)).map_err(|e| { - DataFusionError::Execution(format!("grok: match failed: {e}")) - })? + resolved + .regex + .captures(input.value(i)) + .map_err(|e| DataFusionError::Execution(format!("grok: match failed: {e}")))? }; for (field, group) in &fields { builder.keys().append_value(field); @@ -475,8 +479,15 @@ mod tests { fn number_greedydata_with_lookbehind_and_atomic_group() { // testGrokAddressOverriding: "%{NUMBER} %{GREEDYDATA:address}" — NUMBER → // BASE10NUM uses lookbehind + atomic group (regex crate would reject). - let map = run_grok(vec![Some("880 Holmes Lane")], "%{NUMBER} %{GREEDYDATA:address}").unwrap(); - assert_eq!(value_for(&map, 0, "address").as_deref(), Some("Holmes Lane")); + let map = run_grok( + vec![Some("880 Holmes Lane")], + "%{NUMBER} %{GREEDYDATA:address}", + ) + .unwrap(); + assert_eq!( + value_for(&map, 0, "address").as_deref(), + Some("Holmes Lane") + ); } #[test] @@ -501,7 +512,11 @@ mod tests { #[test] fn unwanted_groups_are_dropped() { // NUMBER has no subname → UNWANTED → must not appear as an output key. - let map = run_grok(vec![Some("880 Holmes Lane")], "%{NUMBER} %{GREEDYDATA:address}").unwrap(); + let map = run_grok( + vec![Some("880 Holmes Lane")], + "%{NUMBER} %{GREEDYDATA:address}", + ) + .unwrap(); let keys: Vec = row_entries(&map, 0).into_iter().map(|(k, _)| k).collect(); assert!(keys.contains(&"address".to_string())); assert!(!keys.contains(&"UNWANTED".to_string())); diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/ip_to_string.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/ip_to_string.rs index 672a47558b53a..d06a6d0ab781f 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/ip_to_string.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/ip_to_string.rs @@ -32,7 +32,8 @@ use datafusion::arrow::datatypes::DataType; use datafusion::common::{exec_err, Result, ScalarValue}; use datafusion::execution::context::SessionContext; use datafusion::logical_expr::{ - ColumnarValue, ScalarFunctionArgs, ScalarUDF, ScalarUDFImpl, Signature, TypeSignature, Volatility, + ColumnarValue, ScalarFunctionArgs, ScalarUDF, ScalarUDFImpl, Signature, TypeSignature, + Volatility, }; pub fn register_all(ctx: &SessionContext) { @@ -90,25 +91,33 @@ impl ScalarUDFImpl for IpToStringUdf { fn invoke_with_args(&self, args: ScalarFunctionArgs) -> Result { if args.args.len() != 1 { - return exec_err!("ip_to_string expects exactly 1 argument, got {}", args.args.len()); + return exec_err!( + "ip_to_string expects exactly 1 argument, got {}", + args.args.len() + ); } match &args.args[0] { ColumnarValue::Scalar(ScalarValue::Binary(opt)) => Ok(ColumnarValue::Scalar( ScalarValue::Utf8(opt.as_ref().and_then(|b| format_ip_bytes(b))), )), - ColumnarValue::Scalar(other) => exec_err!("ip_to_string: expected Binary input, got {other:?}"), + ColumnarValue::Scalar(other) => { + exec_err!("ip_to_string: expected Binary input, got {other:?}") + } ColumnarValue::Array(arr) => { - let bin = arr - .as_any() - .downcast_ref::() - .ok_or_else(|| { - datafusion::common::DataFusionError::Execution(format!( - "ip_to_string: expected BinaryArray, got {:?}", - arr.data_type() - )) - })?; + let bin = arr.as_any().downcast_ref::().ok_or_else(|| { + datafusion::common::DataFusionError::Execution(format!( + "ip_to_string: expected BinaryArray, got {:?}", + arr.data_type() + )) + })?; let out: StringArray = (0..bin.len()) - .map(|i| if bin.is_null(i) { None } else { format_ip_bytes(bin.value(i)) }) + .map(|i| { + if bin.is_null(i) { + None + } else { + format_ip_bytes(bin.value(i)) + } + }) .collect(); Ok(ColumnarValue::Array(Arc::new(out) as ArrayRef)) } @@ -125,7 +134,10 @@ fn format_ip_bytes(bytes: &[u8]) -> Option { } // IPv4-mapped IPv6: 80 bits of zero, then 0xffff, then 4 bytes of IPv4. if bytes[..10].iter().all(|&b| b == 0) && bytes[10] == 0xff && bytes[11] == 0xff { - return Some(format!("{}.{}.{}.{}", bytes[12], bytes[13], bytes[14], bytes[15])); + return Some(format!( + "{}.{}.{}.{}", + bytes[12], bytes[13], bytes[14], bytes[15] + )); } let arr: [u8; 16] = bytes.try_into().expect("checked length above"); Some(Ipv6Addr::from(arr).to_string()) diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/item.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/item.rs index 6308dcbe6dc30..a40e9374d831d 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/item.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/item.rs @@ -29,9 +29,7 @@ use std::sync::Arc; -use datafusion::arrow::array::{ - Array, ArrayRef, MapArray, StringArray, StringBuilder, -}; +use datafusion::arrow::array::{Array, ArrayRef, MapArray, StringArray, StringBuilder}; use datafusion::arrow::datatypes::DataType; use datafusion::common::{plan_err, ScalarValue}; use datafusion::error::{DataFusionError, Result}; @@ -92,16 +90,11 @@ impl ScalarUDFImpl for ItemUdf { // canonicalises to Utf8 so column-valued keys (string view, large // string) work uniformly. if !matches!(arg_types[0], DataType::Map(_, _)) { - return plan_err!( - "item: arg 0 must be a Map type, got {:?}", - arg_types[0] - ); + return plan_err!("item: arg 0 must be a Map type, got {:?}", arg_types[0]); } let key_target = match arg_types[1] { DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View => DataType::Utf8, - ref other => return plan_err!( - "item: arg 1 must be a string key, got {other:?}" - ), + ref other => return plan_err!("item: arg 1 must be a string key, got {other:?}"), }; Ok(vec![arg_types[0].clone(), key_target]) } @@ -113,13 +106,12 @@ impl ScalarUDFImpl for ItemUdf { let n = args.number_rows; let map_arr = args.args[0].clone().into_array(n)?; - let map = map_arr - .as_any() - .downcast_ref::() - .ok_or_else(|| DataFusionError::Internal(format!( + let map = map_arr.as_any().downcast_ref::().ok_or_else(|| { + DataFusionError::Internal(format!( "item: arg 0 expected MapArray, got {:?}", map_arr.data_type(), - )))?; + )) + })?; // Fast-path the common case: literal key (every PPL parse → ITEM call // hits this, since the named-group identifier is a string literal). @@ -160,16 +152,12 @@ impl ScalarUDFImpl for ItemUdf { .column(0) .as_any() .downcast_ref::() - .ok_or_else(|| DataFusionError::Internal( - "item: map keys are not Utf8".into(), - ))?; + .ok_or_else(|| DataFusionError::Internal("item: map keys are not Utf8".into()))?; let values = entries .column(1) .as_any() .downcast_ref::() - .ok_or_else(|| DataFusionError::Internal( - "item: map values are not Utf8".into(), - ))?; + .ok_or_else(|| DataFusionError::Internal("item: map values are not Utf8".into()))?; let mut found = false; for j in 0..entries.len() { @@ -261,21 +249,30 @@ mod tests { #[test] fn returns_value_for_present_key() { let map = build_map(vec![Some(vec![("a", "1"), ("b", "2")])]); - let out = run(map, ColumnarValue::Scalar(ScalarValue::Utf8(Some("a".into())))); + let out = run( + map, + ColumnarValue::Scalar(ScalarValue::Utf8(Some("a".into()))), + ); assert_eq!(out.value(0), "1"); } #[test] fn returns_null_for_absent_key() { let map = build_map(vec![Some(vec![("a", "1")])]); - let out = run(map, ColumnarValue::Scalar(ScalarValue::Utf8(Some("missing".into())))); + let out = run( + map, + ColumnarValue::Scalar(ScalarValue::Utf8(Some("missing".into()))), + ); assert!(out.is_null(0)); } #[test] fn returns_null_for_null_map_cell() { let map = build_map(vec![None]); - let out = run(map, ColumnarValue::Scalar(ScalarValue::Utf8(Some("a".into())))); + let out = run( + map, + ColumnarValue::Scalar(ScalarValue::Utf8(Some("a".into()))), + ); assert!(out.is_null(0)); } @@ -335,9 +332,7 @@ mod tests { false, )); let map_dt = DataType::Map(entries, false); - let err = udf - .coerce_types(&[map_dt, DataType::Int32]) - .unwrap_err(); + let err = udf.coerce_types(&[map_dt, DataType::Int32]).unwrap_err(); assert!(err.to_string().contains("must be a string key")); } } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json.rs index bac65fb0cf98c..89bd828a19342 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json.rs @@ -37,7 +37,9 @@ pub struct JsonUdf { impl JsonUdf { pub fn new() -> Self { - Self { signature: Signature::user_defined(Volatility::Immutable) } + Self { + signature: Signature::user_defined(Volatility::Immutable), + } } } @@ -117,7 +119,10 @@ mod tests { #[test] fn return_type_is_utf8() { - assert_eq!(JsonUdf::new().return_type(&[DataType::Utf8]).unwrap(), DataType::Utf8); + assert_eq!( + JsonUdf::new().return_type(&[DataType::Utf8]).unwrap(), + DataType::Utf8 + ); } #[test] diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_append.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_append.rs index 7069f1cebf5cd..54f67c2f7dd09 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_append.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_append.rs @@ -26,7 +26,9 @@ use datafusion::logical_expr::{ }; use serde_json::Value; -use super::json_common::{parse, parse_ppl_segments, scalar_utf8, walk_mut, Segment, StringArrayView}; +use super::json_common::{ + parse, parse_ppl_segments, scalar_utf8, walk_mut, Segment, StringArrayView, +}; use super::{coerce_slot, CoerceMode}; const NAME: &str = "json_append"; @@ -97,8 +99,10 @@ impl ScalarUDFImpl for JsonAppendUdf { .iter() .map(|v| v.clone().into_array(n)) .collect::>()?; - let columns: Vec> = - arrays.iter().map(StringArrayView::from_array).collect::>()?; + let columns: Vec> = arrays + .iter() + .map(StringArrayView::from_array) + .collect::>()?; let mut b = StringBuilder::with_capacity(n, n * 16); let mut rest: Vec> = Vec::with_capacity(columns.len() - 1); diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_array.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_array.rs index 92f61feb40e54..4a0aa3ebf44c9 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_array.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_array.rs @@ -40,7 +40,9 @@ pub struct JsonArrayUdf { impl JsonArrayUdf { pub fn new() -> Self { - Self { signature: Signature::user_defined(Volatility::Immutable) } + Self { + signature: Signature::user_defined(Volatility::Immutable), + } } } @@ -70,7 +72,11 @@ impl ScalarUDFImpl for JsonArrayUdf { fn invoke_with_args(&self, args: ScalarFunctionArgs) -> Result { let n = args.number_rows; - if args.args.iter().all(|v| matches!(v, ColumnarValue::Scalar(_))) { + if args + .args + .iter() + .all(|v| matches!(v, ColumnarValue::Scalar(_))) + { let cells: Vec> = args.args.iter().map(scalar_utf8).collect(); let out = build_array(&cells); return Ok(ColumnarValue::Scalar(ScalarValue::Utf8(out))); @@ -81,8 +87,10 @@ impl ScalarUDFImpl for JsonArrayUdf { .iter() .map(|v| v.clone().into_array(n)) .collect::>()?; - let columns: Vec> = - arrays.iter().map(StringArrayView::from_array).collect::>()?; + let columns: Vec> = arrays + .iter() + .map(StringArrayView::from_array) + .collect::>()?; let mut b = StringBuilder::with_capacity(n, n * 32); let mut row_cells: Vec> = Vec::with_capacity(columns.len()); @@ -126,12 +134,18 @@ mod tests { fn numeric_values_emit_unquoted() { // testJsonArray — six numerics in source order. let cells = [ - Some("n"), Some("1"), - Some("n"), Some("2"), - Some("n"), Some("0"), - Some("n"), Some("-1"), - Some("n"), Some("1.1"), - Some("n"), Some("-0.11"), + Some("n"), + Some("1"), + Some("n"), + Some("2"), + Some("n"), + Some("0"), + Some("n"), + Some("-1"), + Some("n"), + Some("1.1"), + Some("n"), + Some("-0.11"), ]; assert_eq!(build_array(&cells).as_deref(), Some("[1,2,0,-1,1.1,-0.11]")); } @@ -140,9 +154,12 @@ mod tests { fn mixed_types_round_trip_per_tag() { // testJsonArrayWithDifferentType — int + string + nested-json. let cells = [ - Some("n"), Some("1"), - Some("s"), Some("123"), - Some("j"), Some(r#"{"name":3}"#), + Some("n"), + Some("1"), + Some("s"), + Some("123"), + Some("j"), + Some(r#"{"name":3}"#), ]; assert_eq!( build_array(&cells).as_deref(), @@ -169,6 +186,9 @@ mod tests { #[test] fn return_type_is_utf8() { - assert_eq!(JsonArrayUdf::new().return_type(&[DataType::Utf8]).unwrap(), DataType::Utf8); + assert_eq!( + JsonArrayUdf::new().return_type(&[DataType::Utf8]).unwrap(), + DataType::Utf8 + ); } } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_array_length.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_array_length.rs index 7b713ff5f3d85..97dfd56810761 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_array_length.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_array_length.rs @@ -87,9 +87,9 @@ impl ScalarUDFImpl for JsonArrayLengthUdf { // Scalar fast-path: parse once, broadcast as scalar output. if let ColumnarValue::Scalar(sv) = &args.args[0] { let len = match sv { - ScalarValue::Utf8(opt) | ScalarValue::LargeUtf8(opt) | ScalarValue::Utf8View(opt) => { - opt.as_deref().and_then(json_array_len) - } + ScalarValue::Utf8(opt) + | ScalarValue::LargeUtf8(opt) + | ScalarValue::Utf8View(opt) => opt.as_deref().and_then(json_array_len), _ => None, }; return Ok(ColumnarValue::Scalar(ScalarValue::Int32(len))); @@ -199,7 +199,9 @@ mod tests { fn invoke_scalar_input_produces_scalar_output() { let udf = JsonArrayLengthUdf::new(); let args = ScalarFunctionArgs { - args: vec![ColumnarValue::Scalar(ScalarValue::Utf8(Some("[1,2,3,4]".into())))], + args: vec![ColumnarValue::Scalar(ScalarValue::Utf8(Some( + "[1,2,3,4]".into(), + )))], number_rows: 1, arg_fields: vec![], return_field: Arc::new(Field::new("out", DataType::Int32, true)), diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_common.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_common.rs index c5ed75afa5c07..dc875f532bd77 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_common.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_common.rs @@ -170,7 +170,9 @@ where /// Standard arity guard. pub(crate) fn check_arity(udf: &str, observed: usize, expected: usize) -> Result<()> { (observed == expected).then_some(()).ok_or_else(|| { - DataFusionError::Plan(format!("{udf} expects {expected} arguments, got {observed}")) + DataFusionError::Plan(format!( + "{udf} expects {expected} arguments, got {observed}" + )) }) } @@ -187,13 +189,19 @@ impl<'a> StringArrayView<'a> { pub(crate) fn from_array(arr: &'a ArrayRef) -> Result { match arr.data_type() { DataType::Utf8 => Ok(Self::Utf8( - arr.as_any().downcast_ref::().expect("Utf8 downcast"), + arr.as_any() + .downcast_ref::() + .expect("Utf8 downcast"), )), DataType::LargeUtf8 => Ok(Self::LargeUtf8( - arr.as_any().downcast_ref::().expect("LargeUtf8 downcast"), + arr.as_any() + .downcast_ref::() + .expect("LargeUtf8 downcast"), )), DataType::Utf8View => Ok(Self::Utf8View( - arr.as_any().downcast_ref::().expect("Utf8View downcast"), + arr.as_any() + .downcast_ref::() + .expect("Utf8View downcast"), )), other => Err(DataFusionError::Internal(format!( "expected string array (Utf8/LargeUtf8/Utf8View), got {other:?}" @@ -241,7 +249,10 @@ mod tests { ] { assert_eq!(convert_ppl_path(input).unwrap(), want, "input={input}"); } - assert!(convert_ppl_path("a{0").unwrap_err().to_string().contains("Unmatched")); + assert!(convert_ppl_path("a{0") + .unwrap_err() + .to_string() + .contains("Unmatched")); } #[test] diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_delete.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_delete.rs index 60a9cff3a3373..5c8a5acdfa74b 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_delete.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_delete.rs @@ -25,7 +25,9 @@ use datafusion::logical_expr::{ }; use serde_json::Value; -use super::json_common::{parse, parse_ppl_segments, scalar_utf8, walk_mut, Segment, StringArrayView}; +use super::json_common::{ + parse, parse_ppl_segments, scalar_utf8, walk_mut, Segment, StringArrayView, +}; use super::{coerce_slot, CoerceMode}; const NAME: &str = "json_delete"; @@ -95,8 +97,10 @@ impl ScalarUDFImpl for JsonDeleteUdf { .iter() .map(|v| v.clone().into_array(n)) .collect::>()?; - let columns: Vec> = - arrays.iter().map(StringArrayView::from_array).collect::>()?; + let columns: Vec> = arrays + .iter() + .map(StringArrayView::from_array) + .collect::>()?; let mut b = StringBuilder::with_capacity(n, n * 16); let mut path_buf: Vec> = Vec::with_capacity(columns.len() - 1); diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_extend.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_extend.rs index 227da0d00f2ec..d9e60e0bcf190 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_extend.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_extend.rs @@ -30,7 +30,9 @@ use datafusion::logical_expr::{ }; use serde_json::Value; -use super::json_common::{parse, parse_ppl_segments, scalar_utf8, walk_mut, Segment, StringArrayView}; +use super::json_common::{ + parse, parse_ppl_segments, scalar_utf8, walk_mut, Segment, StringArrayView, +}; use super::{coerce_slot, CoerceMode}; const NAME: &str = "json_extend"; @@ -97,8 +99,10 @@ impl ScalarUDFImpl for JsonExtendUdf { .iter() .map(|v| v.clone().into_array(n)) .collect::>()?; - let columns: Vec> = - arrays.iter().map(StringArrayView::from_array).collect::>()?; + let columns: Vec> = arrays + .iter() + .map(StringArrayView::from_array) + .collect::>()?; let mut b = StringBuilder::with_capacity(n, n * 16); let mut rest: Vec> = Vec::with_capacity(columns.len() - 1); diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_extract.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_extract.rs index 724debd5d20ab..4639d78599c5b 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_extract.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_extract.rs @@ -99,8 +99,10 @@ impl ScalarUDFImpl for JsonExtractUdf { .iter() .map(|v| v.clone().into_array(n)) .collect::>()?; - let columns: Vec> = - arrays.iter().map(StringArrayView::from_array).collect::>()?; + let columns: Vec> = arrays + .iter() + .map(StringArrayView::from_array) + .collect::>()?; let mut b = StringBuilder::with_capacity(n, n * 16); let mut path_buf: Vec> = Vec::with_capacity(columns.len() - 1); diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_extract_all.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_extract_all.rs index 2a987138c4ea0..fb61014a5c38d 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_extract_all.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_extract_all.rs @@ -498,7 +498,9 @@ mod tests { #[test] fn return_type_is_map_utf8_utf8() { - let dt = JsonExtractAllUdf::new().return_type(&[DataType::Utf8]).unwrap(); + let dt = JsonExtractAllUdf::new() + .return_type(&[DataType::Utf8]) + .unwrap(); match dt { DataType::Map(field, sorted) => { assert!(!sorted); diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_keys.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_keys.rs index 740697f369871..e83ddbb68adce 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_keys.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_keys.rs @@ -38,7 +38,9 @@ pub struct JsonKeysUdf { impl JsonKeysUdf { pub fn new() -> Self { - Self { signature: Signature::user_defined(Volatility::Immutable) } + Self { + signature: Signature::user_defined(Volatility::Immutable), + } } } @@ -68,9 +70,9 @@ impl ScalarUDFImpl for JsonKeysUdf { if let ColumnarValue::Scalar(sv) = &args.args[0] { let keys = match sv { - ScalarValue::Utf8(Some(s)) | ScalarValue::LargeUtf8(Some(s)) | ScalarValue::Utf8View(Some(s)) => { - json_keys(s) - } + ScalarValue::Utf8(Some(s)) + | ScalarValue::LargeUtf8(Some(s)) + | ScalarValue::Utf8View(Some(s)) => json_keys(s), _ => None, }; return Ok(ColumnarValue::Scalar(ScalarValue::Utf8(keys))); @@ -134,7 +136,10 @@ mod tests { #[test] fn return_type_is_utf8() { - assert_eq!(JsonKeysUdf::new().return_type(&[DataType::Utf8]).unwrap(), DataType::Utf8); + assert_eq!( + JsonKeysUdf::new().return_type(&[DataType::Utf8]).unwrap(), + DataType::Utf8 + ); } #[test] diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_object.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_object.rs index ec234e75a03a4..e6fe69ef08953 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_object.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_object.rs @@ -51,7 +51,9 @@ pub struct JsonObjectUdf { impl JsonObjectUdf { pub fn new() -> Self { - Self { signature: Signature::user_defined(Volatility::Immutable) } + Self { + signature: Signature::user_defined(Volatility::Immutable), + } } } @@ -81,7 +83,11 @@ impl ScalarUDFImpl for JsonObjectUdf { fn invoke_with_args(&self, args: ScalarFunctionArgs) -> Result { let n = args.number_rows; - if args.args.iter().all(|v| matches!(v, ColumnarValue::Scalar(_))) { + if args + .args + .iter() + .all(|v| matches!(v, ColumnarValue::Scalar(_))) + { let cells: Vec> = args.args.iter().map(scalar_utf8).collect(); let out = build_object(&cells); return Ok(ColumnarValue::Scalar(ScalarValue::Utf8(out))); @@ -92,8 +98,10 @@ impl ScalarUDFImpl for JsonObjectUdf { .iter() .map(|v| v.clone().into_array(n)) .collect::>()?; - let columns: Vec> = - arrays.iter().map(StringArrayView::from_array).collect::>()?; + let columns: Vec> = arrays + .iter() + .map(StringArrayView::from_array) + .collect::>()?; let mut b = StringBuilder::with_capacity(n, n * 32); let mut row_cells: Vec> = Vec::with_capacity(columns.len()); @@ -193,6 +201,9 @@ mod tests { #[test] fn return_type_is_utf8() { - assert_eq!(JsonObjectUdf::new().return_type(&[DataType::Utf8]).unwrap(), DataType::Utf8); + assert_eq!( + JsonObjectUdf::new().return_type(&[DataType::Utf8]).unwrap(), + DataType::Utf8 + ); } } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_set.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_set.rs index 162f475455f8c..e236179cd0449 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_set.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_set.rs @@ -25,7 +25,9 @@ use datafusion::logical_expr::{ }; use serde_json::Value; -use super::json_common::{parse, parse_ppl_segments, scalar_utf8, walk_mut, Segment, StringArrayView}; +use super::json_common::{ + parse, parse_ppl_segments, scalar_utf8, walk_mut, Segment, StringArrayView, +}; use super::{coerce_slot, CoerceMode}; const NAME: &str = "json_set"; @@ -96,8 +98,10 @@ impl ScalarUDFImpl for JsonSetUdf { .iter() .map(|v| v.clone().into_array(n)) .collect::>()?; - let columns: Vec> = - arrays.iter().map(StringArrayView::from_array).collect::>()?; + let columns: Vec> = arrays + .iter() + .map(StringArrayView::from_array) + .collect::>()?; let mut b = StringBuilder::with_capacity(n, n * 16); let mut rest: Vec> = Vec::with_capacity(columns.len() - 1); diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_valid.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_valid.rs index 713645fffcd8f..5fc2a7f862bd0 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_valid.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/json_valid.rs @@ -229,7 +229,10 @@ mod tests { let arr = arr.as_any().downcast_ref::().unwrap(); assert!(!arr.is_null(1), "NULL input must produce FALSE, not NULL"); assert!(arr.value(0)); - assert!(!arr.value(1), "NULL input → FALSE (legacy JsonUtils.isValidJson)"); + assert!( + !arr.value(1), + "NULL input → FALSE (legacy JsonUtils.isValidJson)" + ); assert!(!arr.value(2)); assert!(arr.value(3)); // Empty string is VALID per the legacy Jackson contract (see empty_and_whitespace_are_valid). diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/makedate.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/makedate.rs index fcfc1f6b0b69a..34cbf2428b5f7 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/makedate.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/makedate.rs @@ -136,13 +136,17 @@ mod tests { // (year, doy) → (y, m, d) expected. for (y, doy, ey, em, ed) in [ (2024.0, 1.0, 2024, 1, 1), - (2024.0, 60.0, 2024, 2, 29), // 2024 leap; doy 60 = Feb 29 + (2024.0, 60.0, 2024, 2, 29), // 2024 leap; doy 60 = Feb 29 (2024.0, 366.0, 2024, 12, 31), - (0.0, 1.0, 2000, 1, 1), // year 0 remaps to 2000 - (2023.0, 366.0, 2024, 1, 1), // non-leap overflow cascades - (2024.4, 60.6, 2024, 3, 1), // fractional operands round: 2024, 61 + (0.0, 1.0, 2000, 1, 1), // year 0 remaps to 2000 + (2023.0, 366.0, 2024, 1, 1), // non-leap overflow cascades + (2024.4, 60.6, 2024, 3, 1), // fractional operands round: 2024, 61 ] { - assert_eq!(days_since_epoch(y, doy), Some(days(ey, em, ed)), "y={y} doy={doy}"); + assert_eq!( + days_since_epoch(y, doy), + Some(days(ey, em, ed)), + "y={y} doy={doy}" + ); } } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/maketime.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/maketime.rs index e31e1617f109e..2e4ef1820875f 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/maketime.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/maketime.rs @@ -68,7 +68,11 @@ impl ScalarUDFImpl for MaketimeUdf { coerce_args( "maketime", arg_types, - &[CoerceMode::Float64, CoerceMode::Float64, CoerceMode::Float64], + &[ + CoerceMode::Float64, + CoerceMode::Float64, + CoerceMode::Float64, + ], ) } @@ -90,9 +94,7 @@ impl ScalarUDFImpl for MaketimeUdf { (Some(h), Some(m), Some(s)) => micros_of_day(*h, *m, *s).map(|us| us * 1_000), _ => None, }; - return Ok(ColumnarValue::Scalar(ScalarValue::Time64Nanosecond( - nanos, - ))); + return Ok(ColumnarValue::Scalar(ScalarValue::Time64Nanosecond(nanos))); } let h = args.args[0].clone().into_array(n)?; diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/minspan_bucket.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/minspan_bucket.rs index 773e53f31c82e..7e51b5d4b2ba5 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/minspan_bucket.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/minspan_bucket.rs @@ -35,9 +35,7 @@ use std::sync::Arc; -use datafusion::arrow::array::{ - Array, ArrayRef, Float64Array, StringBuilder, -}; +use datafusion::arrow::array::{Array, ArrayRef, Float64Array, StringBuilder}; use datafusion::arrow::datatypes::DataType; use datafusion::common::plan_err; use datafusion::error::{DataFusionError, Result}; @@ -84,7 +82,10 @@ impl ScalarUDFImpl for MinspanBucketUdf { } fn return_type(&self, arg_types: &[DataType]) -> Result { if arg_types.len() != 4 { - return plan_err!("minspan_bucket expects 4 arguments, got {}", arg_types.len()); + return plan_err!( + "minspan_bucket expects 4 arguments, got {}", + arg_types.len() + ); } Ok(DataType::Utf8) } @@ -102,7 +103,10 @@ impl ScalarUDFImpl for MinspanBucketUdf { } fn invoke_with_args(&self, args: ScalarFunctionArgs) -> Result { if args.args.len() != 4 { - return plan_err!("minspan_bucket expects 4 arguments, got {}", args.args.len()); + return plan_err!( + "minspan_bucket expects 4 arguments, got {}", + args.args.len() + ); } let n = args.number_rows; let value = args.args[0].clone().into_array(n)?; @@ -121,8 +125,12 @@ impl ScalarUDFImpl for MinspanBucketUdf { builder.append_null(); continue; } - match calculate(value.value(i), min_span.value(i), range.value(i), max_value.value(i)) - { + match calculate( + value.value(i), + min_span.value(i), + range.value(i), + max_value.value(i), + ) { Some(s) => builder.append_value(&s), None => builder.append_null(), } @@ -159,7 +167,11 @@ fn calculate(value: f64, min_span: f64, range: f64, _max_value: f64) -> Option= minSpan`, i.e. data's natural order- // of-magnitude width is already at least minSpan, so use it; otherwise // use minspan_width so every bin is at least minSpan wide. - let width = if default_width >= min_span { default_width } else { minspan_width }; + let width = if default_width >= min_span { + default_width + } else { + minspan_width + }; if !width.is_finite() || width <= 0.0 { return None; } @@ -233,10 +245,7 @@ mod tests { // range=1.0 → defaultWidth=10^floor(0)=10^0=1 // 1 >= 0.05 → width=1 (integer) // value=0.7 → binStart=0, binEnd=1 → "0-1" - assert_eq!( - calculate(0.7, 0.05, 1.0, 1.0), - Some("0-1".to_string()) - ); + assert_eq!(calculate(0.7, 0.05, 1.0, 1.0), Some("0-1".to_string())); } // ── `defaultWidth < minSpan` branch (user floor wins) ────────────────── @@ -247,10 +256,7 @@ mod tests { // range=5 → defaultWidth=10^0=1 // 1 >= 3 false → width=10 // value=15 → binStart=10, binEnd=20 → "10-20" - assert_eq!( - calculate(15.0, 3.0, 5.0, 5.0), - Some("10-20".to_string()) - ); + assert_eq!(calculate(15.0, 3.0, 5.0, 5.0), Some("10-20".to_string())); } #[test] @@ -260,10 +266,7 @@ mod tests { // range=0.3 → defaultWidth=10^-1=0.1 // 0.1 >= 0.5 false → width=1 // value=42 → binStart=42, binEnd=43 → "42-43" - assert_eq!( - calculate(42.0, 0.5, 0.3, 0.3), - Some("42-43".to_string()) - ); + assert_eq!(calculate(42.0, 0.5, 0.3, 0.3), Some("42-43".to_string())); } // ── min_span guards ──────────────────────────────────────────────────── @@ -367,10 +370,18 @@ mod tests { config_options: Arc::new(Default::default()), }; let out = udf.invoke_with_args(args).unwrap(); - let ColumnarValue::Array(a) = out else { panic!("expected array") }; + let ColumnarValue::Array(a) = out else { + panic!("expected array") + }; let s = a.as_string::(); (0..n) - .map(|i| if s.is_null(i) { None } else { Some(s.value(i).to_string()) }) + .map(|i| { + if s.is_null(i) { + None + } else { + Some(s.value(i).to_string()) + } + }) .collect() } @@ -413,7 +424,12 @@ mod tests { fn coerce_types_rejects_non_numeric() { let udf = MinspanBucketUdf::new(); assert!(udf - .coerce_types(&[DataType::Utf8, DataType::Float64, DataType::Float64, DataType::Float64]) + .coerce_types(&[ + DataType::Utf8, + DataType::Float64, + DataType::Float64, + DataType::Float64 + ]) .is_err()); } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/mod.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/mod.rs index 1beb145504b4e..853b85fe13cd5 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/mod.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/mod.rs @@ -122,8 +122,8 @@ pub(crate) fn coerce_args( pub mod binary_to_base64; pub mod conv; -pub mod convert_tz; pub mod conversion; +pub mod convert_tz; pub mod crc32; pub mod date_format; pub mod extract; @@ -155,6 +155,7 @@ pub mod os_week; pub mod parse; pub mod pattern_parser; pub mod range_bucket; +pub mod reduce_eval; pub mod rex_extract; pub mod rex_extract_multi; pub mod rex_offset; @@ -164,7 +165,6 @@ pub mod str_to_date; pub mod strftime; pub mod time_format; pub mod width_bucket; -pub mod reduce_eval; // Dev note: if a freshly added UDF here fails at runtime with // "Unsupported function name: " despite the Java side being wired, the @@ -346,7 +346,10 @@ mod tests { fn utf8_passes_string_variant_through_unchanged() { for observed in [DataType::Utf8, DataType::LargeUtf8, DataType::Utf8View] { let result = coerce_slot("s", 0, &observed, CoerceMode::Utf8).unwrap(); - assert_eq!(result, observed, "CoerceMode::Utf8 should pass the variant through"); + assert_eq!( + result, observed, + "CoerceMode::Utf8 should pass the variant through" + ); } } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/mvappend.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/mvappend.rs index b6207d87badd9..4553a53fd4d79 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/mvappend.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/mvappend.rs @@ -39,8 +39,8 @@ use datafusion::arrow::array::{ Float32Array, Float32Builder, Float64Array, Float64Builder, GenericListArray, Int16Array, Int16Builder, Int32Array, Int32Builder, Int64Array, Int64Builder, Int8Array, Int8Builder, ListArray, ListBuilder, StringArray, StringBuilder, StringViewArray, StringViewBuilder, - UInt16Array, UInt16Builder, UInt32Array, UInt32Builder, UInt64Array, UInt64Builder, - UInt8Array, UInt8Builder, + UInt16Array, UInt16Builder, UInt32Array, UInt32Builder, UInt64Array, UInt64Builder, UInt8Array, + UInt8Builder, }; use datafusion::arrow::datatypes::{DataType, Field}; use datafusion::common::plan_err; @@ -88,8 +88,11 @@ impl ScalarUDFImpl for MvappendUdf { } // Java adapter pre-coerces every operand to either ARRAY or E for a // single E. Use whichever element type we see first. - let element_type = element_type(arg_types) - .ok_or_else(|| DataFusionError::Plan("mvappend: unable to determine element type from operand types".to_string()))?; + let element_type = element_type(arg_types).ok_or_else(|| { + DataFusionError::Plan( + "mvappend: unable to determine element type from operand types".to_string(), + ) + })?; Ok(DataType::List(Arc::new(Field::new( "item", element_type, @@ -109,8 +112,16 @@ impl ScalarUDFImpl for MvappendUdf { fn invoke_with_args(&self, args: ScalarFunctionArgs) -> Result { let n = args.number_rows; - let element_type = element_type(&args.arg_fields.iter().map(|f| f.data_type().clone()).collect::>()) - .ok_or_else(|| DataFusionError::Internal("mvappend: lost element type at invoke".to_string()))?; + let element_type = element_type( + &args + .arg_fields + .iter() + .map(|f| f.data_type().clone()) + .collect::>(), + ) + .ok_or_else(|| { + DataFusionError::Internal("mvappend: lost element type at invoke".to_string()) + })?; // Materialize each operand as an ArrayRef whose Arrow type is either // {element_type} or List. Scalar operands replicate to n rows. @@ -130,16 +141,17 @@ impl ScalarUDFImpl for MvappendUdf { if arr.is_null(row) { continue; } - if let Some(list_arr) = arr.as_any().downcast_ref::>() { + if let Some(list_arr) = arr.as_any().downcast_ref::>() + { // Iterate elements of the list at this row. let row_list = list_arr.value(row); - let typed = row_list - .as_any() - .downcast_ref::<$Scalar>() - .ok_or_else(|| DataFusionError::Internal(format!( - "mvappend: list element vector type mismatch ({:?})", - row_list.data_type() - )))?; + let typed = + row_list.as_any().downcast_ref::<$Scalar>().ok_or_else(|| { + DataFusionError::Internal(format!( + "mvappend: list element vector type mismatch ({:?})", + row_list.data_type() + )) + })?; for i in 0..typed.len() { if !typed.is_null(i) { builder.values().append_value(typed.value(i)); @@ -185,7 +197,9 @@ impl ScalarUDFImpl for MvappendUdf { DataType::Decimal128(precision, scale) => { let inner = Decimal128Builder::new() .with_precision_and_scale(*precision, *scale) - .map_err(|e| DataFusionError::Plan(format!("mvappend: decimal builder: {e}")))?; + .map_err(|e| { + DataFusionError::Plan(format!("mvappend: decimal builder: {e}")) + })?; let mut builder = ListBuilder::new(inner); for row in 0..n { let mut any_value = false; @@ -193,15 +207,18 @@ impl ScalarUDFImpl for MvappendUdf { if arr.is_null(row) { continue; } - if let Some(list_arr) = arr.as_any().downcast_ref::>() { + if let Some(list_arr) = arr.as_any().downcast_ref::>() + { let row_list = list_arr.value(row); let typed = row_list .as_any() .downcast_ref::() - .ok_or_else(|| DataFusionError::Internal(format!( - "mvappend: list element vector type mismatch ({:?})", - row_list.data_type() - )))?; + .ok_or_else(|| { + DataFusionError::Internal(format!( + "mvappend: list element vector type mismatch ({:?})", + row_list.data_type() + )) + })?; for i in 0..typed.len() { if !typed.is_null(i) { builder.values().append_value(typed.value(i)); @@ -449,7 +466,10 @@ mod tests { } let inner = list.value(row); let typed = inner.as_primitive::(); - (0..typed.len()).filter(|i| !typed.is_null(*i)).map(|i| typed.value(i)).collect() + (0..typed.len()) + .filter(|i| !typed.is_null(*i)) + .map(|i| typed.value(i)) + .collect() } #[test] diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/mvfind.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/mvfind.rs index 4b2fe2e6e63c9..dc96d79aa80db 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/mvfind.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/mvfind.rs @@ -110,27 +110,31 @@ impl ScalarUDFImpl for MvfindUdf { let n = args.number_rows; // Fast path: pattern is a Utf8 scalar literal — compile once. - let scalar_regex: Option = if let ColumnarValue::Scalar(ScalarValue::Utf8(Some(p))) - | ColumnarValue::Scalar(ScalarValue::LargeUtf8(Some(p))) - | ColumnarValue::Scalar(ScalarValue::Utf8View(Some(p))) = &args.args[1] - { - // Plan-time invalid pattern → planning error so users see it instantly. - // Mirrors the SQL plugin's IllegalArgumentException for invalid literal regex. - match Regex::new(p) { - Ok(r) => Some(r), - Err(e) => return plan_err!("mvfind: invalid regex pattern '{p}': {e}"), - } - } else { - None - }; + let scalar_regex: Option = + if let ColumnarValue::Scalar(ScalarValue::Utf8(Some(p))) + | ColumnarValue::Scalar(ScalarValue::LargeUtf8(Some(p))) + | ColumnarValue::Scalar(ScalarValue::Utf8View(Some(p))) = &args.args[1] + { + // Plan-time invalid pattern → planning error so users see it instantly. + // Mirrors the SQL plugin's IllegalArgumentException for invalid literal regex. + match Regex::new(p) { + Ok(r) => Some(r), + Err(e) => return plan_err!("mvfind: invalid regex pattern '{p}': {e}"), + } + } else { + None + }; let arr_arr = args.args[0].clone().into_array(n)?; - let list = arr_arr.as_any().downcast_ref::>().ok_or_else(|| { - DataFusionError::Internal(format!( - "mvfind: expected ListArray, got {:?}", - arr_arr.data_type() - )) - })?; + let list = arr_arr + .as_any() + .downcast_ref::>() + .ok_or_else(|| { + DataFusionError::Internal(format!( + "mvfind: expected ListArray, got {:?}", + arr_arr.data_type() + )) + })?; // Materialize a column-valued pattern up front; for scalar patterns we keep // the pre-compiled regex. @@ -139,8 +143,10 @@ impl ScalarUDFImpl for MvfindUdf { } else { None }; - let pattern_arr: Option> = - pattern_arr_ref.as_ref().map(StringArrayView::from_array).transpose()?; + let pattern_arr: Option> = pattern_arr_ref + .as_ref() + .map(StringArrayView::from_array) + .transpose()?; let mut builder = Int32Builder::with_capacity(n); for i in 0..n { @@ -149,11 +155,12 @@ impl ScalarUDFImpl for MvfindUdf { continue; } // Per-row regex (compile if column-valued; reuse the scalar compile otherwise). - let regex_for_row: Option = match (&scalar_regex, pattern_arr.as_ref().and_then(|a| a.cell(i))) { - (Some(r), _) => Some(r.clone()), - (None, Some(s)) => Regex::new(s).ok(), - _ => None, - }; + let regex_for_row: Option = + match (&scalar_regex, pattern_arr.as_ref().and_then(|a| a.cell(i))) { + (Some(r), _) => Some(r.clone()), + (None, Some(s)) => Regex::new(s).ok(), + _ => None, + }; let regex = match regex_for_row { Some(r) => r, None => { @@ -192,7 +199,9 @@ fn find_first_match(arr: &dyn Array, regex: &Regex) -> Option { } match arr.data_type() { DataType::Utf8 => { - let typed = arr.as_any().downcast_ref::()?; + let typed = arr + .as_any() + .downcast_ref::()?; for i in 0..n { if !typed.is_null(i) && regex.is_match(typed.value(i)) { return Some(i as i32); @@ -201,7 +210,9 @@ fn find_first_match(arr: &dyn Array, regex: &Regex) -> Option { None } DataType::LargeUtf8 => { - let typed = arr.as_any().downcast_ref::()?; + let typed = arr + .as_any() + .downcast_ref::()?; for i in 0..n { if !typed.is_null(i) && regex.is_match(typed.value(i)) { return Some(i as i32); @@ -210,7 +221,9 @@ fn find_first_match(arr: &dyn Array, regex: &Regex) -> Option { None } DataType::Utf8View => { - let typed = arr.as_any().downcast_ref::()?; + let typed = arr + .as_any() + .downcast_ref::()?; for i in 0..n { if !typed.is_null(i) && regex.is_match(typed.value(i)) { return Some(i as i32); diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/mvzip.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/mvzip.rs index 3a17a69995bfb..7d15b1d9b9c5e 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/mvzip.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/mvzip.rs @@ -90,7 +90,10 @@ impl ScalarUDFImpl for MvzipUdf { return plan_err!("mvzip expects 2 or 3 arguments, got {}", arg_types.len()); } for (i, t) in arg_types.iter().take(2).enumerate() { - if !matches!(t, DataType::List(_) | DataType::LargeList(_) | DataType::FixedSizeList(_, _)) { + if !matches!( + t, + DataType::List(_) | DataType::LargeList(_) | DataType::FixedSizeList(_, _) + ) { return plan_err!("mvzip: arg {i} expected list type, got {t:?}"); } } @@ -100,7 +103,9 @@ impl ScalarUDFImpl for MvzipUdf { DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View => { coerced.push(DataType::Utf8) } - other => return plan_err!("mvzip: arg 2 (separator) expected string, got {other:?}"), + other => { + return plan_err!("mvzip: arg 2 (separator) expected string, got {other:?}") + } } } Ok(coerced) @@ -147,8 +152,7 @@ impl ScalarUDFImpl for MvzipUdf { for j in 0..take { let l = left_strs[j].as_deref().unwrap_or(""); let r = right_strs[j].as_deref().unwrap_or(""); - let mut joined = - String::with_capacity(l.len() + separator.len() + r.len()); + let mut joined = String::with_capacity(l.len() + separator.len() + r.len()); joined.push_str(l); joined.push_str(&separator); joined.push_str(r); @@ -176,12 +180,14 @@ fn scalar_string(cv: &ColumnarValue) -> Option<&str> { } fn downcast_list<'a>(arr: &'a ArrayRef, slot: &str) -> Result<&'a ListArray> { - arr.as_any().downcast_ref::>().ok_or_else(|| { - DataFusionError::Internal(format!( - "mvzip: {slot} expected ListArray, got {:?}", - arr.data_type() - )) - }) + arr.as_any() + .downcast_ref::>() + .ok_or_else(|| { + DataFusionError::Internal(format!( + "mvzip: {slot} expected ListArray, got {:?}", + arr.data_type() + )) + }) } /// Convert each element of an Arrow array of any supported scalar type to its @@ -273,9 +279,14 @@ mod tests { use datafusion::arrow::array::StringBuilder as ArrowStringBuilder; fn run(left: ArrayRef, right: ArrayRef, sep: Option<&str>) -> ArrayRef { - let mut args = vec![ColumnarValue::Array(left.clone()), ColumnarValue::Array(right.clone())]; + let mut args = vec![ + ColumnarValue::Array(left.clone()), + ColumnarValue::Array(right.clone()), + ]; if let Some(s) = sep { - args.push(ColumnarValue::Scalar(ScalarValue::Utf8(Some(s.to_string())))); + args.push(ColumnarValue::Scalar(ScalarValue::Utf8(Some( + s.to_string(), + )))); } let n = left.len(); let return_field = Arc::new(Field::new( diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/os_strftime.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/os_strftime.rs index 0c57a94f54e3e..2455f07249240 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/os_strftime.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/os_strftime.rs @@ -16,11 +16,38 @@ use chrono::{DateTime, Datelike, TimeZone, Timelike, Utc, Weekday}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Token { Literal(char), - A, B, C, D, DLower, E, F, - HUpper, HLower, ILower, IUpper, J, K, L, - MUpper, MLower, P, PUpper, R, SUpper, SLower, T, - UUpper, ULower, VUpper, VLower, W, WLower, - XUpper, XLower, YUpper, YLower, + A, + B, + C, + D, + DLower, + E, + F, + HUpper, + HLower, + ILower, + IUpper, + J, + K, + L, + MUpper, + MLower, + P, + PUpper, + R, + SUpper, + SLower, + T, + UUpper, + ULower, + VUpper, + VLower, + W, + WLower, + XUpper, + XLower, + YUpper, + YLower, } fn tokenize(format: &str) -> Vec { @@ -149,19 +176,47 @@ fn render_token(tok: Token, dt: DateTime, mode: FormatMode) -> Option "MM" in date mode), which differs from stock MySQL's // no-leading-zero %c. PPL semantics are the contract on the analytics-engine route. // Time mode keeps the reference's single-"0" literal (TIME_HANDLERS maps %c -> "0"). - Token::C => Str(if time_mode { "0".into() } else { format!("{:02}", dt.month()) }), - Token::DLower => Str(if time_mode { "00".into() } else { format!("{:02}", dt.day()) }), - Token::E => Str(if time_mode { "0".into() } else { dt.day().to_string() }), - Token::MLower => Str(if time_mode { "00".into() } else { format!("{:02}", dt.month()) }), - Token::YUpper => Str(if time_mode { "0000".into() } else { format!("{:04}", dt.year()) }), - Token::YLower => Str(if time_mode { "00".into() } else { format!("{:02}", dt.year() % 100) }), + Token::C => Str(if time_mode { + "0".into() + } else { + format!("{:02}", dt.month()) + }), + Token::DLower => Str(if time_mode { + "00".into() + } else { + format!("{:02}", dt.day()) + }), + Token::E => Str(if time_mode { + "0".into() + } else { + dt.day().to_string() + }), + Token::MLower => Str(if time_mode { + "00".into() + } else { + format!("{:02}", dt.month()) + }), + Token::YUpper => Str(if time_mode { + "0000".into() + } else { + format!("{:04}", dt.year()) + }), + Token::YLower => Str(if time_mode { + "00".into() + } else { + format!("{:02}", dt.year() % 100) + }), Token::F => Str(format!("{:06}", dt.nanosecond() / 1_000)), Token::HUpper => Str(format!("{:02}", dt.hour())), Token::HLower | Token::IUpper => Str(format!("{:02}", twelve_hour(dt.hour()))), Token::ILower => Str(format!("{:02}", dt.minute())), Token::K => Str(dt.hour().to_string()), Token::L => Str(twelve_hour(dt.hour()).to_string()), - Token::P => Str(if dt.hour() < 12 { "AM".into() } else { "PM".into() }), + Token::P => Str(if dt.hour() < 12 { + "AM".into() + } else { + "PM".into() + }), // %P is non-MySQL; PPL passes the trailing letter through verbatim Token::PUpper => Char('P'), Token::R => Str(format!( @@ -172,14 +227,23 @@ fn render_token(tok: Token, dt: DateTime, mode: FormatMode) -> Option Str(format!("{:02}", dt.second())), - Token::T => Str(format!("{:02}:{:02}:{:02}", dt.hour(), dt.minute(), dt.second())), + Token::T => Str(format!( + "{:02}:{:02}:{:02}", + dt.hour(), + dt.minute(), + dt.second() + )), }; Some(r) } fn twelve_hour(h: u32) -> u32 { let h = h % 12; - if h == 0 { 12 } else { h } + if h == 0 { + 12 + } else { + h + } } fn ordinal_suffix(day: u32) -> &'static str { @@ -196,32 +260,56 @@ fn ordinal_suffix(day: u32) -> &'static str { fn weekday_short(w: Weekday) -> &'static str { match w { - Weekday::Mon => "Mon", Weekday::Tue => "Tue", Weekday::Wed => "Wed", - Weekday::Thu => "Thu", Weekday::Fri => "Fri", Weekday::Sat => "Sat", Weekday::Sun => "Sun", + Weekday::Mon => "Mon", + Weekday::Tue => "Tue", + Weekday::Wed => "Wed", + Weekday::Thu => "Thu", + Weekday::Fri => "Fri", + Weekday::Sat => "Sat", + Weekday::Sun => "Sun", } } fn weekday_full(w: Weekday) -> &'static str { match w { - Weekday::Mon => "Monday", Weekday::Tue => "Tuesday", Weekday::Wed => "Wednesday", - Weekday::Thu => "Thursday", Weekday::Fri => "Friday", - Weekday::Sat => "Saturday", Weekday::Sun => "Sunday", + Weekday::Mon => "Monday", + Weekday::Tue => "Tuesday", + Weekday::Wed => "Wednesday", + Weekday::Thu => "Thursday", + Weekday::Fri => "Friday", + Weekday::Sat => "Saturday", + Weekday::Sun => "Sunday", } } fn month_short(m: u32) -> &'static str { - ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] - .get(m as usize) - .copied() - .unwrap_or("") + [ + "", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ] + .get(m as usize) + .copied() + .unwrap_or("") } fn month_full(m: u32) -> &'static str { - ["", "January", "February", "March", "April", "May", "June", - "July", "August", "September", "October", "November", "December"] - .get(m as usize) - .copied() - .unwrap_or("") + [ + "", + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ] + .get(m as usize) + .copied() + .unwrap_or("") } fn week_number_sunday_first(dt: DateTime) -> u32 { @@ -292,8 +380,13 @@ fn days_until_start(from: Weekday, start: Weekday) -> u32 { fn weekday_idx(w: Weekday) -> u32 { match w { - Weekday::Mon => 0, Weekday::Tue => 1, Weekday::Wed => 2, Weekday::Thu => 3, - Weekday::Fri => 4, Weekday::Sat => 5, Weekday::Sun => 6, + Weekday::Mon => 0, + Weekday::Tue => 1, + Weekday::Wed => 2, + Weekday::Thu => 3, + Weekday::Fri => 4, + Weekday::Sat => 5, + Weekday::Sun => 6, } } @@ -319,7 +412,8 @@ impl Parsed { pub(crate) fn to_naive(&self) -> Option { let (y, mo, d) = (self.year, self.month, self.day); let has_time = self.hour.is_some() || self.hour_12.is_some(); - let has_date_parts = y.is_some() || mo.is_some() || d.is_some() || self.day_of_year.is_some(); + let has_date_parts = + y.is_some() || mo.is_some() || d.is_some() || self.day_of_year.is_some(); if !has_date_parts && !has_time { return None; } @@ -463,7 +557,9 @@ pub(crate) fn parse_os_strftime(input: &str, format: &str) -> Option { } // %P consumes a single literal P (mirrors the formatter's pass-through behavior) Token::PUpper => { - if pos >= input_bytes.len() || (input_bytes[pos] != b'P' && input_bytes[pos] != b'p') { + if pos >= input_bytes.len() + || (input_bytes[pos] != b'P' && input_bytes[pos] != b'p') + { return None; } pos += 1; @@ -541,12 +637,21 @@ pub(crate) fn parse_os_strftime(input: &str, format: &str) -> Option { /// `September` doesn't get truncated to `Sep` with `tember` left as dangling input. fn match_month_name(bytes: &[u8], pos: usize) -> Option<(u32, usize)> { const FULL: [&str; 12] = [ - "January", "February", "March", "April", "May", "June", - "July", "August", "September", "October", "November", "December", + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", ]; const SHORT: [&str; 12] = [ - "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ]; let tail = bytes.get(pos..)?; for (i, name) in FULL.iter().enumerate() { @@ -617,7 +722,9 @@ mod tests { fn sample() -> DateTime { // 2020-03-15 10:30:45.123456 UTC (Sunday) - Utc.timestamp_micros(1_584_268_245_123_456).single().unwrap() + Utc.timestamp_micros(1_584_268_245_123_456) + .single() + .unwrap() } fn fmt(dt: DateTime, f: &str, m: FormatMode) -> Option { @@ -627,17 +734,34 @@ mod tests { #[test] fn date_mode_common_tokens() { let dt = sample(); - assert_eq!(fmt(dt, "%Y-%m-%d %H:%i:%S", FormatMode::Date).unwrap(), "2020-03-15 10:30:45"); - assert_eq!(fmt(dt, "%W, %M %D, %Y", FormatMode::Date).unwrap(), "Sunday, March 15th, 2020"); - assert_eq!(fmt(dt, "%h:%i:%s %p", FormatMode::Date).unwrap(), "10:30:45 AM"); + assert_eq!( + fmt(dt, "%Y-%m-%d %H:%i:%S", FormatMode::Date).unwrap(), + "2020-03-15 10:30:45" + ); + assert_eq!( + fmt(dt, "%W, %M %D, %Y", FormatMode::Date).unwrap(), + "Sunday, March 15th, 2020" + ); + assert_eq!( + fmt(dt, "%h:%i:%s %p", FormatMode::Date).unwrap(), + "10:30:45 AM" + ); } #[test] fn ordinal_suffix_edges() { for (d, want) in [ - (1, "1st"), (2, "2nd"), (3, "3rd"), (4, "4th"), - (11, "11th"), (12, "12th"), (13, "13th"), - (21, "21st"), (22, "22nd"), (23, "23rd"), (31, "31st"), + (1, "1st"), + (2, "2nd"), + (3, "3rd"), + (4, "4th"), + (11, "11th"), + (12, "12th"), + (13, "13th"), + (21, "21st"), + (22, "22nd"), + (23, "23rd"), + (31, "31st"), ] { let dt = Utc.with_ymd_and_hms(2020, 1, d, 0, 0, 0).unwrap(); assert_eq!(fmt(dt, "%D", FormatMode::Date).unwrap(), want, "day={d}"); @@ -647,7 +771,10 @@ mod tests { #[test] fn time_mode_masks_date_tokens() { let dt = sample(); - assert_eq!(fmt(dt, "%Y-%m-%d %H:%i:%S", FormatMode::Time).unwrap(), "0000-00-00 10:30:45"); + assert_eq!( + fmt(dt, "%Y-%m-%d %H:%i:%S", FormatMode::Time).unwrap(), + "0000-00-00 10:30:45" + ); assert!(fmt(dt, "%W", FormatMode::Time).is_none()); assert!(fmt(dt, "%a %H:%i", FormatMode::Time).is_none()); } @@ -669,8 +796,10 @@ mod tests { // 1998-01-31 (Saturday): all four week tokens render `04`. let jan31 = Utc.with_ymd_and_hms(1998, 1, 31, 0, 0, 0).unwrap(); - assert_eq!(fmt(jan31, "%U %u %V %v %W %w %X %x %Y %y", FormatMode::Date).unwrap(), - "04 04 04 04 Saturday 6 1998 1998 1998 98"); + assert_eq!( + fmt(jan31, "%U %u %V %v %W %w %X %x %Y %y", FormatMode::Date).unwrap(), + "04 04 04 04 Saturday 6 1998 1998 1998 98" + ); } #[test] @@ -695,13 +824,20 @@ mod tests { #[test] fn parse_roundtrip_and_defaults() { for (input, format, expected) in [ - ("2020-03-15 10:30:45", "%Y-%m-%d %H:%i:%S", "2020-03-15 10:30:45"), + ( + "2020-03-15 10:30:45", + "%Y-%m-%d %H:%i:%S", + "2020-03-15 10:30:45", + ), ("2020-03-15 extra", "%Y-%m-%d", "2020-03-15 00:00:00"), ("2020", "%Y", "2020-01-01 00:00:00"), // PPL uses `today`; we emit the MySQL-compatible 2000-01-01 default for determinism. ("10:30:45", "%H:%i:%S", "2000-01-01 10:30:45"), ] { - let ndt = parse_os_strftime(input, format).unwrap().to_naive().unwrap(); + let ndt = parse_os_strftime(input, format) + .unwrap() + .to_naive() + .unwrap(); assert_eq!(ndt.to_string(), expected, "input={input}"); } } @@ -745,7 +881,10 @@ mod tests { #[test] fn parse_fractional_seconds() { let p = parse_os_strftime("2020-03-15 10:30:45.123456", "%Y-%m-%d %H:%i:%S.%f").unwrap(); - assert_eq!(p.to_naive().unwrap().and_utc().timestamp_micros(), 1_584_268_245_123_456); + assert_eq!( + p.to_naive().unwrap().and_utc().timestamp_micros(), + 1_584_268_245_123_456 + ); } #[test] diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/os_week.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/os_week.rs index 5645202d3b9fb..13c22a2d838f4 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/os_week.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/os_week.rs @@ -71,7 +71,9 @@ impl ScalarUDFImpl for OsWeekUdf { | DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View => out.push(DataType::Date32), - other => return plan_err!("os_week: arg 0 expected date/timestamp/string, got {other:?}"), + other => { + return plan_err!("os_week: arg 0 expected date/timestamp/string, got {other:?}") + } } if arg_types.len() == 2 { match &arg_types[1] { @@ -100,9 +102,11 @@ impl ScalarUDFImpl for OsWeekUdf { let dates = date_arg .as_any() .downcast_ref::() - .ok_or_else(|| datafusion::error::DataFusionError::Internal( - "os_week: arg 0 not coerced to Date32".to_string(), - ))?; + .ok_or_else(|| { + datafusion::error::DataFusionError::Internal( + "os_week: arg 0 not coerced to Date32".to_string(), + ) + })?; let mut builder = Int32Builder::with_capacity(n); for i in 0..n { if dates.is_null(i) { @@ -111,14 +115,17 @@ impl ScalarUDFImpl for OsWeekUdf { } let mode = match &mode_arg { Some(arr) if arr.is_null(i) => 0, - Some(arr) => arr.as_primitive::().value(i), + Some(arr) => arr + .as_primitive::() + .value(i), None => 0, }; let days = dates.value(i); - let date = NaiveDate::from_num_days_from_ce_opt(days + 719_163) - .ok_or_else(|| datafusion::error::DataFusionError::Execution( - format!("os_week: invalid Date32 day count {days}"), - ))?; + let date = NaiveDate::from_num_days_from_ce_opt(days + 719_163).ok_or_else(|| { + datafusion::error::DataFusionError::Execution(format!( + "os_week: invalid Date32 day count {days}" + )) + })?; builder.append_value(os_week_number(date, mode) as i32); } Ok(ColumnarValue::Array(Arc::new(builder.finish()) as ArrayRef)) @@ -149,13 +156,19 @@ impl ScalarUDFImpl for OsYearweekUdf { } fn return_type(&self, arg_types: &[DataType]) -> Result { if arg_types.is_empty() || arg_types.len() > 2 { - return plan_err!("os_yearweek expects 1 or 2 arguments, got {}", arg_types.len()); + return plan_err!( + "os_yearweek expects 1 or 2 arguments, got {}", + arg_types.len() + ); } Ok(DataType::Int32) } fn coerce_types(&self, arg_types: &[DataType]) -> Result> { if arg_types.is_empty() || arg_types.len() > 2 { - return plan_err!("os_yearweek expects 1 or 2 arguments, got {}", arg_types.len()); + return plan_err!( + "os_yearweek expects 1 or 2 arguments, got {}", + arg_types.len() + ); } let mut out = Vec::with_capacity(arg_types.len()); match &arg_types[0] { @@ -165,7 +178,11 @@ impl ScalarUDFImpl for OsYearweekUdf { | DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View => out.push(DataType::Date32), - other => return plan_err!("os_yearweek: arg 0 expected date/timestamp/string, got {other:?}"), + other => { + return plan_err!( + "os_yearweek: arg 0 expected date/timestamp/string, got {other:?}" + ) + } } if arg_types.len() == 2 { match &arg_types[1] { @@ -177,7 +194,9 @@ impl ScalarUDFImpl for OsYearweekUdf { | DataType::UInt16 | DataType::UInt32 | DataType::UInt64 => out.push(DataType::Int32), - other => return plan_err!("os_yearweek: arg 1 expected integer mode, got {other:?}"), + other => { + return plan_err!("os_yearweek: arg 1 expected integer mode, got {other:?}") + } } } Ok(out) @@ -194,9 +213,11 @@ impl ScalarUDFImpl for OsYearweekUdf { let dates = date_arg .as_any() .downcast_ref::() - .ok_or_else(|| datafusion::error::DataFusionError::Internal( - "os_yearweek: arg 0 not coerced to Date32".to_string(), - ))?; + .ok_or_else(|| { + datafusion::error::DataFusionError::Internal( + "os_yearweek: arg 0 not coerced to Date32".to_string(), + ) + })?; let mut builder = Int32Builder::with_capacity(n); for i in 0..n { if dates.is_null(i) { @@ -205,14 +226,17 @@ impl ScalarUDFImpl for OsYearweekUdf { } let mode = match &mode_arg { Some(arr) if arr.is_null(i) => 0, - Some(arr) => arr.as_primitive::().value(i), + Some(arr) => arr + .as_primitive::() + .value(i), None => 0, }; let days = dates.value(i); - let date = NaiveDate::from_num_days_from_ce_opt(days + 719_163) - .ok_or_else(|| datafusion::error::DataFusionError::Execution( - format!("os_yearweek: invalid Date32 day count {days}"), - ))?; + let date = NaiveDate::from_num_days_from_ce_opt(days + 719_163).ok_or_else(|| { + datafusion::error::DataFusionError::Execution(format!( + "os_yearweek: invalid Date32 day count {days}" + )) + })?; builder.append_value(os_yearweek_number(date, mode)); } Ok(ColumnarValue::Array(Arc::new(builder.finish()) as ArrayRef)) @@ -260,7 +284,11 @@ fn os_year_number(date: NaiveDate, mode: i32) -> i32 { /// `(firstDayOfWeek, minimalDaysInFirstWeek)` for MySQL modes 0..7. fn week_params(mode: u32) -> (Weekday, u32) { - let first = if mode % 2 == 0 { Weekday::Sun } else { Weekday::Mon }; + let first = if mode % 2 == 0 { + Weekday::Sun + } else { + Weekday::Mon + }; let min_days = match mode { 1 | 3 => 5, 4 | 6 => 4, @@ -373,8 +401,14 @@ mod tests { fn os_yearweek_jan1_1900_all_modes() { let d = NaiveDate::from_ymd_opt(1900, 1, 1).unwrap(); let expected: [(i32, i32); 8] = [ - (0, 189953), (1, 190001), (2, 189953), (3, 190001), - (4, 190001), (5, 190001), (6, 190001), (7, 190001), + (0, 189953), + (1, 190001), + (2, 189953), + (3, 190001), + (4, 190001), + (5, 190001), + (6, 190001), + (7, 190001), ]; for (mode, want) in expected { assert_eq!(os_yearweek_number(d, mode), want, "mode={mode}"); diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/parse.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/parse.rs index 50131647480c4..45cb6e75b9d6a 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/parse.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/parse.rs @@ -34,7 +34,9 @@ use std::sync::Arc; -use datafusion::arrow::array::{Array, ArrayRef, MapBuilder, MapFieldNames, StringArray, StringBuilder}; +use datafusion::arrow::array::{ + Array, ArrayRef, MapBuilder, MapFieldNames, StringArray, StringBuilder, +}; use datafusion::arrow::datatypes::{DataType, Field}; use datafusion::common::{plan_err, ScalarValue}; use datafusion::error::{DataFusionError, Result}; @@ -118,14 +120,12 @@ impl ScalarUDFImpl for ParseUdf { // The Java adapter validates that pattern + method are non-null string // literals at plan time. Re-check defensively so a misuse from an // unknown caller produces an actionable plan error instead of a panic. - let pattern = scalar_str(&args.args[1]) - .ok_or_else(|| DataFusionError::Plan( - "parse: pattern must be a non-null string literal".into(), - ))?; - let method = scalar_str(&args.args[2]) - .ok_or_else(|| DataFusionError::Plan( - "parse: method must be a non-null string literal".into(), - ))?; + let pattern = scalar_str(&args.args[1]).ok_or_else(|| { + DataFusionError::Plan("parse: pattern must be a non-null string literal".into()) + })?; + let method = scalar_str(&args.args[2]).ok_or_else(|| { + DataFusionError::Plan("parse: method must be a non-null string literal".into()) + })?; if method != "regex" { return Err(DataFusionError::Plan(format!( @@ -139,9 +139,8 @@ impl ScalarUDFImpl for ParseUdf { // group is safe — it does not perturb the named-group indexing the // caller relies on. let anchored = format!("^(?:{pattern})$"); - let regex = Regex::new(&anchored).map_err(|e| { - DataFusionError::Plan(format!("parse: invalid regex [{pattern}]: {e}")) - })?; + let regex = Regex::new(&anchored) + .map_err(|e| DataFusionError::Plan(format!("parse: invalid regex [{pattern}]: {e}")))?; // Collect named groups in pattern definition order so the resulting // map's key order matches what users see in legacy PPL output. @@ -160,10 +159,12 @@ impl ScalarUDFImpl for ParseUdf { let input = input_arr .as_any() .downcast_ref::() - .ok_or_else(|| DataFusionError::Internal(format!( - "parse: expected Utf8 input, got {:?}", - input_arr.data_type(), - )))?; + .ok_or_else(|| { + DataFusionError::Internal(format!( + "parse: expected Utf8 input, got {:?}", + input_arr.data_type(), + )) + })?; let mut builder = MapBuilder::new( Some(MapFieldNames { @@ -226,11 +227,7 @@ mod tests { use datafusion::arrow::array::MapArray; use datafusion::arrow::datatypes::TimeUnit; - fn run_parse( - input: Vec>, - pattern: &str, - method: &str, - ) -> Result { + fn run_parse(input: Vec>, pattern: &str, method: &str) -> Result { let udf = ParseUdf::new(); let n = input.len(); let input_arr = StringArray::from(input); @@ -317,8 +314,14 @@ mod tests { ) .unwrap(); assert!(!map.is_null(0)); // row 0 cell is present - assert_eq!(entries_of(&map, 0), vec![("name".to_string(), "".to_string())]); - assert_eq!(entries_of(&map, 1), vec![("name".to_string(), "alice".to_string())]); + assert_eq!( + entries_of(&map, 0), + vec![("name".to_string(), "".to_string())] + ); + assert_eq!( + entries_of(&map, 1), + vec![("name".to_string(), "alice".to_string())] + ); } #[test] @@ -359,10 +362,7 @@ mod tests { let out = udf .coerce_types(&[DataType::LargeUtf8, DataType::Utf8View, DataType::Utf8]) .unwrap(); - assert_eq!( - out, - vec![DataType::Utf8, DataType::Utf8, DataType::Utf8] - ); + assert_eq!(out, vec![DataType::Utf8, DataType::Utf8, DataType::Utf8]); } #[test] diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/pattern_parser.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/pattern_parser.rs index 5378092d3d6ee..2db34c46a9500 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/pattern_parser.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/pattern_parser.rs @@ -97,20 +97,18 @@ impl ScalarUDFImpl for PatternParserUdf { let first = utf8_or_err(NAME, 0, &arg_types[0])?; let second = match &arg_types[1] { DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View => DataType::Utf8, - DataType::List(inner) | DataType::LargeList(inner) => { - match inner.data_type() { - DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View => { - DataType::List(Arc::new(Field::new("item", DataType::Utf8, true))) - } - other => { - return plan_err!( - "{} arg 1: List element must be string, got {:?}", - NAME, - other - ); - } + DataType::List(inner) | DataType::LargeList(inner) => match inner.data_type() { + DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View => { + DataType::List(Arc::new(Field::new("item", DataType::Utf8, true))) } - } + other => { + return plan_err!( + "{} arg 1: List element must be string, got {:?}", + NAME, + other + ); + } + }, other => { return plan_err!( "{} arg 1: expected string or List, got {:?}", @@ -390,12 +388,7 @@ fn read_utf8<'a>(udf: &str, slot: usize, arr: &'a ArrayRef) -> Result Result { match dt { DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View => Ok(DataType::Utf8), - other => plan_err!( - "{} arg {}: expected string, got {:?}", - udf, - slot, - other - ), + other => plan_err!("{} arg {}: expected string, got {:?}", udf, slot, other), } } @@ -449,8 +442,7 @@ fn build_struct_array(results: &[PatternResult]) -> Result { Field::new(FIELD_PATTERN, DataType::Utf8, true), Field::new(FIELD_TOKENS, tokens_map_type(), true), ]); - let struct_array = - StructArray::new(struct_fields, vec![pattern_array, tokens_array], None); + let struct_array = StructArray::new(struct_fields, vec![pattern_array, tokens_array], None); Ok(Arc::new(struct_array) as ArrayRef) } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/range_bucket.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/range_bucket.rs index 3773216f773b2..8362671ff16ab 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/range_bucket.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/range_bucket.rs @@ -43,9 +43,7 @@ use std::sync::Arc; -use datafusion::arrow::array::{ - Array, ArrayRef, Float64Array, StringBuilder, -}; +use datafusion::arrow::array::{Array, ArrayRef, Float64Array, StringBuilder}; use datafusion::arrow::datatypes::DataType; use datafusion::common::plan_err; use datafusion::error::{DataFusionError, Result}; @@ -141,7 +139,13 @@ impl ScalarUDFImpl for RangeBucketUdf { } else { Some(end_param.value(i)) }; - match calculate(value.value(i), data_min.value(i), data_max.value(i), start, end) { + match calculate( + value.value(i), + data_min.value(i), + data_max.value(i), + start, + end, + ) { Some(s) => builder.append_value(&s), None => builder.append_null(), } @@ -202,7 +206,11 @@ fn magnitude_based_width(effective_range: f64) -> f64 { let log10_range = effective_range.log10(); let floor_log = log10_range.floor(); let is_exact_power_of_10 = (log10_range - floor_log).abs() < 1e-10; - let adjusted = if is_exact_power_of_10 { floor_log - 1.0 } else { floor_log }; + let adjusted = if is_exact_power_of_10 { + floor_log - 1.0 + } else { + floor_log + }; 10f64.powf(adjusted) } @@ -405,10 +413,18 @@ mod tests { config_options: Arc::new(Default::default()), }; let out = udf.invoke_with_args(args).unwrap(); - let ColumnarValue::Array(a) = out else { panic!("expected array") }; + let ColumnarValue::Array(a) = out else { + panic!("expected array") + }; let s = a.as_string::(); (0..n) - .map(|i| if s.is_null(i) { None } else { Some(s.value(i).to_string()) }) + .map(|i| { + if s.is_null(i) { + None + } else { + Some(s.value(i).to_string()) + } + }) .collect() } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/reduce_eval.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/reduce_eval.rs index 9849762274d91..10fa573623a83 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/reduce_eval.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/reduce_eval.rs @@ -17,7 +17,9 @@ use datafusion::common::{DataFusionError, Result, ScalarValue}; use datafusion::execution::context::SessionContext; use datafusion::functions_aggregate::approx_distinct::approx_distinct_udaf; use datafusion::logical_expr::function::AccumulatorArgs; -use datafusion::logical_expr::{ColumnarValue, ScalarFunctionArgs, ScalarUDF, ScalarUDFImpl, Signature, Volatility}; +use datafusion::logical_expr::{ + ColumnarValue, ScalarFunctionArgs, ScalarUDF, ScalarUDFImpl, Signature, Volatility, +}; use datafusion::physical_expr::expressions::Column; pub fn register_all(ctx: &SessionContext) { @@ -28,12 +30,13 @@ pub fn register_all(ctx: &SessionContext) { struct ReduceEvalUdf; impl ScalarUDFImpl for ReduceEvalUdf { - fn name(&self) -> &str { "reduce_eval" } + fn name(&self) -> &str { + "reduce_eval" + } fn signature(&self) -> &Signature { - static SIG: std::sync::LazyLock = std::sync::LazyLock::new(|| { - Signature::any(2, Volatility::Immutable) - }); + static SIG: std::sync::LazyLock = + std::sync::LazyLock::new(|| Signature::any(2, Volatility::Immutable)); &SIG } @@ -44,7 +47,11 @@ impl ScalarUDFImpl for ReduceEvalUdf { fn invoke_with_args(&self, args: ScalarFunctionArgs) -> Result { let agg_name = match &args.args[0] { ColumnarValue::Scalar(ScalarValue::Utf8(Some(s))) => s.clone(), - _ => return Err(DataFusionError::Execution("reduce_eval: first arg must be literal agg name".into())), + _ => { + return Err(DataFusionError::Execution( + "reduce_eval: first arg must be literal agg name".into(), + )) + } }; let state_col = match &args.args[1] { ColumnarValue::Array(a) => a.clone(), @@ -53,17 +60,25 @@ impl ScalarUDFImpl for ReduceEvalUdf { match agg_name.as_str() { "approx_distinct" => eval_approx_distinct(&state_col), - other => Err(DataFusionError::Execution(format!("reduce_eval: unsupported aggregate '{other}'"))), + other => Err(DataFusionError::Execution(format!( + "reduce_eval: unsupported aggregate '{other}'" + ))), } } } fn eval_approx_distinct(state_col: &ArrayRef) -> Result { - let binary = state_col.as_any().downcast_ref::() - .ok_or_else(|| DataFusionError::Execution("reduce_eval(approx_distinct): expected Binary state".into()))?; + let binary = state_col + .as_any() + .downcast_ref::() + .ok_or_else(|| { + DataFusionError::Execution("reduce_eval(approx_distinct): expected Binary state".into()) + })?; let field: Arc = Arc::new(Field::new("x", DataType::Int64, true)); - let schema = Arc::new(datafusion::arrow::datatypes::Schema::new(vec![field.as_ref().clone()])); + let schema = Arc::new(datafusion::arrow::datatypes::Schema::new(vec![field + .as_ref() + .clone()])); let expr: Arc = Arc::new(Column::new("x", 0)); let ret_field: Arc = Arc::new(Field::new("r", DataType::UInt64, true)); @@ -105,17 +120,29 @@ mod tests { fn test_reduce_eval_approx_distinct() { // Build an HLL state by updating an accumulator let field: Arc = Arc::new(Field::new("x", DataType::Int64, true)); - let schema = Arc::new(datafusion::arrow::datatypes::Schema::new(vec![field.as_ref().clone()])); + let schema = Arc::new(datafusion::arrow::datatypes::Schema::new(vec![field + .as_ref() + .clone()])); let expr: Arc = Arc::new(Column::new("x", 0)); let ret_field: Arc = Arc::new(Field::new("r", DataType::UInt64, true)); - let mut acc = approx_distinct_udaf().accumulator(AccumulatorArgs { - return_field: ret_field.clone(), schema: &schema, ignore_nulls: false, - order_bys: &[], name: "x", is_distinct: false, - exprs: &[expr.clone()], expr_fields: &[field.clone()], is_reversed: false, - }).unwrap(); + let mut acc = approx_distinct_udaf() + .accumulator(AccumulatorArgs { + return_field: ret_field.clone(), + schema: &schema, + ignore_nulls: false, + order_bys: &[], + name: "x", + is_distinct: false, + exprs: &[expr.clone()], + expr_fields: &[field.clone()], + is_reversed: false, + }) + .unwrap(); // Feed some values - let values: ArrayRef = Arc::new(datafusion::arrow::array::Int64Array::from(vec![1, 2, 3, 4, 5])); + let values: ArrayRef = Arc::new(datafusion::arrow::array::Int64Array::from(vec![ + 1, 2, 3, 4, 5, + ])); acc.update_batch(&[values]).unwrap(); let state = acc.state().unwrap(); diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/rex_extract.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/rex_extract.rs index 1bcf38b3c459e..f8d64b68ee23f 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/rex_extract.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/rex_extract.rs @@ -122,20 +122,26 @@ impl ScalarUDFImpl for RexExtractUdf { // Materialize column-valued operands lazily; keep the ArrayRef alive // alongside the StringArrayView (the view borrows the underlying buffers). - let pattern_arr_ref: Option = if pattern_scalar.is_none() && matches!(&args.args[1], ColumnarValue::Array(_)) { - Some(args.args[1].clone().into_array(n)?) - } else { - None - }; - let group_arr_ref: Option = if group_scalar.is_none() && matches!(&args.args[2], ColumnarValue::Array(_)) { - Some(args.args[2].clone().into_array(n)?) - } else { - None - }; - let pattern_array: Option> = - pattern_arr_ref.as_ref().map(StringArrayView::from_array).transpose()?; - let group_array: Option> = - group_arr_ref.as_ref().map(StringArrayView::from_array).transpose()?; + let pattern_arr_ref: Option = + if pattern_scalar.is_none() && matches!(&args.args[1], ColumnarValue::Array(_)) { + Some(args.args[1].clone().into_array(n)?) + } else { + None + }; + let group_arr_ref: Option = + if group_scalar.is_none() && matches!(&args.args[2], ColumnarValue::Array(_)) { + Some(args.args[2].clone().into_array(n)?) + } else { + None + }; + let pattern_array: Option> = pattern_arr_ref + .as_ref() + .map(StringArrayView::from_array) + .transpose()?; + let group_array: Option> = group_arr_ref + .as_ref() + .map(StringArrayView::from_array) + .transpose()?; let mut builder = StringBuilder::with_capacity(n, n * 16); for i in 0..n { @@ -146,7 +152,10 @@ impl ScalarUDFImpl for RexExtractUdf { // Resolve the regex — scalar fast-path or per-row column lookup. let regex_owned; - let regex: &Regex = match (&scalar_regex, pattern_array.as_ref().and_then(|a| a.cell(i))) { + let regex: &Regex = match ( + &scalar_regex, + pattern_array.as_ref().and_then(|a| a.cell(i)), + ) { (Some(r), _) => r, (None, Some(s)) => { regex_owned = compile_pattern(s)?; @@ -159,14 +168,15 @@ impl ScalarUDFImpl for RexExtractUdf { }; // Resolve the group name (or numeric index, if it parses as one). - let group_name: &str = match (&group_scalar, group_array.as_ref().and_then(|a| a.cell(i))) { - (Some(g), _) => g.as_str(), - (None, Some(s)) => s, - _ => { - builder.append_null(); - continue; - } - }; + let group_name: &str = + match (&group_scalar, group_array.as_ref().and_then(|a| a.cell(i))) { + (Some(g), _) => g.as_str(), + (None, Some(s)) => s, + _ => { + builder.append_null(); + continue; + } + }; match extract_group(regex, input_value, group_name) { Some(s) => builder.append_value(s), @@ -227,7 +237,10 @@ mod tests { fn extract_named_group_first_match() { let r = compile_pattern("(?[^@]+)@(?.+)").unwrap(); assert_eq!(extract_group(&r, "user@example.com", "host"), Some("user")); - assert_eq!(extract_group(&r, "user@example.com", "domain"), Some("example.com")); + assert_eq!( + extract_group(&r, "user@example.com", "domain"), + Some("example.com") + ); } #[test] diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/rex_extract_multi.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/rex_extract_multi.rs index cb808f3cab64b..19ace209f5d1d 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/rex_extract_multi.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/rex_extract_multi.rs @@ -37,8 +37,8 @@ use datafusion::logical_expr::{ }; use regex::Regex; -use super::{coerce_args, CoerceMode}; use super::rex_extract::compile_pattern; +use super::{coerce_args, CoerceMode}; pub fn register_all(ctx: &SessionContext) { ctx.register_udf(ScalarUDF::from(RexExtractMultiUdf::new())); @@ -73,11 +73,18 @@ impl ScalarUDFImpl for RexExtractMultiUdf { fn return_type(&self, arg_types: &[DataType]) -> Result { if arg_types.len() != 4 { - return plan_err!("rex_extract_multi expects 4 arguments, got {}", arg_types.len()); + return plan_err!( + "rex_extract_multi expects 4 arguments, got {}", + arg_types.len() + ); } // List — element type is nullable Utf8 to match the Java // signature `ARRAY(VARCHAR_2000_NULLABLE)`. - Ok(DataType::List(Arc::new(Field::new("item", DataType::Utf8, true)))) + Ok(DataType::List(Arc::new(Field::new( + "item", + DataType::Utf8, + true, + )))) } fn coerce_types(&self, arg_types: &[DataType]) -> Result> { @@ -95,7 +102,10 @@ impl ScalarUDFImpl for RexExtractMultiUdf { fn invoke_with_args(&self, args: ScalarFunctionArgs) -> Result { if args.args.len() != 4 { - return plan_err!("rex_extract_multi expects 4 arguments, got {}", args.args.len()); + return plan_err!( + "rex_extract_multi expects 4 arguments, got {}", + args.args.len() + ); } let n = args.number_rows; @@ -111,25 +121,32 @@ impl ScalarUDFImpl for RexExtractMultiUdf { let input_arr = args.args[0].clone().into_array(n)?; let input = StringArrayView::from_array(&input_arr)?; - let pattern_arr_ref: Option = if pattern_scalar.is_none() && matches!(&args.args[1], ColumnarValue::Array(_)) { - Some(args.args[1].clone().into_array(n)?) - } else { - None - }; - let group_arr_ref: Option = if group_scalar.is_none() && matches!(&args.args[2], ColumnarValue::Array(_)) { - Some(args.args[2].clone().into_array(n)?) - } else { - None - }; - let max_match_arr_ref: Option = if max_match_scalar.is_none() && matches!(&args.args[3], ColumnarValue::Array(_)) { - Some(args.args[3].clone().into_array(n)?) - } else { - None - }; - let pattern_array: Option> = - pattern_arr_ref.as_ref().map(StringArrayView::from_array).transpose()?; - let group_array: Option> = - group_arr_ref.as_ref().map(StringArrayView::from_array).transpose()?; + let pattern_arr_ref: Option = + if pattern_scalar.is_none() && matches!(&args.args[1], ColumnarValue::Array(_)) { + Some(args.args[1].clone().into_array(n)?) + } else { + None + }; + let group_arr_ref: Option = + if group_scalar.is_none() && matches!(&args.args[2], ColumnarValue::Array(_)) { + Some(args.args[2].clone().into_array(n)?) + } else { + None + }; + let max_match_arr_ref: Option = + if max_match_scalar.is_none() && matches!(&args.args[3], ColumnarValue::Array(_)) { + Some(args.args[3].clone().into_array(n)?) + } else { + None + }; + let pattern_array: Option> = pattern_arr_ref + .as_ref() + .map(StringArrayView::from_array) + .transpose()?; + let group_array: Option> = group_arr_ref + .as_ref() + .map(StringArrayView::from_array) + .transpose()?; // After coerce_types(Int64) the array may arrive as Int32 in some plans; // accept either by widening on read. let max_match_array_i32: Option<&Int32Array> = max_match_arr_ref @@ -138,7 +155,9 @@ impl ScalarUDFImpl for RexExtractMultiUdf { let value_builder = StringBuilder::new(); let mut builder = ListBuilder::new(value_builder).with_field(Arc::new(Field::new( - "item", DataType::Utf8, true, + "item", + DataType::Utf8, + true, ))); for i in 0..n { @@ -148,7 +167,10 @@ impl ScalarUDFImpl for RexExtractMultiUdf { }; let regex_owned; - let regex: &Regex = match (&scalar_regex, pattern_array.as_ref().and_then(|a| a.cell(i))) { + let regex: &Regex = match ( + &scalar_regex, + pattern_array.as_ref().and_then(|a| a.cell(i)), + ) { (Some(r), _) => r, (None, Some(s)) => { regex_owned = compile_pattern(s)?; @@ -160,14 +182,15 @@ impl ScalarUDFImpl for RexExtractMultiUdf { } }; - let group_name: &str = match (&group_scalar, group_array.as_ref().and_then(|a| a.cell(i))) { - (Some(g), _) => g.as_str(), - (None, Some(s)) => s, - _ => { - builder.append_null(); - continue; - } - }; + let group_name: &str = + match (&group_scalar, group_array.as_ref().and_then(|a| a.cell(i))) { + (Some(g), _) => g.as_str(), + (None, Some(s)) => s, + _ => { + builder.append_null(); + continue; + } + }; let max_match: i64 = match (max_match_scalar, max_match_array_i32) { (Some(m), _) => m, diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/rex_offset.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/rex_offset.rs index cea282b54a25e..379ded9627d6a 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/rex_offset.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/rex_offset.rs @@ -44,8 +44,8 @@ use datafusion::logical_expr::{ }; use regex::Regex; -use super::{coerce_args, CoerceMode}; use super::rex_extract::compile_pattern; +use super::{coerce_args, CoerceMode}; pub fn register_all(ctx: &SessionContext) { ctx.register_udf(ScalarUDF::from(RexOffsetUdf::new())); @@ -108,13 +108,16 @@ impl ScalarUDFImpl for RexOffsetUdf { let input_arr = args.args[0].clone().into_array(n)?; let input = StringArrayView::from_array(&input_arr)?; - let pattern_arr_ref: Option = if pattern_scalar.is_none() && matches!(&args.args[1], ColumnarValue::Array(_)) { - Some(args.args[1].clone().into_array(n)?) - } else { - None - }; - let pattern_array: Option> = - pattern_arr_ref.as_ref().map(StringArrayView::from_array).transpose()?; + let pattern_arr_ref: Option = + if pattern_scalar.is_none() && matches!(&args.args[1], ColumnarValue::Array(_)) { + Some(args.args[1].clone().into_array(n)?) + } else { + None + }; + let pattern_array: Option> = pattern_arr_ref + .as_ref() + .map(StringArrayView::from_array) + .transpose()?; let mut builder = StringBuilder::with_capacity(n, n * 32); for i in 0..n { @@ -123,7 +126,10 @@ impl ScalarUDFImpl for RexOffsetUdf { continue; }; let regex_owned; - let regex: &Regex = match (&scalar_regex, pattern_array.as_ref().and_then(|a| a.cell(i))) { + let regex: &Regex = match ( + &scalar_regex, + pattern_array.as_ref().and_then(|a| a.cell(i)), + ) { (Some(r), _) => r, (None, Some(s)) => { regex_owned = compile_pattern(s)?; @@ -229,7 +235,10 @@ mod tests { // Java calls `matcher.find()` once, then walks named groups within // that single match. Subsequent matches don't contribute. let r = compile_pattern("(?\\d+)").unwrap(); - assert_eq!(calculate_offsets(&r, "12 then 34"), Some("n=0-1".to_string())); + assert_eq!( + calculate_offsets(&r, "12 then 34"), + Some("n=0-1".to_string()) + ); } #[test] diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/sha1.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/sha1.rs index 9dc4fcef7a4ea..807f099ea67f0 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/sha1.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/sha1.rs @@ -21,7 +21,8 @@ use datafusion::arrow::datatypes::DataType; use datafusion::common::{exec_err, Result, ScalarValue}; use datafusion::execution::context::SessionContext; use datafusion::logical_expr::{ - ColumnarValue, ScalarFunctionArgs, ScalarUDF, ScalarUDFImpl, Signature, TypeSignature, Volatility, + ColumnarValue, ScalarFunctionArgs, ScalarUDF, ScalarUDFImpl, Signature, TypeSignature, + Volatility, }; use sha1::{Digest, Sha1}; @@ -177,11 +178,7 @@ mod tests { fn array_input_preserves_null_mask() { let u = udf(); let return_field = Arc::new(Field::new(u.name(), DataType::Utf8, true)); - let values: ArrayRef = Arc::new(StringArray::from(vec![ - Some("abc"), - None, - Some(""), - ])); + let values: ArrayRef = Arc::new(StringArray::from(vec![Some("abc"), None, Some("")])); let args = ScalarFunctionArgs { args: vec![ColumnarValue::Array(values)], arg_fields: vec![Arc::new(Field::new("v", DataType::Utf8, true))], diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/span_bucket.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/span_bucket.rs index 16d60afa1f7d5..b93cce5947203 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/span_bucket.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/span_bucket.rs @@ -29,9 +29,7 @@ use std::sync::Arc; -use datafusion::arrow::array::{ - Array, ArrayRef, Float64Array, StringBuilder, -}; +use datafusion::arrow::array::{Array, ArrayRef, Float64Array, StringBuilder}; use datafusion::arrow::datatypes::DataType; use datafusion::common::plan_err; use datafusion::error::{DataFusionError, Result}; @@ -154,10 +152,7 @@ fn format_range(bin_start: f64, bin_end: f64, span: f64) -> String { format!("{}-{}", bin_start as i64, bin_end as i64) } else { let decimals = decimal_places_for(span); - format!( - "{:.*}-{:.*}", - decimals, bin_start, decimals, bin_end - ) + format!("{:.*}-{:.*}", decimals, bin_start, decimals, bin_end) } } @@ -307,10 +302,18 @@ mod tests { config_options: Arc::new(Default::default()), }; let out = udf.invoke_with_args(args).unwrap(); - let ColumnarValue::Array(a) = out else { panic!("expected array") }; + let ColumnarValue::Array(a) = out else { + panic!("expected array") + }; let s = a.as_string::(); (0..n) - .map(|i| if s.is_null(i) { None } else { Some(s.value(i).to_string()) }) + .map(|i| { + if s.is_null(i) { + None + } else { + Some(s.value(i).to_string()) + } + }) .collect() } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/str_to_date.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/str_to_date.rs index 0ad29959173a3..e5703f1731b0d 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/str_to_date.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/str_to_date.rs @@ -140,9 +140,17 @@ mod tests { fn parses_well_formed_inputs() { // (input, format, expected micros). Date-only defaults 00:00:00. for (i, f, want) in [ - ("2020-03-15 10:30:45", "%Y-%m-%d %H:%i:%S", 1_584_268_245_000_000_i64), + ( + "2020-03-15 10:30:45", + "%Y-%m-%d %H:%i:%S", + 1_584_268_245_000_000_i64, + ), ("2020-03-15", "%Y-%m-%d", 1_584_230_400_000_000), - ("2020-03-15 10:30:45.123456", "%Y-%m-%d %H:%i:%S.%f", 1_584_268_245_123_456), + ( + "2020-03-15 10:30:45.123456", + "%Y-%m-%d %H:%i:%S.%f", + 1_584_268_245_123_456, + ), ] { assert_eq!(parse_to_micros(i, f), Some(want), "input={i}"); } @@ -164,7 +172,10 @@ mod tests { .unwrap() .and_utc() .timestamp_micros(); - assert_eq!(parse_to_micros("2017-10-23", "%Y-%m-%d %h:%i:%s"), Some(want)); + assert_eq!( + parse_to_micros("2017-10-23", "%Y-%m-%d %h:%i:%s"), + Some(want) + ); } #[test] @@ -175,7 +186,10 @@ mod tests { .unwrap() .and_utc() .timestamp_micros(); - assert_eq!(parse_to_micros("2017-10-23 00:00:00", "%Y-%m-%d %h:%i:%s"), Some(want)); + assert_eq!( + parse_to_micros("2017-10-23 00:00:00", "%Y-%m-%d %h:%i:%s"), + Some(want) + ); } #[test] @@ -186,6 +200,9 @@ mod tests { .unwrap() .and_utc() .timestamp_micros(); - assert_eq!(parse_to_micros("23-Oct-17 00:00:00", "%d-%b-%y %h:%i:%s"), Some(want)); + assert_eq!( + parse_to_micros("23-Oct-17 00:00:00", "%d-%b-%y %h:%i:%s"), + Some(want) + ); } } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/strftime.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/strftime.rs index 1091faf4024cf..b9901bf5cfe1f 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/strftime.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/strftime.rs @@ -11,9 +11,7 @@ use std::sync::Arc; use chrono::{DateTime, Datelike, NaiveDate, TimeZone, Timelike, Utc, Weekday}; -use datafusion::arrow::array::{ - Array, ArrayRef, AsArray, StringArray, TimestampMicrosecondArray, -}; +use datafusion::arrow::array::{Array, ArrayRef, AsArray, StringArray, TimestampMicrosecondArray}; use datafusion::arrow::datatypes::{DataType, TimeUnit}; use datafusion::common::{exec_err, plan_err, Result, ScalarValue}; use datafusion::execution::context::SessionContext; @@ -74,7 +72,11 @@ impl ScalarUDFImpl for StrftimeUdf { DataType::Timestamp(_, _) | DataType::Date32 | DataType::Date64 => { DataType::Timestamp(TimeUnit::Microsecond, None) } - other => return plan_err!("strftime: arg 0 expected numeric/timestamp/date/string, got {other:?}"), + other => { + return plan_err!( + "strftime: arg 0 expected numeric/timestamp/date/string, got {other:?}" + ) + } }; let format = match &arg_types[1] { t @ (DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View) => t.clone(), @@ -113,12 +115,17 @@ impl ScalarUDFImpl for StrftimeUdf { let format_array = args.args[1].clone().into_array(n)?; let out: StringArray = match value_array.data_type() { DataType::Float64 => { - let values = value_array.as_primitive::(); + let values = + value_array.as_primitive::(); (0..n) - .map(|i| if values.is_null(i) { None } else { - match format_at(&format_array, i) { - Ok(Some(f)) => render_from_seconds(values.value(i), Some(&f)), - _ => None, + .map(|i| { + if values.is_null(i) { + None + } else { + match format_at(&format_array, i) { + Ok(Some(f)) => render_from_seconds(values.value(i), Some(&f)), + _ => None, + } } }) .collect() @@ -129,15 +136,23 @@ impl ScalarUDFImpl for StrftimeUdf { .downcast_ref::() .expect("coerce_types canonicalizes to TimestampMicrosecond"); (0..n) - .map(|i| if values.is_null(i) { None } else { - match format_at(&format_array, i) { - Ok(Some(f)) => render_from_micros(values.value(i), Some(&f)), - _ => None, + .map(|i| { + if values.is_null(i) { + None + } else { + match format_at(&format_array, i) { + Ok(Some(f)) => render_from_micros(values.value(i), Some(&f)), + _ => None, + } } }) .collect() } - other => return exec_err!("strftime: unsupported value array type after coercion: {other:?}"), + other => { + return exec_err!( + "strftime: unsupported value array type after coercion: {other:?}" + ) + } }; Ok(ColumnarValue::Array(Arc::new(out) as ArrayRef)) } @@ -275,16 +290,34 @@ fn format_with_directives(dt: DateTime, format: &str) -> String { fn append_simple_directive(out: &mut String, dt: DateTime, directive: u8) -> bool { let h12 = { let h = dt.hour() % 12; - if h == 0 { 12 } else { h } + if h == 0 { + 12 + } else { + h + } }; match directive { b'%' => out.push('%'), - b'c' => out.push_str(&format!("{} {} {:02} {:02}:{:02}:{:02} {:04}", - weekday_short(dt.weekday()), month_short(dt.month()), - dt.day(), dt.hour(), dt.minute(), dt.second(), dt.year())), - b'+' => out.push_str(&format!("{} {} {:02} {:02}:{:02}:{:02} UTC {:04}", - weekday_short(dt.weekday()), month_short(dt.month()), - dt.day(), dt.hour(), dt.minute(), dt.second(), dt.year())), + b'c' => out.push_str(&format!( + "{} {} {:02} {:02}:{:02}:{:02} {:04}", + weekday_short(dt.weekday()), + month_short(dt.month()), + dt.day(), + dt.hour(), + dt.minute(), + dt.second(), + dt.year() + )), + b'+' => out.push_str(&format!( + "{} {} {:02} {:02}:{:02}:{:02} UTC {:04}", + weekday_short(dt.weekday()), + month_short(dt.month()), + dt.day(), + dt.hour(), + dt.minute(), + dt.second(), + dt.year() + )), b'f' => out.push_str(&format!("{:06}", dt.nanosecond() / 1000)), b'H' => out.push_str(&format!("{:02}", dt.hour())), b'I' => out.push_str(&format!("{:02}", h12)), @@ -293,11 +326,26 @@ fn append_simple_directive(out: &mut String, dt: DateTime, directive: u8) - b'p' => out.push_str(if dt.hour() < 12 { "AM" } else { "PM" }), b'S' => out.push_str(&format!("{:02}", dt.second())), b's' => out.push_str(&dt.timestamp().to_string()), - b'T' | b'X' => out.push_str(&format!("{:02}:{:02}:{:02}", dt.hour(), dt.minute(), dt.second())), + b'T' | b'X' => out.push_str(&format!( + "{:02}:{:02}:{:02}", + dt.hour(), + dt.minute(), + dt.second() + )), b'Z' => out.push_str("UTC"), b'z' => out.push_str("+0000"), - b'F' => out.push_str(&format!("{:04}-{:02}-{:02}", dt.year(), dt.month(), dt.day())), - b'x' => out.push_str(&format!("{:02}/{:02}/{:04}", dt.month(), dt.day(), dt.year())), + b'F' => out.push_str(&format!( + "{:04}-{:02}-{:02}", + dt.year(), + dt.month(), + dt.day() + )), + b'x' => out.push_str(&format!( + "{:02}/{:02}/{:04}", + dt.month(), + dt.day(), + dt.year() + )), b'A' => out.push_str(weekday_full(dt.weekday())), b'a' => out.push_str(weekday_short(dt.weekday())), b'w' => out.push_str(&weekday_numeric_sunday_zero(dt.weekday()).to_string()), @@ -351,47 +399,91 @@ fn append_subsecond(out: &mut String, dt: DateTime, kind: u8, precision: us fn weekday_short(w: Weekday) -> &'static str { match w { - Weekday::Mon => "Mon", Weekday::Tue => "Tue", Weekday::Wed => "Wed", - Weekday::Thu => "Thu", Weekday::Fri => "Fri", Weekday::Sat => "Sat", Weekday::Sun => "Sun", + Weekday::Mon => "Mon", + Weekday::Tue => "Tue", + Weekday::Wed => "Wed", + Weekday::Thu => "Thu", + Weekday::Fri => "Fri", + Weekday::Sat => "Sat", + Weekday::Sun => "Sun", } } fn weekday_full(w: Weekday) -> &'static str { match w { - Weekday::Mon => "Monday", Weekday::Tue => "Tuesday", Weekday::Wed => "Wednesday", - Weekday::Thu => "Thursday", Weekday::Fri => "Friday", - Weekday::Sat => "Saturday", Weekday::Sun => "Sunday", + Weekday::Mon => "Monday", + Weekday::Tue => "Tuesday", + Weekday::Wed => "Wednesday", + Weekday::Thu => "Thursday", + Weekday::Fri => "Friday", + Weekday::Sat => "Saturday", + Weekday::Sun => "Sunday", } } fn weekday_numeric_sunday_zero(w: Weekday) -> u32 { match w { - Weekday::Sun => 0, Weekday::Mon => 1, Weekday::Tue => 2, Weekday::Wed => 3, - Weekday::Thu => 4, Weekday::Fri => 5, Weekday::Sat => 6, + Weekday::Sun => 0, + Weekday::Mon => 1, + Weekday::Tue => 2, + Weekday::Wed => 3, + Weekday::Thu => 4, + Weekday::Fri => 5, + Weekday::Sat => 6, } } fn month_short(m: u32) -> &'static str { - ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] - .get(m as usize).copied().unwrap_or("") + [ + "", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ] + .get(m as usize) + .copied() + .unwrap_or("") } fn month_full(m: u32) -> &'static str { - ["", "January", "February", "March", "April", "May", "June", - "July", "August", "September", "October", "November", "December"] - .get(m as usize).copied().unwrap_or("") + [ + "", + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ] + .get(m as usize) + .copied() + .unwrap_or("") } /// `%U` — week of year, Sunday-start; week 0 is the partial week before the /// first Sunday of the year (matches `WeekFields.SUNDAY_START.weekOfYear() - 1`). fn week_of_year_sunday_start(dt: DateTime) -> u32 { let doy = dt.ordinal(); - let jan1 = NaiveDate::from_ymd_opt(dt.year(), 1, 1).expect("valid jan 1").weekday(); + let jan1 = NaiveDate::from_ymd_opt(dt.year(), 1, 1) + .expect("valid jan 1") + .weekday(); let days_to_first_sunday = match jan1 { - Weekday::Sun => 1, Weekday::Mon => 7, Weekday::Tue => 6, Weekday::Wed => 5, - Weekday::Thu => 4, Weekday::Fri => 3, Weekday::Sat => 2, + Weekday::Sun => 1, + Weekday::Mon => 7, + Weekday::Tue => 6, + Weekday::Wed => 5, + Weekday::Thu => 4, + Weekday::Fri => 3, + Weekday::Sat => 2, }; - if doy < days_to_first_sunday { 0 } else { (doy - days_to_first_sunday) / 7 + 1 } + if doy < days_to_first_sunday { + 0 + } else { + (doy - days_to_first_sunday) / 7 + 1 + } } #[cfg(test)] @@ -436,7 +528,10 @@ mod tests { utf8_scalar( invoke( args, - &[DataType::Timestamp(TimeUnit::Microsecond, None), DataType::Utf8], + &[ + DataType::Timestamp(TimeUnit::Microsecond, None), + DataType::Utf8, + ], 1, ) .unwrap(), @@ -460,7 +555,10 @@ mod tests { #[test] fn integer_seconds_ymd_hms() { - assert_eq!(call_f64(1521467703.0, "%Y-%m-%d %H:%M:%S").unwrap(), "2018-03-19 13:55:03"); + assert_eq!( + call_f64(1521467703.0, "%Y-%m-%d %H:%M:%S").unwrap(), + "2018-03-19 13:55:03" + ); } #[test] @@ -473,7 +571,10 @@ mod tests { #[test] fn negative_epoch_rendering() { - assert_eq!(call_f64(-1.0, "%Y-%m-%d %H:%M:%S").unwrap(), "1969-12-31 23:59:59"); + assert_eq!( + call_f64(-1.0, "%Y-%m-%d %H:%M:%S").unwrap(), + "1969-12-31 23:59:59" + ); assert_eq!(call_f64(-86400.0, "%Y-%m-%d").unwrap(), "1969-12-31"); assert_eq!(call_f64(-31_536_000.0, "%Y-%m-%d").unwrap(), "1969-01-01"); assert_eq!(call_f64(-946_771_200.0, "%Y-%m-%d").unwrap(), "1940-01-01"); @@ -481,8 +582,14 @@ mod tests { #[test] fn timestamp_micros_renders_date_and_time() { - assert_eq!(call_ts_us(1_521_467_703_000_000, "%F").unwrap(), "2018-03-19"); - assert_eq!(call_ts_us(1_521_467_703_000_000, "%H:%M:%S").unwrap(), "13:55:03"); + assert_eq!( + call_ts_us(1_521_467_703_000_000, "%F").unwrap(), + "2018-03-19" + ); + assert_eq!( + call_ts_us(1_521_467_703_000_000, "%H:%M:%S").unwrap(), + "13:55:03" + ); } #[test] @@ -559,7 +666,11 @@ mod tests { #[test] fn array_f64_with_scalar_format() { - let arr: ArrayRef = Arc::new(Float64Array::from(vec![Some(1521467703.0), None, Some(-86400.0)])); + let arr: ArrayRef = Arc::new(Float64Array::from(vec![ + Some(1521467703.0), + None, + Some(-86400.0), + ])); let u = udf(); let args = ScalarFunctionArgs { args: vec![ @@ -575,7 +686,9 @@ mod tests { config_options: Arc::new(Default::default()), }; let out = u.invoke_with_args(args).unwrap(); - let ColumnarValue::Array(a) = out else { panic!("expected array"); }; + let ColumnarValue::Array(a) = out else { + panic!("expected array"); + }; let s = a.as_string::(); assert_eq!(s.value(0), "2018-03-19"); assert!(s.is_null(1)); @@ -586,20 +699,37 @@ mod tests { fn coerce_types_folds_inputs() { let u = udf(); // Numeric + string fold onto Float64. - for t in [DataType::Int64, DataType::Int32, DataType::UInt8, DataType::Float32, DataType::Utf8] { - assert_eq!(u.coerce_types(&[t.clone(), DataType::Utf8]).unwrap()[0], DataType::Float64, "{t:?}"); + for t in [ + DataType::Int64, + DataType::Int32, + DataType::UInt8, + DataType::Float32, + DataType::Utf8, + ] { + assert_eq!( + u.coerce_types(&[t.clone(), DataType::Utf8]).unwrap()[0], + DataType::Float64, + "{t:?}" + ); } // Temporal types fold onto Timestamp(us). let canonical = DataType::Timestamp(TimeUnit::Microsecond, None); for t in [ - DataType::Date32, DataType::Date64, + DataType::Date32, + DataType::Date64, DataType::Timestamp(TimeUnit::Millisecond, None), DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into())), ] { - assert_eq!(u.coerce_types(&[t.clone(), DataType::Utf8]).unwrap()[0], canonical, "{t:?}"); + assert_eq!( + u.coerce_types(&[t.clone(), DataType::Utf8]).unwrap()[0], + canonical, + "{t:?}" + ); } // Non-numeric / non-temporal rejected. - let err = u.coerce_types(&[DataType::Boolean, DataType::Utf8]).unwrap_err(); + let err = u + .coerce_types(&[DataType::Boolean, DataType::Utf8]) + .unwrap_err(); assert!(err.to_string().contains("expected numeric/timestamp")); // Int64 shape sanity — exercised by the array test above. let _arr: ArrayRef = Arc::new(Int64Array::from(vec![Some(1521467703i64)])); diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/time_format.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/time_format.rs index a149fd807bf98..d7a05b6e564f0 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/time_format.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/time_format.rs @@ -11,7 +11,6 @@ //! (%d→"00", %Y→"0000"); date-only name tokens (%W, %a, %M, %D, %j, %w, %U/%u, //! %V/%v, %X/%x, %b) cause the whole render to collapse to NULL. - use super::udf_identity; use datafusion::arrow::datatypes::{DataType, TimeUnit}; diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/width_bucket.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/width_bucket.rs index b341aa1355880..7479a8ab2aeae 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/width_bucket.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/width_bucket.rs @@ -30,9 +30,7 @@ use std::sync::Arc; -use datafusion::arrow::array::{ - Array, ArrayRef, Float64Array, Int64Array, StringBuilder, -}; +use datafusion::arrow::array::{Array, ArrayRef, Float64Array, Int64Array, StringBuilder}; use datafusion::arrow::datatypes::DataType; use datafusion::common::plan_err; use datafusion::error::{DataFusionError, Result}; @@ -120,7 +118,12 @@ impl ScalarUDFImpl for WidthBucketUdf { builder.append_null(); continue; } - match calculate(value.value(i), num_bins.value(i), range.value(i), max_value.value(i)) { + match calculate( + value.value(i), + num_bins.value(i), + range.value(i), + max_value.value(i), + ) { Some(s) => builder.append_value(&s), None => builder.append_null(), } @@ -358,10 +361,18 @@ mod tests { config_options: Arc::new(Default::default()), }; let out = udf.invoke_with_args(args).unwrap(); - let ColumnarValue::Array(a) = out else { panic!("expected array") }; + let ColumnarValue::Array(a) = out else { + panic!("expected array") + }; let s = a.as_string::(); (0..n) - .map(|i| if s.is_null(i) { None } else { Some(s.value(i).to_string()) }) + .map(|i| { + if s.is_null(i) { + None + } else { + Some(s.value(i).to_string()) + } + }) .collect() } @@ -416,14 +427,21 @@ mod tests { fn coerce_types_rejects_string_in_value_slot() { let udf = WidthBucketUdf::new(); assert!(udf - .coerce_types(&[DataType::Utf8, DataType::Int64, DataType::Float64, DataType::Float64]) + .coerce_types(&[ + DataType::Utf8, + DataType::Int64, + DataType::Float64, + DataType::Float64 + ]) .is_err()); } #[test] fn coerce_types_rejects_wrong_arity() { let udf = WidthBucketUdf::new(); - assert!(udf.coerce_types(&[DataType::Int64, DataType::Int64, DataType::Int64]).is_err()); + assert!(udf + .coerce_types(&[DataType::Int64, DataType::Int64, DataType::Int64]) + .is_err()); } #[test] diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udwf/internal_pattern.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udwf/internal_pattern.rs index 20cb6031bc68a..41886987995b0 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/udwf/internal_pattern.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udwf/internal_pattern.rs @@ -22,7 +22,6 @@ //! resulting {@code array_element(map_extract(...), 1)} chain to one of the //! per-field scalar UDFs. - use datafusion::arrow::array::{ArrayRef, AsArray, StringArray, StringBuilder}; use datafusion::arrow::datatypes::{DataType, Field, FieldRef}; use datafusion::common::Result; @@ -123,7 +122,10 @@ impl PartitionEvaluator for InternalPatternEvaluator { fn evaluate_all(&mut self, values: &[ArrayRef], num_rows: usize) -> Result { if values.is_empty() { - return Ok(std::sync::Arc::new(StringArray::from(vec![None::<&str>; num_rows]))); + return Ok(std::sync::Arc::new(StringArray::from(vec![ + None::<&str>; + num_rows + ]))); } let cfg = WindowConfig::from_args(values); @@ -135,10 +137,8 @@ impl PartitionEvaluator for InternalPatternEvaluator { } // Run BRAIN over the corpus once. - let mut parser = BrainLogParser::with_thresholds( - cfg.variable_count_threshold, - cfg.threshold_percentage, - ); + let mut parser = + BrainLogParser::with_thresholds(cfg.variable_count_threshold, cfg.threshold_percentage); let stats = parser.parse_all_log_patterns(&messages, cfg.max_sample_count); let candidates: Vec = stats .values() @@ -225,10 +225,16 @@ fn read_string_at(arr: &ArrayRef, i: usize) -> Option { if let Some(s) = arr.as_any().downcast_ref::() { return Some(s.value(i).to_string()); } - if let Some(s) = arr.as_any().downcast_ref::() { + if let Some(s) = arr + .as_any() + .downcast_ref::() + { return Some(s.value(i).to_string()); } - if let Some(s) = arr.as_any().downcast_ref::() { + if let Some(s) = arr + .as_any() + .downcast_ref::() + { return Some(s.value(i).to_string()); } None @@ -241,10 +247,22 @@ fn read_i64_at(arr: &ArrayRef, i: usize) -> Option { } let dt = arr.data_type(); match dt { - DataType::Int64 => arr.as_any().downcast_ref::().map(|a| a.value(i)), - DataType::Int32 => arr.as_any().downcast_ref::().map(|a| a.value(i) as i64), - DataType::Int16 => arr.as_any().downcast_ref::().map(|a| a.value(i) as i64), - DataType::Int8 => arr.as_any().downcast_ref::().map(|a| a.value(i) as i64), + DataType::Int64 => arr + .as_any() + .downcast_ref::() + .map(|a| a.value(i)), + DataType::Int32 => arr + .as_any() + .downcast_ref::() + .map(|a| a.value(i) as i64), + DataType::Int16 => arr + .as_any() + .downcast_ref::() + .map(|a| a.value(i) as i64), + DataType::Int8 => arr + .as_any() + .downcast_ref::() + .map(|a| a.value(i) as i64), _ => None, } } @@ -256,8 +274,14 @@ fn read_f64_at(arr: &ArrayRef, i: usize) -> Option { } let dt = arr.data_type(); match dt { - DataType::Float64 => arr.as_any().downcast_ref::().map(|a| a.value(i)), - DataType::Float32 => arr.as_any().downcast_ref::().map(|a| a.value(i) as f64), + DataType::Float64 => arr + .as_any() + .downcast_ref::() + .map(|a| a.value(i)), + DataType::Float32 => arr + .as_any() + .downcast_ref::() + .map(|a| a.value(i) as f64), _ => None, } } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/tests/budget_accuracy_test.rs b/sandbox/plugins/analytics-backend-datafusion/rust/tests/budget_accuracy_test.rs index d9f5119f774dd..c503a40994ebe 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/tests/budget_accuracy_test.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/tests/budget_accuracy_test.rs @@ -23,11 +23,13 @@ use std::sync::Arc; use arrow::datatypes::{DataType, Field, Schema}; use arrow_array::{Float64Array, Int64Array, RecordBatch, StringArray}; use datafusion::common::DataFusionError; -use datafusion::datasource::listing::{ListingOptions, ListingTable, ListingTableConfig, ListingTableUrl}; use datafusion::datasource::file_format::parquet::ParquetFormat; +use datafusion::datasource::listing::{ + ListingOptions, ListingTable, ListingTableConfig, ListingTableUrl, +}; +use datafusion::execution::context::SessionContext; use datafusion::execution::memory_pool::GreedyMemoryPool; use datafusion::execution::runtime_env::RuntimeEnvBuilder; -use datafusion::execution::context::SessionContext; use datafusion::execution::SessionStateBuilder; use datafusion::physical_plan::{execute_stream_partitioned, ExecutionPlan}; use datafusion::prelude::*; @@ -39,7 +41,9 @@ use prost::Message; use substrait::proto::Plan; use tempfile::TempDir; -use opensearch_datafusion::query_budget::{estimate_avg_row_bytes, acquire_budget, acquire_budget_from_metadata}; +use opensearch_datafusion::query_budget::{ + acquire_budget, acquire_budget_from_metadata, estimate_avg_row_bytes, +}; /// Create parquet test data with a known schema. fn create_parquet_data(dir: &std::path::Path, num_rows: usize, num_files: usize) -> Arc { @@ -166,7 +170,15 @@ async fn validate_budget_accuracy_relaxed( batch_size: usize, max_over_estimate_ratio: f64, ) { - validate_budget_accuracy_inner(dir, schema, sql, target_partitions, batch_size, max_over_estimate_ratio).await; + validate_budget_accuracy_inner( + dir, + schema, + sql, + target_partitions, + batch_size, + max_over_estimate_ratio, + ) + .await; } /// Core validation: run a query, measure actual memory per batch, compare to formula. @@ -224,7 +236,10 @@ async fn validate_budget_accuracy_inner( let dataframe = ctx.execute_logical_plan(logical_plan).await.unwrap(); let physical_plan = dataframe.create_physical_plan().await.unwrap(); - let actual_partitions = physical_plan.properties().output_partitioning().partition_count(); + let actual_partitions = physical_plan + .properties() + .output_partitioning() + .partition_count(); let (actual_total_bytes, actual_rows, actual_batches) = measure_actual_bytes(physical_plan, ctx.task_ctx()).await; @@ -242,7 +257,8 @@ async fn validate_budget_accuracy_inner( // Formula prediction let predicted_avg_row_bytes = estimate_avg_row_bytes(schema); - let pool = Arc::new(GreedyMemoryPool::new(1_000_000_000)) as Arc; + let pool = Arc::new(GreedyMemoryPool::new(1_000_000_000)) + as Arc; let budget = acquire_budget(&pool, schema, target_partitions, batch_size).unwrap(); // The formula's "untracked per batch" = batch_size × predicted_avg_row_bytes @@ -250,25 +266,77 @@ async fn validate_budget_accuracy_inner( println!("┌─────────────────────────────────────────────────────────────────┐"); println!("│ Query: {:<56} │", sql); - println!("│ target_partitions={}, batch_size={:<30} │", target_partitions, batch_size); + println!( + "│ target_partitions={}, batch_size={:<30} │", + target_partitions, batch_size + ); println!("├────────────────── ACTUAL (measured) ────────────────────────────┤"); - println!("│ Total bytes emitted: {:<38} │", format!("{} ({:.1} MB)", actual_total_bytes, actual_total_bytes as f64 / 1048576.0)); + println!( + "│ Total bytes emitted: {:<38} │", + format!( + "{} ({:.1} MB)", + actual_total_bytes, + actual_total_bytes as f64 / 1048576.0 + ) + ); println!("│ Total rows: {:<38} │", actual_rows); println!("│ Total batches: {:<38} │", actual_batches); println!("│ Actual partitions used: {:<38} │", actual_partitions); - println!("│ Avg batch bytes: {:<38} │", format!("{} ({:.1} KB)", avg_batch_bytes, avg_batch_bytes as f64 / 1024.0)); + println!( + "│ Avg batch bytes: {:<38} │", + format!( + "{} ({:.1} KB)", + avg_batch_bytes, + avg_batch_bytes as f64 / 1024.0 + ) + ); println!("│ Actual avg row bytes: {:<38} │", actual_avg_row_bytes); println!("├────────────────── PREDICTED (formula) ──────────────────────────┤"); - println!("│ Predicted avg row bytes: {:<38} │", predicted_avg_row_bytes); - println!("│ Predicted batch bytes: {:<38} │", format!("{} ({:.1} KB)", predicted_batch_bytes, predicted_batch_bytes as f64 / 1024.0)); - println!("│ Phantom reservation: {:<38} │", format!("{} ({:.1} MB)", budget.phantom_bytes, budget.phantom_bytes as f64 / 1048576.0)); + println!( + "│ Predicted avg row bytes: {:<38} │", + predicted_avg_row_bytes + ); + println!( + "│ Predicted batch bytes: {:<38} │", + format!( + "{} ({:.1} KB)", + predicted_batch_bytes, + predicted_batch_bytes as f64 / 1024.0 + ) + ); + println!( + "│ Phantom reservation: {:<38} │", + format!( + "{} ({:.1} MB)", + budget.phantom_bytes, + budget.phantom_bytes as f64 / 1048576.0 + ) + ); println!("├────────────────── VALIDATION ───────────────────────────────────┤"); let row_bytes_ratio = predicted_avg_row_bytes as f64 / actual_avg_row_bytes.max(1) as f64; let is_conservative = predicted_avg_row_bytes >= actual_avg_row_bytes; let within_threshold = row_bytes_ratio <= max_over_estimate_ratio; - println!("│ Row bytes ratio: {:<38} │", format!("{:.2}x (predicted/actual)", row_bytes_ratio)); - println!("│ Conservative (pred≥act): {:<38} │", if is_conservative { "YES ✓" } else { "NO ✗ — UNDER-ESTIMATE" }); - println!("│ Within {:.0}x (not wasteful):{:<37} │", max_over_estimate_ratio, if within_threshold { "YES ✓" } else { "NO ✗ — OVER-ESTIMATE" }); + println!( + "│ Row bytes ratio: {:<38} │", + format!("{:.2}x (predicted/actual)", row_bytes_ratio) + ); + println!( + "│ Conservative (pred≥act): {:<38} │", + if is_conservative { + "YES ✓" + } else { + "NO ✗ — UNDER-ESTIMATE" + } + ); + println!( + "│ Within {:.0}x (not wasteful):{:<37} │", + max_over_estimate_ratio, + if within_threshold { + "YES ✓" + } else { + "NO ✗ — OVER-ESTIMATE" + } + ); println!("└─────────────────────────────────────────────────────────────────┘"); println!(); @@ -287,7 +355,9 @@ async fn validate_budget_accuracy_inner( within_threshold || actual_avg_row_bytes == 0, "Budget OVER-estimated by more than {:.0}x: predicted={}, actual={}. \ This wastes pool capacity and forces unnecessary spilling.", - max_over_estimate_ratio, predicted_avg_row_bytes, actual_avg_row_bytes + max_over_estimate_ratio, + predicted_avg_row_bytes, + actual_avg_row_bytes ); } @@ -314,7 +384,8 @@ async fn budget_accuracy_narrow_schema_projection() { // is still conservative (≥ actual) even for narrow projections — it just // over-estimates. We relax the 3x threshold for projections since the formula // intentionally budgets for scan-level buffers at full schema width. - validate_budget_accuracy_relaxed(&dir, &schema, "SELECT id, metric FROM t", 4, 8192, 15.0).await; + validate_budget_accuracy_relaxed(&dir, &schema, "SELECT id, metric FROM t", 4, 8192, 15.0) + .await; } #[tokio::test] @@ -387,7 +458,8 @@ async fn budget_accuracy_metadata_based_is_tighter() { .filter_map(|e| e.ok()) .find(|e| e.path().extension().map_or(false, |ext| ext == "parquet")) .unwrap(); - let reader = SerializedFileReader::new(std::fs::File::open(first_file.path()).unwrap()).unwrap(); + let reader = + SerializedFileReader::new(std::fs::File::open(first_file.path()).unwrap()).unwrap(); let metadata = reader.metadata().clone(); let pool = Arc::new(GreedyMemoryPool::new(1_000_000_000)) @@ -399,8 +471,7 @@ async fn budget_accuracy_metadata_based_is_tighter() { drop(static_budget); // Metadata-based estimate (measured from parquet column sizes) - let meta_budget = - acquire_budget_from_metadata(&pool, &schema, &metadata, 4, 8192).unwrap(); + let meta_budget = acquire_budget_from_metadata(&pool, &schema, &metadata, 4, 8192).unwrap(); let meta_phantom = meta_budget.phantom_bytes; drop(meta_budget); @@ -409,11 +480,19 @@ async fn budget_accuracy_metadata_based_is_tighter() { println!("├─────────────────────────────────────────────────────────────────┤"); println!( "│ Static phantom: {:<44} │", - format!("{} ({:.1} MB)", static_phantom, static_phantom as f64 / 1048576.0) + format!( + "{} ({:.1} MB)", + static_phantom, + static_phantom as f64 / 1048576.0 + ) ); println!( "│ Metadata phantom: {:<44} │", - format!("{} ({:.1} MB)", meta_phantom, meta_phantom as f64 / 1048576.0) + format!( + "{} ({:.1} MB)", + meta_phantom, + meta_phantom as f64 / 1048576.0 + ) ); let reduction_pct = (1.0 - meta_phantom as f64 / static_phantom as f64) * 100.0; println!( diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/tests/local_exec_test.rs b/sandbox/plugins/analytics-backend-datafusion/rust/tests/local_exec_test.rs index 0587b92b05b53..272002e2e3dd6 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/tests/local_exec_test.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/tests/local_exec_test.rs @@ -82,6 +82,10 @@ impl RuntimeGuard { spill_bytes.as_ptr(), spill_bytes.len() as i64, 64 * 1024 * 1024, + 0, // liquid_cache_enabled — off for this test + 0, // liquid_cache_size + b"lru".as_ptr(), + 3, // liquid_cache_eviction_policy ) }; assert!(rc > 0, "df_create_global_runtime returned {}", rc); diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/tests/stringview_gc_test.rs b/sandbox/plugins/analytics-backend-datafusion/rust/tests/stringview_gc_test.rs index 44f8e157e06c2..2448278b9c0a7 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/tests/stringview_gc_test.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/tests/stringview_gc_test.rs @@ -65,6 +65,10 @@ impl RuntimeGuard { spill_bytes.as_ptr(), spill_bytes.len() as i64, 64 * 1024 * 1024, + 0, // liquid_cache_enabled — off for this test + 0, // liquid_cache_size + b"lru".as_ptr(), + 3, // liquid_cache_eviction_policy ) }; assert!(rc > 0, "df_create_global_runtime returned {}", rc); @@ -86,7 +90,11 @@ impl Drop for RuntimeGuard { // --------------------------------------------------------------------------- fn utf8view_schema() -> SchemaRef { - Arc::new(Schema::new(vec![Field::new("s", DataType::Utf8View, false)])) + Arc::new(Schema::new(vec![Field::new( + "s", + DataType::Utf8View, + false, + )])) } /// Build a substrait plan for `SELECT * FROM "input-0"` with the given schema. @@ -200,7 +208,11 @@ fn test_stringview_gc_on_sliced_batch() { ); let batch = RecordBatch::try_new( - Arc::new(Schema::new(vec![Field::new("s", DataType::Utf8View, false)])), + Arc::new(Schema::new(vec![Field::new( + "s", + DataType::Utf8View, + false, + )])), vec![Arc::new(sliced)], ) .expect("batch from sliced array"); diff --git a/sandbox/plugins/analytics-backend-datafusion/src/internalClusterTest/java/org/opensearch/be/datafusion/BaseScalarFunctionIT.java b/sandbox/plugins/analytics-backend-datafusion/src/internalClusterTest/java/org/opensearch/be/datafusion/BaseScalarFunctionIT.java index d2b9b2ee75d4c..8ca4d37939aba 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/internalClusterTest/java/org/opensearch/be/datafusion/BaseScalarFunctionIT.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/internalClusterTest/java/org/opensearch/be/datafusion/BaseScalarFunctionIT.java @@ -160,7 +160,6 @@ private void indexBankDocs() { // This lets scalar-JSON UDF tests assert both the happy path (row 1 → length 3) // and the non-array → NULL path (row 6) from real column values. client().prepareIndex(BANK_INDEX) - .setId("1") .setSource( "account_number", 1, @@ -175,7 +174,6 @@ private void indexBankDocs() { ) .get(); client().prepareIndex(BANK_INDEX) - .setId("6") .setSource( "account_number", 6, diff --git a/sandbox/plugins/analytics-backend-datafusion/src/internalClusterTest/java/org/opensearch/be/datafusion/CoordinatorJoinIT.java b/sandbox/plugins/analytics-backend-datafusion/src/internalClusterTest/java/org/opensearch/be/datafusion/CoordinatorJoinIT.java index ec288e2457a96..5bd20cc9d7059 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/internalClusterTest/java/org/opensearch/be/datafusion/CoordinatorJoinIT.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/internalClusterTest/java/org/opensearch/be/datafusion/CoordinatorJoinIT.java @@ -170,7 +170,7 @@ private void createParquetBackedIndex(String indexName, String payloadField) { private void indexKeyedDocs(String indexName, String payloadField) { for (int key = 1; key <= NUM_KEYS; key++) { int payload = payloadField.equals("v") ? expectedV(key) : expectedW(key); - client().prepareIndex(indexName).setId(indexName + "_" + key).setSource("k", key, payloadField, payload).get(); + client().prepareIndex(indexName).setSource("k", key, payloadField, payload).get(); } client().admin().indices().prepareRefresh(indexName).get(); client().admin().indices().prepareFlush(indexName).get(); diff --git a/sandbox/plugins/analytics-backend-datafusion/src/internalClusterTest/java/org/opensearch/be/datafusion/CoordinatorJoinMultiNodeIT.java b/sandbox/plugins/analytics-backend-datafusion/src/internalClusterTest/java/org/opensearch/be/datafusion/CoordinatorJoinMultiNodeIT.java index 09ba047523ca1..f6e6d2324e4e7 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/internalClusterTest/java/org/opensearch/be/datafusion/CoordinatorJoinMultiNodeIT.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/internalClusterTest/java/org/opensearch/be/datafusion/CoordinatorJoinMultiNodeIT.java @@ -340,10 +340,7 @@ private void createParquetIndex(String indexName, int numShards, String payloadF /** One document per key in {@code [keyLo, keyHi]}. Sequential — fine for small N. */ private void indexUnique(String indexName, String payloadField, int keyLo, int keyHi, IntUnaryOperator keyToPayload) { for (int key = keyLo; key <= keyHi; key++) { - client().prepareIndex(indexName) - .setId(indexName + "_" + key) - .setSource("k", key, payloadField, keyToPayload.applyAsInt(key)) - .get(); + client().prepareIndex(indexName).setSource("k", key, payloadField, keyToPayload.applyAsInt(key)).get(); } client().admin().indices().prepareRefresh(indexName).get(); client().admin().indices().prepareFlush(indexName).get(); @@ -356,11 +353,7 @@ private void bulkIndexUnique(String indexName, String payloadField, int keyLo, i int batchEnd = Math.min(batchStart + batchSize - 1, keyHi); org.opensearch.action.bulk.BulkRequestBuilder bulk = client().prepareBulk(); for (int key = batchStart; key <= batchEnd; key++) { - bulk.add( - client().prepareIndex(indexName) - .setId(indexName + "_" + key) - .setSource("k", key, payloadField, keyToPayload.applyAsInt(key)) - ); + bulk.add(client().prepareIndex(indexName).setSource("k", key, payloadField, keyToPayload.applyAsInt(key))); } org.opensearch.action.bulk.BulkResponse response = bulk.get(); assertFalse( @@ -377,7 +370,7 @@ private void indexWithDuplicates(String indexName, String payloadField, int keyL for (int key = keyLo; key <= keyHi; key++) { for (int d = 0; d < dupes; d++) { int payload = key * 1000 + d; - client().prepareIndex(indexName).setId(indexName + "_" + key + "_" + d).setSource("k", key, payloadField, payload).get(); + client().prepareIndex(indexName).setSource("k", key, payloadField, payload).get(); } } client().admin().indices().prepareRefresh(indexName).get(); diff --git a/sandbox/plugins/analytics-backend-datafusion/src/internalClusterTest/java/org/opensearch/be/datafusion/DatafusionDynamicSettingsIT.java b/sandbox/plugins/analytics-backend-datafusion/src/internalClusterTest/java/org/opensearch/be/datafusion/DatafusionDynamicSettingsIT.java index 85cea8d93c102..40fa47e4e2ba0 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/internalClusterTest/java/org/opensearch/be/datafusion/DatafusionDynamicSettingsIT.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/internalClusterTest/java/org/opensearch/be/datafusion/DatafusionDynamicSettingsIT.java @@ -70,26 +70,20 @@ public void testAllIndexedSettingsCanBeUpdatedDynamically() { .prepareUpdateSettings() .setTransientSettings( Settings.builder() - .put("datafusion.indexed.batch_size", 16384) - .put("datafusion.indexed.parquet_pushdown_filters", true) + .put("datafusion.batch_size", 16384) + .put("datafusion.listing_table.pushdown_filters", true) .put("datafusion.indexed.min_skip_run_default", 2048) .put("datafusion.indexed.min_skip_run_selectivity_threshold", 0.5) - .put("datafusion.indexed.single_collector_strategy", "full_range") - .put("datafusion.indexed.tree_collector_strategy", "page_range_split") - .put("datafusion.indexed.max_collector_parallelism", 4) .build() ) .get(); assertTrue(response.isAcknowledged()); Settings transientSettings = response.getTransientSettings(); - assertEquals("16384", transientSettings.get("datafusion.indexed.batch_size")); - assertEquals("true", transientSettings.get("datafusion.indexed.parquet_pushdown_filters")); + assertEquals("16384", transientSettings.get("datafusion.batch_size")); + assertEquals("true", transientSettings.get("datafusion.listing_table.pushdown_filters")); assertEquals("2048", transientSettings.get("datafusion.indexed.min_skip_run_default")); assertEquals("0.5", transientSettings.get("datafusion.indexed.min_skip_run_selectivity_threshold")); - assertEquals("full_range", transientSettings.get("datafusion.indexed.single_collector_strategy")); - assertEquals("page_range_split", transientSettings.get("datafusion.indexed.tree_collector_strategy")); - assertEquals("4", transientSettings.get("datafusion.indexed.max_collector_parallelism")); } public void testInvalidValuesAreRejected() { @@ -98,7 +92,7 @@ public void testInvalidValuesAreRejected() { () -> client().admin() .cluster() .prepareUpdateSettings() - .setTransientSettings(Settings.builder().put("datafusion.indexed.batch_size", 0).build()) + .setTransientSettings(Settings.builder().put("datafusion.batch_size", 0).build()) .get() ); @@ -110,14 +104,5 @@ public void testInvalidValuesAreRejected() { .setTransientSettings(Settings.builder().put("datafusion.indexed.min_skip_run_selectivity_threshold", 1.5).build()) .get() ); - - expectThrows( - IllegalArgumentException.class, - () -> client().admin() - .cluster() - .prepareUpdateSettings() - .setTransientSettings(Settings.builder().put("datafusion.indexed.single_collector_strategy", "bogus").build()) - .get() - ); } } diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionAnalyticsBackendPlugin.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionAnalyticsBackendPlugin.java index a3b04a5a632a1..6831e27bb06e2 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionAnalyticsBackendPlugin.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionAnalyticsBackendPlugin.java @@ -1022,6 +1022,15 @@ public EngineResultStream fetchByRowIds( return new DatafusionResultStream(streamHandle, allocator); } + @Override + public void cancelByContext(long contextId) { + // Fire the per-context cancellation token so the fetch stream's cross_rt task breaks + // cooperatively. No-op for an unknown contextId. + if (contextId != 0) { + NativeBridge.cancelQuery(contextId); + } + } + public Exception convertException(Exception original) { return NativeErrorConverter.convert(original); } diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionPlugin.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionPlugin.java index ab8892c23cbfd..3ce6e6dd79f14 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionPlugin.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionPlugin.java @@ -16,9 +16,13 @@ import org.opensearch.arrow.allocator.ArrowNativeAllocator; import org.opensearch.arrow.spi.NativeAllocator; import org.opensearch.arrow.spi.PoolGroup; +import org.opensearch.be.datafusion.action.LiquidCacheClearAction; import org.opensearch.be.datafusion.action.stats.DataFusionStatsActionType; import org.opensearch.be.datafusion.action.stats.RestDataFusionStatsAction; import org.opensearch.be.datafusion.action.stats.TransportDataFusionStatsAction; +import org.opensearch.be.datafusion.cache.CacheManager; +import org.opensearch.be.datafusion.cache.CacheSettings; +import org.opensearch.be.datafusion.cache.CacheUtils; import org.opensearch.be.datafusion.nativelib.NativeBridge; import org.opensearch.cluster.metadata.IndexNameExpressionResolver; import org.opensearch.cluster.node.DiscoveryNodes; @@ -30,21 +34,26 @@ import org.opensearch.common.settings.Setting; import org.opensearch.common.settings.Settings; import org.opensearch.common.settings.SettingsFilter; +import org.opensearch.common.util.FeatureFlags; import org.opensearch.core.action.ActionResponse; import org.opensearch.core.common.breaker.CircuitBreaker; import org.opensearch.core.common.io.stream.NamedWriteableRegistry; import org.opensearch.core.common.unit.ByteSizeValue; +import org.opensearch.core.index.Index; import org.opensearch.core.indices.breaker.CircuitBreakerStats; import org.opensearch.core.xcontent.NamedXContentRegistry; import org.opensearch.env.Environment; import org.opensearch.env.NodeEnvironment; import org.opensearch.index.IndexSettings; import org.opensearch.index.IndexSortConfig; +import org.opensearch.index.engine.Engine; import org.opensearch.index.engine.dataformat.DataFormatRegistry; import org.opensearch.index.engine.dataformat.ReaderManagerConfig; +import org.opensearch.index.engine.exec.DocumentMetadataResolver; import org.opensearch.index.engine.exec.EngineReaderManager; +import org.opensearch.index.engine.exec.IndexReaderProvider; +import org.opensearch.index.get.DocumentLookupResult; import org.opensearch.indices.breaker.BreakerSettings; -import org.opensearch.monitor.os.OsProbe; import org.opensearch.nativebridge.spi.NativeMemoryFetcher; import org.opensearch.nativebridge.spi.RustLoggerBridge; import org.opensearch.node.resource.tracker.ResourceTrackerSettings; @@ -52,6 +61,7 @@ import org.opensearch.plugin.stats.AnalyticsBackendTaskCancellationStats; import org.opensearch.plugins.ActionPlugin; import org.opensearch.plugins.CircuitBreakerPlugin; +import org.opensearch.plugins.DocumentLookupProvider; import org.opensearch.plugins.NativeStoreHandle; import org.opensearch.plugins.Plugin; import org.opensearch.plugins.SearchBackEndPlugin; @@ -66,10 +76,12 @@ import org.opensearch.watcher.ResourceWatcherService; import java.io.IOException; +import java.nio.file.FileStore; import java.nio.file.Path; import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.function.Function; @@ -90,10 +102,68 @@ public class DataFusionPlugin extends Plugin SearchBackEndPlugin, AnalyticsSearchBackendPlugin, ActionPlugin, - CircuitBreakerPlugin { + CircuitBreakerPlugin, + DocumentLookupProvider { private static final Logger logger = LogManager.getLogger(DataFusionPlugin.class); + /** Fraction of the spill volume's total capacity used as the default cap. */ + static final double SPILL_LIMIT_FRACTION = 0.90; + + /** Fallback when the spill volume's capacity cannot be probed. 8 GiB. */ + static final long SPILL_LIMIT_FALLBACK_BYTES = 8L * 1024 * 1024 * 1024; + + /** + * Validates {@link #DATAFUSION_SPILL_MEMORY_LIMIT} against {@link #DATAFUSION_SPILL_DIRECTORY}: + *
    + *
  • If spill is disabled (empty directory), the limit is a no-op and any value is accepted — + * the capacity check has no volume to probe, so it is skipped.
  • + *
  • If spill is enabled, the value must not exceed the spill volume's total capacity + * (probed live via {@link FileStore#getTotalSpace()}).
  • + *
+ * Probes the filesystem on each validate call. Cluster-settings updates are infrequent and + * the probe is a single syscall (~µs), so caching is not worth the bookkeeping cost. + * If the probe fails ({@link IOException}) the capacity check is skipped — operators retain + * the ability to set the cap; only the safety-net validation is disabled. + */ + static final class SpillLimitValidator implements Setting.Validator { + @Override + public void validate(Long value) { + // Range check (>= 0) lives in the parser; nothing to do here without dependencies. + } + + @Override + public void validate(Long value, Map, Object> dependencies) { + String dir = (String) dependencies.get(DATAFUSION_SPILL_DIRECTORY); + if (dir == null) { + dir = ""; + } + if (dir.isEmpty()) { + // Spill disabled: the limit has no effect and there is no volume to size it against, + // so accept any value rather than rejecting startup over an inert setting. + return; + } + long total; + try { + total = Environment.getFileStore(Path.of(dir)).getTotalSpace(); + } catch (IOException e) { + // Probe failed — skip the capacity check. Same fail-open behavior as the + // boot-time default derivation. + return; + } + if (total > 0 && value > total) { + throw new IllegalArgumentException( + "Setting [datafusion.spill_memory_limit_bytes]=" + value + " exceeds spill volume capacity (" + total + " bytes)" + ); + } + } + + @Override + public Iterator> settings() { + return List.>of(DATAFUSION_SPILL_DIRECTORY).iterator(); + } + } + /** * Memory pool limit for the DataFusion runtime. * @@ -101,7 +171,8 @@ public class DataFusionPlugin extends Plugin * ({@link ResourceTrackerSettings#NODE_NATIVE_MEMORY_LIMIT_SETTING}), which is * the same off-heap budget admission control throttles against. The DataFusion Rust * runtime is the dominant native-memory consumer for analytics workloads (see PR #21732 - * partitioning model), so the default takes 74% of {@code node.native_memory.limit}. + * partitioning model), so the default takes 71% of {@code node.native_memory.limit} + * (reduced from 74% to fund the 3% parquet cache budget). * If the AC limit is unset (== 0), the default is {@link Long#MAX_VALUE} — unbounded — to * preserve pre-AC behaviour rather than make up a number from JVM heap (which is a * separate, already-allocated region with no relation to native-memory sizing). @@ -124,10 +195,16 @@ public class DataFusionPlugin extends Plugin ); /** - * Computes the default for {@link #DATAFUSION_MEMORY_POOL_LIMIT} as 74% of + * Computes the default for {@link #DATAFUSION_MEMORY_POOL_LIMIT} as 71% of * {@link ResourceTrackerSettings#NODE_NATIVE_MEMORY_LIMIT_SETTING}, falling back to * {@link Long#MAX_VALUE} when AC is unconfigured. * + *

Reduced from 74% to 71%: 3% of {@code node.native_memory.limit} is now reserved for + * the DataFusion parquet caches (footer metadata, ColumnIndex, OffsetIndex). That 3% is + * funded by 2% from the operator pool and 1% from the unmanaged headroom (which expanded + * from 21% to 20% of off-heap via the 79→80% change to + * {@code ResourceTrackerSettings.deriveNativeMemoryLimitDefault}). + * *

The fraction is taken straight from {@code node.native_memory.limit}, not from * {@code limit - buffer_percent}. {@code buffer_percent} is an admission-control throttle * margin, not a framework budget reduction; subtracting it here would collapse AC's safety @@ -140,28 +217,53 @@ static String deriveMemoryPoolLimitDefault(Settings settings) { if (nativeLimit.getBytes() <= 0) { return Long.toString(Long.MAX_VALUE); } - // 74% of node.native_memory.limit. DataFusion is the dominant native consumer for + // 71% of node.native_memory.limit. DataFusion is the dominant native consumer for // analytics workloads; operators tune via the dynamic setting once they characterize // their workload. - long pool = Math.max(0L, nativeLimit.getBytes() * 74 / 100); + long pool = Math.max(0L, nativeLimit.getBytes() * 71 / 100); return Long.toString(pool); } + /** + * Spill directory used by DataFusion's {@code DiskManager} for intermediate state when + * operators (HashAggregate, Sort, TopK) exceed {@link #DATAFUSION_MEMORY_POOL_LIMIT}. + * + *

Optional. When set, DataFusion uses {@code DiskManagerMode::Directories} to spill + * to the configured path. When unset (empty), DataFusion runs in + * {@code DiskManagerMode::Disabled} — spill is off and queries that exceed + * {@link #DATAFUSION_MEMORY_POOL_LIMIT} fail with a clear "DiskManager is disabled" error + * rather than silently spilling somewhere unexpected. + * + *

{@code Final} because DataFusion's {@code DiskManager} is built once at runtime + * startup; changing the directory mid-flight would orphan in-progress spill files. + * + *

Declared before {@link #DATAFUSION_SPILL_MEMORY_LIMIT} because the {@code Setting} + * constructor evaluates the default-value supplier eagerly (under {@code -ea}); the + * spill-limit default reads this setting, so it must be initialized first. + */ + public static final Setting DATAFUSION_SPILL_DIRECTORY = new Setting<>( + "datafusion.spill_directory", + "", + Function.identity(), + DataFusionPlugin::validateSpillDirectory, + Setting.Property.NodeScope, + Setting.Property.Final + ); + /** * Disk-staging budget for DataFusion spill. When in-memory operations (HashAggregate, Sort, * TopK) exceed {@link #DATAFUSION_MEMORY_POOL_LIMIT}, DataFusion writes working state to disk; * this setting caps how much disk space that staging can consume. * - *

Default: 50% of physical RAM. Spill is a disk budget, not a memory budget, - * so it is intentionally not derived from {@link ResourceTrackerSettings#NODE_NATIVE_MEMORY_LIMIT_SETTING} - * — the operator-declared off-heap budget bounds working memory, but the spill ceiling needs - * to scale with how much state could plausibly need to spill across all concurrent queries, - * which tracks physical RAM rather than the off-heap carve-out. 50% is a conservative upper - * bound that leaves room for page cache, JVM heap, and OS overhead. + *

Default: 80% of the spill volume's total disk capacity. Spill is a disk + * budget, not a memory budget, so the default is derived from the disk volume itself rather + * than from physical RAM or the off-heap carve-out. This sizes correctly on both supported + * deployment patterns: a dedicated EBS volume mounted at the spill directory, or the spill + * directory on the same disk as the OpenSearch process. * - *

Falls back to {@link Long#MAX_VALUE} when {@link OsProbe#getTotalPhysicalMemorySize()} - * returns 0 (containerized environments where {@code /proc/meminfo} is restricted), preserving - * pre-AC unbounded behaviour. + *

When {@link #DATAFUSION_SPILL_DIRECTORY} is unset (empty), returns {@code 0} — spill is + * disabled and the cap is irrelevant. Falls back to {@link #SPILL_LIMIT_FALLBACK_BYTES} + * (8 GiB) when the spill volume cannot be probed. * *

Dynamic only when the loaded native library exports {@code df_set_spill_limit} * (see {@link org.opensearch.be.datafusion.nativelib.NativeBridge#isSpillLimitDynamic()}). @@ -170,8 +272,8 @@ static String deriveMemoryPoolLimitDefault(Settings settings) { * logs a warning in that case. */ public static final Setting DATAFUSION_SPILL_MEMORY_LIMIT = new Setting<>( - "datafusion.spill_memory_limit_bytes", - s -> deriveSpillLimitDefault(), + new Setting.SimpleKey("datafusion.spill_memory_limit_bytes"), + DataFusionPlugin::deriveSpillLimitDefault, s -> { long v = Long.parseLong(s); if (v < 0) { @@ -179,32 +281,11 @@ static String deriveMemoryPoolLimitDefault(Settings settings) { } return v; }, + new SpillLimitValidator(), Setting.Property.NodeScope, Setting.Property.Dynamic ); - /** - * Spill directory used by DataFusion's {@code DiskManager} for intermediate state when - * operators (HashAggregate, Sort, TopK) exceed {@link #DATAFUSION_MEMORY_POOL_LIMIT}. - * - *

Optional. When set, DataFusion uses {@code DiskManagerMode::Directories} to spill - * to the configured path. When unset (empty), DataFusion runs in - * {@code DiskManagerMode::Disabled} — spill is off and queries that exceed - * {@link #DATAFUSION_MEMORY_POOL_LIMIT} fail with a clear "DiskManager is disabled" error - * rather than silently spilling somewhere unexpected. - * - *

{@code Final} because DataFusion's {@code DiskManager} is built once at runtime - * startup; changing the directory mid-flight would orphan in-progress spill files. - */ - public static final Setting DATAFUSION_SPILL_DIRECTORY = new Setting<>( - "datafusion.spill_directory", - "", - Function.identity(), - DataFusionPlugin::validateSpillDirectory, - Setting.Property.NodeScope, - Setting.Property.Final - ); - /** * Validates {@link #DATAFUSION_SPILL_DIRECTORY}. Empty (the unset sentinel) is accepted * and signals that spill should be disabled. Non-empty values must parse as a {@link Path}. @@ -227,20 +308,36 @@ static String validateSpillDirectory(String value) { } /** - * Computes the default for {@link #DATAFUSION_SPILL_MEMORY_LIMIT} as 50% of physical RAM. - * Returns the bytes-as-string representation expected by the {@link Setting} parser. + * Computes the default for {@link #DATAFUSION_SPILL_MEMORY_LIMIT}. * - *

Falls back to {@link Long#MAX_VALUE} when the OS probe cannot read total physical memory - * (returns 0 or negative), which happens in some containerized environments. Preserving the - * unbounded fallback matches the pattern used by {@link #DATAFUSION_MEMORY_POOL_LIMIT} and - * {@code ArrowBasePlugin}'s pool-max defaults when AC is unconfigured. + *

    + *
  • When {@link #DATAFUSION_SPILL_DIRECTORY} is unset (empty), returns {@code "0"} — + * spill is disabled and the cap is irrelevant.
  • + *
  • When set, returns {@link #SPILL_LIMIT_FRACTION} of the spill volume's + * {@link FileStore#getTotalSpace() total space}.
  • + *
  • When the file store cannot be probed (transient FS hiccup after boot probe), + * returns {@link #SPILL_LIMIT_FALLBACK_BYTES} — a conservative 8 GiB.
  • + *
+ * + *

Spill is a disk budget, so the default is derived from the disk volume itself + * rather than from physical RAM. This sizes correctly on both supported deployment + * patterns: a dedicated EBS volume mounted at the spill directory, or the spill + * directory on the same disk as the OpenSearch process. */ - static String deriveSpillLimitDefault() { - long totalRam = OsProbe.getInstance().getTotalPhysicalMemorySize(); - if (totalRam <= 0) { - return Long.toString(Long.MAX_VALUE); + static String deriveSpillLimitDefault(Settings settings) { + String dir = DATAFUSION_SPILL_DIRECTORY.get(settings); + if (dir == null || dir.isEmpty()) { + return "0"; + } + try { + long total = Environment.getFileStore(Path.of(dir)).getTotalSpace(); + if (total <= 0) { + return Long.toString(SPILL_LIMIT_FALLBACK_BYTES); + } + return Long.toString((long) (total * SPILL_LIMIT_FRACTION)); + } catch (IOException e) { + return Long.toString(SPILL_LIMIT_FALLBACK_BYTES); } - return Long.toString(totalRam / 2); } /** @@ -272,6 +369,18 @@ static String deriveSpillLimitDefault() { Setting.Property.Dynamic ); + /** + * Kill-switch for the scoped page-index feature. + * When false, the scoped CI/OI caches are bypassed and the metadata cache + * retains the full page index (fallback mode). Default: true. + */ + public static final Setting SCOPED_PAGE_INDEX_ENABLED = Setting.boolSetting( + "datafusion.scoped_page_index.enabled", + true, + Setting.Property.NodeScope, + Setting.Property.Dynamic + ); + /** * Admission threshold for the jemalloc memory guard (0.0–1.0). * When pool accounting rejects a phantom reservation but jemalloc reports @@ -331,6 +440,21 @@ static String deriveSpillLimitDefault() { Setting.Property.Dynamic ); + /** + * Total in-flight allocation in bytes allowed through the 85% spill gate by spillable consumers + * so they can finish spilling before the pool rejects them. Bounds how much concurrent spilling + * can collectively borrow above the spill threshold while staying below the 95% critical limit. + * Default 536870912 (512MB) expressed in raw bytes. Live-tunable — takes effect on the next + * allocation decision. + */ + public static final Setting DATAFUSION_MEMORY_GUARD_SPILL_EXEMPT_CAP = Setting.longSetting( + "datafusion.memory_guard.spill_exempt_cap_bytes", + 536870912L, + 0L, + Setting.Property.NodeScope, + Setting.Property.Dynamic + ); + /** * Selects how the coordinator-reduce sink hands shard responses to the native runtime. *

    @@ -368,6 +492,8 @@ static String deriveSpillLimitDefault() { private volatile SimpleExtension.ExtensionCollection substraitExtensions; private volatile ClusterService clusterService; private volatile DatafusionSettings datafusionSettings; + // DocumentLookupProvider implementation. Construction deferred until the DataFusion service is live. + private volatile GetService getService; private volatile CircuitBreaker datafusionBreaker; /** @@ -436,29 +562,79 @@ public Collection createComponents( .spillDirectory(spillDir) .datanodeMultiplier(DatafusionSettings.CONCURRENCY_DATANODE_MULTIPLIER.get(settings)) .clusterSettings(clusterService.getClusterSettings()) + .liquidCacheEnabled(FeatureFlags.isEnabled(FeatureFlags.LIQUID_CACHE_EXPERIMENTAL_SETTING)) + .liquidCacheSize(DatafusionSettings.LIQUID_CACHE_SIZE.get(settings)) + .liquidCacheEvictionPolicy(DatafusionSettings.LIQUID_CACHE_EVICTION_POLICY.get(settings)) .build(); dataFusionService.start(); logger.debug("DataFusion plugin initialized — memory pool {}B, spill limit {}B", memoryPoolLimit, spillMemoryLimit); + // Build the get-by-id service now that the DataFusion runtime is live. The + // DocumentMetadataResolver is supplied per-call by the engine, so it is not needed here. + this.getService = new GetService(this); + // Wire the dynamic spill limit setting to the native runtime so updates via the // cluster settings API take effect without restarting the node. clusterService.getClusterSettings().addSettingsUpdateConsumer(DATAFUSION_SPILL_MEMORY_LIMIT, this::updateSpillMemoryLimit); clusterService.getClusterSettings().addSettingsUpdateConsumer(DATAFUSION_MIN_TARGET_PARTITIONS, this::updateMinTargetPartitions); + // Recompute and push absolute cache limits whenever the total budget or any + // sub-cache percentage changes. Validates that percentages sum to 100 first. + // The total-size budget and the four sub-cache percentages together determine the + // absolute CI/OI limits, so they are wired through a single grouped Consumer. + // This MUST use the grouped overload (not `v -> recompute(getClusterSettings())`): during + // an apply cycle the per-setting getter resolves against `lastSettingsApplied`, which the + // settings framework only swaps in AFTER all update consumers have run — so a callback that + // re-reads via getClusterSettings().get() sees the stale/previous value and pushes the + // limit derived from the OLD budget (an off-by-one update). The grouped consumer receives a + // Settings built from the cycle's new values, so reading each setting from it is correct. + clusterService.getClusterSettings() + .addSettingsUpdateConsumer( + this::recomputePageCacheLimits, + List.of( + CacheSettings.METADATA_INDEX_CACHE_TOTAL_SIZE, + CacheSettings.FOOTER_METADATA_CACHE_PERCENT, + CacheSettings.OFFSET_INDEX_CACHE_PERCENT, + CacheSettings.COLUMN_INDEX_CACHE_PERCENT, + CacheSettings.STATISTICS_CACHE_PERCENT + ) + ); clusterService.getClusterSettings() .addSettingsUpdateConsumer(DATAFUSION_REDUCE_TARGET_PARTITIONS, NativeBridge::setReduceTargetPartitions); + clusterService.getClusterSettings().addSettingsUpdateConsumer(SCOPED_PAGE_INDEX_ENABLED, enabled -> { + NativeBridge.setScopedPageIndexEnabled(enabled); + if (!enabled) { + // Clear scoped caches immediately when disabling — entries are + // useless in fallback mode and would waste native heap. + NativeBridge.clearColumnIndexCache(); + NativeBridge.clearOffsetIndexCache(); + logger.info("Scoped page-index disabled: cleared CI and OI caches"); + } + }); clusterService.getClusterSettings() - .addSettingsUpdateConsumer(DATAFUSION_MEMORY_GUARD_ADMISSION_THROTTLE_THRESHOLD, v -> updateMemoryGuardThresholds()); + .addSettingsUpdateConsumer(DATAFUSION_MEMORY_GUARD_SPILL_EXEMPT_CAP, NativeBridge::setSpillExemptCapBytes); + // The four memory-guard thresholds are pushed to the native pool together via a single + // grouped consumer. This MUST use the grouped Consumer overload (not + // `v -> updateMemoryGuardThresholds()` re-reading via getClusterSettings().get()): during + // an apply cycle the per-setting getter resolves against `lastSettingsApplied`, which the + // settings framework only swaps in AFTER all update consumers have run — so a callback that + // re-reads sees the stale/previous value and would push defaults instead of the new value. + // The grouped consumer receives a Settings built from the cycle's new (`current`) settings, + // so reading each threshold from it yields the value just set. clusterService.getClusterSettings() - .addSettingsUpdateConsumer(DATAFUSION_MEMORY_GUARD_ADMISSION_REJECT_THRESHOLD, v -> updateMemoryGuardThresholds()); + .addSettingsUpdateConsumer( + this::updateMemoryGuardThresholds, + List.of( + DATAFUSION_MEMORY_GUARD_ADMISSION_THROTTLE_THRESHOLD, + DATAFUSION_MEMORY_GUARD_ADMISSION_REJECT_THRESHOLD, + DATAFUSION_MEMORY_GUARD_EXECUTION_SPILL_THRESHOLD, + DATAFUSION_MEMORY_GUARD_EXECUTION_CRITICAL_THRESHOLD + ) + ); // Push Rust log level whenever any logger.* cluster setting changes, so Rust macros // can short-circuit format!() for suppressed levels without polling. clusterService.getClusterSettings() .addAffixUpdateConsumer(Loggers.LOG_LEVEL_SETTING, (namespace, level) -> RustLoggerBridge.pushLevel(), (k, v) -> {}); - clusterService.getClusterSettings() - .addSettingsUpdateConsumer(DATAFUSION_MEMORY_GUARD_EXECUTION_SPILL_THRESHOLD, v -> updateMemoryGuardThresholds()); - clusterService.getClusterSettings() - .addSettingsUpdateConsumer(DATAFUSION_MEMORY_GUARD_EXECUTION_CRITICAL_THRESHOLD, v -> updateMemoryGuardThresholds()); // Wire dynamic concurrency gate multiplier settings int cpuThreads = DataFusionService.cpuThreadCount(); @@ -471,6 +647,8 @@ public Collection createComponents( // Apply initial values NativeBridge.setMinTargetPartitions(DATAFUSION_MIN_TARGET_PARTITIONS.get(settings)); NativeBridge.setReduceTargetPartitions(DATAFUSION_REDUCE_TARGET_PARTITIONS.get(settings)); + NativeBridge.setSpillExemptCapBytes(DATAFUSION_MEMORY_GUARD_SPILL_EXEMPT_CAP.get(settings)); + NativeBridge.setScopedPageIndexEnabled(SCOPED_PAGE_INDEX_ENABLED.get(settings)); NativeBridge.setMemoryGuardThresholds( DATAFUSION_MEMORY_GUARD_ADMISSION_THROTTLE_THRESHOLD.get(settings), DATAFUSION_MEMORY_GUARD_ADMISSION_REJECT_THRESHOLD.get(settings), @@ -478,6 +656,31 @@ public Collection createComponents( DATAFUSION_MEMORY_GUARD_EXECUTION_CRITICAL_THRESHOLD.get(settings) ); + // Wire Liquid Cache dynamic settings only when the experimental flag is enabled. + if (FeatureFlags.isEnabled(FeatureFlags.LIQUID_CACHE_EXPERIMENTAL_SETTING)) { + clusterService.getClusterSettings() + .addSettingsUpdateConsumer(DatafusionSettings.LIQUID_CACHE_ENABLED, enabled -> NativeBridge.setLiquidCacheEnabled(enabled)); + clusterService.getClusterSettings() + .addSettingsUpdateConsumer(DatafusionSettings.LIQUID_CACHE_SIZE, bytes -> NativeBridge.setLiquidCacheMemoryLimit(bytes)); + clusterService.getClusterSettings() + .addSettingsUpdateConsumer( + DatafusionSettings.LIQUID_CACHE_SELECTIVITY_THRESHOLD, + threshold -> NativeBridge.setLiquidCacheSelectivityThreshold((long) (threshold * 1000)) + ); + clusterService.getClusterSettings() + .addSettingsUpdateConsumer( + DatafusionSettings.LIQUID_CACHE_MAX_COLUMNS, + count -> NativeBridge.setLiquidCacheMaxColumns((long) count) + ); + + NativeBridge.setLiquidCacheEnabled(DatafusionSettings.LIQUID_CACHE_ENABLED.get(settings)); + NativeBridge.setLiquidCacheMemoryLimit(DatafusionSettings.LIQUID_CACHE_SIZE.get(settings)); + NativeBridge.setLiquidCacheSelectivityThreshold( + (long) (DatafusionSettings.LIQUID_CACHE_SELECTIVITY_THRESHOLD.get(settings) * 1000) + ); + NativeBridge.setLiquidCacheMaxColumns((long) DatafusionSettings.LIQUID_CACHE_MAX_COLUMNS.get(settings)); + } + this.datafusionSettings = new DatafusionSettings(clusterService); // Expose per-task native-memory usage to search backpressure. @@ -681,11 +884,61 @@ void updateMinTargetPartitions(int value) { logger.info("Updated DataFusion min_target_partitions to {}", value); } - private void updateMemoryGuardThresholds() { - double admissionThrottle = clusterService.getClusterSettings().get(DATAFUSION_MEMORY_GUARD_ADMISSION_THROTTLE_THRESHOLD); - double admissionReject = clusterService.getClusterSettings().get(DATAFUSION_MEMORY_GUARD_ADMISSION_REJECT_THRESHOLD); - double executionSpill = clusterService.getClusterSettings().get(DATAFUSION_MEMORY_GUARD_EXECUTION_SPILL_THRESHOLD); - double executionCritical = clusterService.getClusterSettings().get(DATAFUSION_MEMORY_GUARD_EXECUTION_CRITICAL_THRESHOLD); + /** + * Recompute absolute ColumnIndex and OffsetIndex cache limits from the updated + * {@link CacheSettings#METADATA_INDEX_CACHE_TOTAL_SIZE} and percent settings, then push them + * to native. Validates that percentages sum to 100 before applying. + * + *

    Reads each value from the {@code updated} Settings supplied by the grouped settings-update + * consumer — NOT via {@code clusterService.getClusterSettings().get(...)}, which during an apply + * cycle still resolves against the previous settings and would derive limits from the stale + * budget (an off-by-one update). + */ + private void recomputePageCacheLimits(org.opensearch.common.settings.Settings updated) { + long total = CacheSettings.METADATA_INDEX_CACHE_TOTAL_SIZE.get(updated).getBytes(); + int metaPct = CacheSettings.FOOTER_METADATA_CACHE_PERCENT.get(updated); + int oiPct = CacheSettings.OFFSET_INDEX_CACHE_PERCENT.get(updated); + int ciPct = CacheSettings.COLUMN_INDEX_CACHE_PERCENT.get(updated); + int statsPct = CacheSettings.STATISTICS_CACHE_PERCENT.get(updated); + CacheSettings.validatePercentSum(metaPct, oiPct, ciPct, statsPct); + long metaLimit = total * metaPct / 100; + long ciLimit = total * ciPct / 100; + long oiLimit = total * oiPct / 100; + long statsLimit = total * statsPct / 100; + logger.info( + "Updating cache limits: footer_metadata={} bytes, " + "column_index={} bytes, offset_index={} bytes, statistics={} bytes", + metaLimit, + ciLimit, + oiLimit, + statsLimit + ); + NativeBridge.setColumnIndexCacheLimit(ciLimit); + NativeBridge.setOffsetIndexCacheLimit(oiLimit); + DataFusionService service = dataFusionService; + if (service != null) { + CacheManager cm = service.getCacheManager(); + if (cm != null) { + cm.updateSizeLimit(CacheUtils.CacheType.METADATA, metaLimit); + cm.updateSizeLimit(CacheUtils.CacheType.STATISTICS, statsLimit); + } + } + } + + /** + * Pushes the four memory-guard thresholds to the native pool. Reads each value from the + * `updated` Settings supplied by the grouped settings-update consumer — which the framework + * builds from the cycle's new settings — rather than re-reading via + * `clusterService.getClusterSettings().get(...)`. The latter resolves against + * `lastSettingsApplied`, which is not swapped in until after all update consumers have run, so + * re-reading mid-cycle would yield the stale/previous value (the root cause of thresholds + * silently not updating at runtime). Unchanged thresholds are filled with their registered + * defaults in `updated`, so passing all four every time is correct. + */ + private void updateMemoryGuardThresholds(Settings updated) { + double admissionThrottle = DATAFUSION_MEMORY_GUARD_ADMISSION_THROTTLE_THRESHOLD.get(updated); + double admissionReject = DATAFUSION_MEMORY_GUARD_ADMISSION_REJECT_THRESHOLD.get(updated); + double executionSpill = DATAFUSION_MEMORY_GUARD_EXECUTION_SPILL_THRESHOLD.get(updated); + double executionCritical = DATAFUSION_MEMORY_GUARD_EXECUTION_CRITICAL_THRESHOLD.get(updated); NativeBridge.setMemoryGuardThresholds(admissionThrottle, admissionReject, executionSpill, executionCritical); logger.info( "Updated DataFusion memory guard thresholds: admission_throttle={}, admission_reject={}, execution_spill={}, execution_critical={}", @@ -748,7 +1001,13 @@ public List getSupportedFormats() { @Override public List> getActions() { - return List.of(new ActionHandler<>(DataFusionStatsActionType.INSTANCE, TransportDataFusionStatsAction.class)); + return List.of( + new ActionHandler<>(DataFusionStatsActionType.INSTANCE, TransportDataFusionStatsAction.class), + new ActionHandler<>( + org.opensearch.be.datafusion.action.stats.ClearCacheActionType.INSTANCE, + org.opensearch.be.datafusion.action.stats.TransportClearCacheAction.class + ) + ); } @Override @@ -764,7 +1023,14 @@ public List getRestHandlers( if (dataFusionService == null) { return Collections.emptyList(); } - return List.of(new RestDataFusionStatsAction()); + if (FeatureFlags.isEnabled(FeatureFlags.LIQUID_CACHE_EXPERIMENTAL_SETTING)) { + return List.of( + new RestDataFusionStatsAction(), + new org.opensearch.be.datafusion.action.stats.RestClearCacheAction(), + new LiquidCacheClearAction(dataFusionService) + ); + } + return List.of(new RestDataFusionStatsAction(), new org.opensearch.be.datafusion.action.stats.RestClearCacheAction()); } @Override @@ -812,6 +1078,9 @@ public Supplier getAnalyticsBackendNativeMemo @Override public void close() throws IOException { + if (getService != null) { + getService.close(); + } if (dataFusionService != null) { dataFusionService.close(); } @@ -839,4 +1108,48 @@ public Map getTopQueriesByMemory() { } return result; } + + /** + * Get-by-id entry point. Delegates to {@link GetService}, which fetches the row through the native runtime. + */ + @Override + public DocumentLookupResult getById(Engine.Get get, IndexReaderProvider.Reader reader, Index index, DocumentMetadataResolver resolver) + throws IOException { + GetService getService = getServiceOrThrow(); + return getService.documentLookupService(resolver).getById(get.id(), reader, index); + } + + @Override + public DocumentLookupResult getVersionMetadata( + String id, + IndexReaderProvider.Reader reader, + Index index, + DocumentMetadataResolver resolver + ) throws IOException { + GetService svc = getServiceOrThrow(); + return svc.documentLookupService(resolver).getVersionMetadata(id, reader, index); + } + + @Override + public List getDocsAboveSeqNo( + long fromSeqNoExclusive, + IndexReaderProvider.Reader reader, + Index index, + DocumentMetadataResolver resolver + ) throws IOException { + GetService svc = getService; + if (svc == null) return List.of(); + return svc.documentLookupService(resolver).getDocsAboveSeqNo(fromSeqNoExclusive, reader, index); + } + + /** + * Returns the {@link GetService} , throwing IllegalStateException if not initialized. + */ + private GetService getServiceOrThrow() { + GetService svc = getService; + if (svc == null) { + throw new IllegalStateException("GetService is not initialized. "); + } + return svc; + } } diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionService.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionService.java index adfd2eb5356ec..420a60a1f8f80 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionService.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionService.java @@ -49,6 +49,9 @@ public class DataFusionService extends AbstractLifecycleComponent { private final double datanodeMultiplier; private final double coordinatorMultiplier; private final ClusterSettings clusterSettings; + private final boolean liquidCacheEnabled; + private final long liquidCacheSize; + private final String liquidCacheEvictionPolicy; /** Handle to the native DataFusion global runtime (memory pool + cache). */ private volatile NativeRuntimeHandle runtimeHandle; @@ -64,6 +67,9 @@ private DataFusionService(Builder builder) { this.datanodeMultiplier = builder.datanodeMultiplier; this.coordinatorMultiplier = builder.coordinatorMultiplier; this.clusterSettings = builder.clusterSettings; + this.liquidCacheEnabled = builder.liquidCacheEnabled; + this.liquidCacheSize = builder.liquidCacheSize; + this.liquidCacheEvictionPolicy = builder.liquidCacheEvictionPolicy; } /** Creates a new builder. */ @@ -98,7 +104,15 @@ protected void doStart() { } try { - long ptr = NativeBridge.createGlobalRuntime(memoryPoolLimit, cacheManagerPtr, spillDirectory, spillMemoryLimit); + long ptr = NativeBridge.createGlobalRuntime( + memoryPoolLimit, + cacheManagerPtr, + spillDirectory, + spillMemoryLimit, + liquidCacheEnabled, + liquidCacheSize, + liquidCacheEvictionPolicy + ); if (cacheHandle != null) { cacheHandle.markConsumed(); } @@ -266,6 +280,23 @@ public void onFilesAdded(Collection filePaths) { } } + /** + * Warmup files through the TieredObjectStore — reads metadata via the store + * (populating Foyer caches) and puts into heap cache. For warm nodes where + * files are on S3, this routes through the registry to remote storage. + * + * @param filePaths absolute paths of the files to warm + * @param storeBoxPtr Box<Arc<dyn ObjectStore>> pointer from TieredStorageBridge.getObjectStoreBoxPtr + */ + public void onFilesAddedWithStore(Collection filePaths, long storeBoxPtr) { + if (filePaths == null || filePaths.isEmpty()) return; + try { + NativeBridge.cacheManagerAddFilesWithStore(runtimeHandle.get(), storeBoxPtr, filePaths.toArray(new String[0])); + } catch (Exception e) { + logger.warn("Failed to warmup files with store", e); + } + } + /** * Notifies the native cache that files have been deleted and should be evicted. * @param filePaths absolute paths of the deleted files @@ -279,6 +310,13 @@ public void onFilesDeleted(Collection filePaths) { } } + /** + * Clears the Liquid Cache and DataFusion internal caches. + */ + public void clearLiquidCache() { + NativeBridge.clearLiquidCache(getNativeRuntime().get()); + } + private void releaseRuntime() { NativeRuntimeHandle handle = runtimeHandle; if (handle != null) { @@ -299,6 +337,9 @@ public static class Builder { private double datanodeMultiplier = 1.0; private double coordinatorMultiplier = 1.0; private ClusterSettings clusterSettings; + private boolean liquidCacheEnabled = false; + private long liquidCacheSize = 1L * 1024 * 1024 * 1024; // 1GB default + private String liquidCacheEvictionPolicy = "lru"; private Builder() {} @@ -359,6 +400,33 @@ public Builder clusterSettings(ClusterSettings clusterSettings) { return this; } + /** + * Enables or disables Liquid Cache for byte-level Parquet caching. + * @param enabled whether to enable liquid cache + */ + public Builder liquidCacheEnabled(boolean enabled) { + this.liquidCacheEnabled = enabled; + return this; + } + + /** + * Sets the Liquid Cache size in bytes. + * @param bytes cache size limit + */ + public Builder liquidCacheSize(long bytes) { + this.liquidCacheSize = bytes; + return this; + } + + /** + * Sets the Liquid Cache eviction policy (liquid, lru). + * @param policy the eviction policy + */ + public Builder liquidCacheEvictionPolicy(String policy) { + this.liquidCacheEvictionPolicy = policy; + return this; + } + /** Builds the {@link DataFusionService}. */ public DataFusionService build() { return new DataFusionService(this); diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DatafusionReaderManager.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DatafusionReaderManager.java index a066eda7bf3da..008468648ddd5 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DatafusionReaderManager.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DatafusionReaderManager.java @@ -110,7 +110,30 @@ public void onFilesDeleted(Collection files) throws IOException { @Override public void onFilesAdded(Collection files) throws IOException { if (files == null || files.isEmpty()) return; - dataFusionService.onFilesAdded(toAbsolutePaths(files)); + Collection absolutePaths = toAbsolutePaths(files); + long storePtr = storePointerOrDefault(dataformatAwareStoreHandle); + if (storePtr > 0) { + dataFusionService.onFilesAddedWithStore(absolutePaths, storePtr); + } else { + dataFusionService.onFilesAdded(absolutePaths); + } + } + + /** + * Resolves the native store pointer for cache warming. Returns {@code 0} when there is no + * live handle (no per-shard remote store, e.g. hot tier) so the caller falls back to the + * legacy local-FS warming path. + */ + private static long storePointerOrDefault(NativeStoreHandle handle) { + if (handle == null) { + return 0L; + } + try { + return handle.getPointer(); + } catch (IllegalStateException closed) { + // Handle closed between check and extraction — fall back to local. + return 0L; + } } @Override diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DatafusionReduceSink.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DatafusionReduceSink.java index 1ee4c1bd9f0dd..6ece9d4f20a7f 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DatafusionReduceSink.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DatafusionReduceSink.java @@ -81,6 +81,10 @@ enum SinkState { /** Guards the teardown body so concurrent + sequential close paths don't run it twice. */ final java.util.concurrent.atomic.AtomicBoolean torndown = new java.util.concurrent.atomic.AtomicBoolean(); + /** Signalled when reduce's finally completes teardown. closeImpl awaits this + * when cancelled during REDUCING state so the allocator isn't closed prematurely. */ + private final java.util.concurrent.CountDownLatch reduceDone = new java.util.concurrent.CountDownLatch(1); + public DatafusionReduceSink(ExchangeSinkContext ctx, NativeRuntimeHandle runtimeHandle) { this(ctx, runtimeHandle, null); } @@ -359,9 +363,17 @@ protected void feedBatchUnderLock(VectorSchemaRoot batch) { protected Exception closeImpl() { SinkState before = state.compareAndExchange(SinkState.READY, SinkState.DONE); if (before == SinkState.REDUCING) { - // Drain parked — dropping senders/outStream now would panic in drop_in_place. + // Drain in flight — fire cancel so it unblocks, then wait for reduce's + // finally to complete teardown (releases Arrow batches from the allocator). fireCancelQuery(); - return null; // reduce()'s finally calls closeImpl directly to tear down. + try { + if (!reduceDone.await(5, java.util.concurrent.TimeUnit.SECONDS)) { + logger.warn("[reduce-sink] timed out waiting for reduce teardown: taskId={}", ctx.taskId()); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return null; } // before == READY (we just won) or DONE (reduce's finally calling us, or duplicate close). if (torndown.compareAndSet(false, true) == false) { @@ -419,6 +431,9 @@ public void reduce(ActionListener listener) { } catch (Exception t) { failure = accumulate(failure, t); } + // Signal that teardown is complete — unblocks any concurrent closeImpl() + // waiting on REDUCING state (cancel path) so the allocator can close safely. + reduceDone.countDown(); } if (failure == null) { listener.onResponse(null); diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DatafusionSettings.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DatafusionSettings.java index 06de123d0a675..4828db8a206d3 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DatafusionSettings.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DatafusionSettings.java @@ -39,8 +39,8 @@ public final class DatafusionSettings { // ── New indexed query settings ── /** Number of rows per batch in the indexed query execution path. */ - public static final Setting INDEXED_BATCH_SIZE = Setting.intSetting( - "datafusion.indexed.batch_size", + public static final Setting BATCH_SIZE = Setting.intSetting( + "datafusion.batch_size", 8192, 1, Setting.Property.NodeScope, @@ -48,44 +48,29 @@ public final class DatafusionSettings { ); /** - * Whether DataFusion applies residual predicate pushdown during parquet decode - * on the indexed path. When true, narrow row-granular selections benefit from - * decode-time filtering via {@code RowFilter}. When false (default), the indexed - * stream handles filtering externally via bitmap-based row selection. - *

    - * Note: ideally this decision should be taken by the planner on a per-query basis - * (e.g., based on filter shape and estimated selectivity). This setting acts as - * the node-wide default until per-query planner support is added. + * Whether DataFusion applies its own decode-time predicate pushdown on the + * ListingTable (non-indexed) query path. Maps to DataFusion's + * {@code execution.parquet.pushdown_filters} session option. */ - public static final Setting INDEXED_PARQUET_PUSHDOWN_FILTERS = Setting.boolSetting( - "datafusion.indexed.parquet_pushdown_filters", + public static final Setting LISTING_TABLE_PUSHDOWN_FILTERS = Setting.boolSetting( + "datafusion.listing_table.pushdown_filters", false, Setting.Property.NodeScope, Setting.Property.Dynamic ); /** - * Whether to use parquet bloom filters for row-group pruning on the indexed read path. - * When true, equality predicates are checked against the SBBF (Split Block Bloom Filter) - * embedded in the parquet footer before invoking the expensive FFM collector call. - * If the bloom filter proves absence, the row group is skipped entirely. - */ - public static final Setting INDEXED_BLOOM_FILTER_ON_READ = Setting.boolSetting( - "datafusion.indexed.bloom_filter_on_read", - true, - Setting.Property.NodeScope, - Setting.Property.Dynamic - ); - - /** - * Whether the indexed scan accepts runtime dynamic filters (TopK / join) - * pushed down via physical filter pushdown and uses them to prune row groups - * whose parquet statistics cannot satisfy the tightening predicate. On by - * default; turn off to A/B the performance impact. When off, the scan - * declines the pushdown and behaves exactly as before this feature. + * Whether the indexed stream asks parquet to apply the residual predicate during + * decode (via {@code RowFilter} pushdown). When true (default), narrow row-granular + * selections benefit from decode-time filtering; when false, the indexed stream + * handles filtering externally via bitmap-based row selection. + *

    + * Note: ideally this decision should be taken by the planner on a per-query basis + * (e.g., based on filter shape and estimated selectivity). This setting acts as + * the node-wide default until per-query planner support is added. */ - public static final Setting INDEXED_DYNAMIC_FILTER_PUSHDOWN = Setting.boolSetting( - "datafusion.indexed.dynamic_filter_pushdown", + public static final Setting INDEXED_PUSHDOWN_FILTERS = Setting.boolSetting( + "datafusion.indexed.pushdown_filters", true, Setting.Property.NodeScope, Setting.Property.Dynamic @@ -122,76 +107,44 @@ public final class DatafusionSettings { Setting.Property.Dynamic ); - // Strategy constants for CollectorCallStrategy - public static final String STRATEGY_FULL_RANGE = "full_range"; - public static final String STRATEGY_TIGHTEN_OUTER_BOUNDS = "tighten_outer_bounds"; - public static final String STRATEGY_PAGE_RANGE_SPLIT = "page_range_split"; - /** - * How the SingleCollectorEvaluator narrows collector doc ranges relative to - * page-pruning results. Valid values: full_range, tighten_outer_bounds, page_range_split. - * Default is page_range_split — only one collector, so multiple FFM calls per RG is acceptable. + * Pins the indexed stream's per-row-group {@code min_skip_run} strategy instead of + * letting candidate selectivity decide. Accepts: + *

      + *
    • {@code "none"} (default) — selectivity heuristic decides (wire {@code -1});
    • + *
    • {@code "row_selection"} — force row-granular selection, {@code min_skip_run = 1} (wire {@code 0});
    • + *
    • {@code "boolean_mask"} — force a single whole-RG select (wire {@code 1}).
    • + *
    + * Node-wide override intended for benchmarking/diagnostics; production normally + * leaves this at {@code "none"}. */ - public static final Setting INDEXED_SINGLE_COLLECTOR_STRATEGY = Setting.simpleString( - "datafusion.indexed.single_collector_strategy", - STRATEGY_PAGE_RANGE_SPLIT, - value -> { - switch (value) { - case STRATEGY_FULL_RANGE: - case STRATEGY_TIGHTEN_OUTER_BOUNDS: - case STRATEGY_PAGE_RANGE_SPLIT: - break; - default: - throw new IllegalArgumentException( - "datafusion.indexed.single_collector_strategy must be one of " - + "[full_range, tighten_outer_bounds, page_range_split], got: " - + value - ); - } - }, + public static final Setting INDEXED_FORCE_STRATEGY = Setting.simpleString( + "datafusion.indexed.force_strategy", + "none", + DatafusionSettings::validateForceStrategy, Setting.Property.NodeScope, Setting.Property.Dynamic ); - /** - * How the bitmap tree evaluator narrows collector doc ranges when multiple collectors - * are present. Valid values: full_range, tighten_outer_bounds, page_range_split. - * Default is tighten_outer_bounds — multiple collectors make page_range_split expensive. - */ - public static final Setting INDEXED_TREE_COLLECTOR_STRATEGY = Setting.simpleString( - "datafusion.indexed.tree_collector_strategy", - STRATEGY_TIGHTEN_OUTER_BOUNDS, - value -> { - switch (value) { - case STRATEGY_FULL_RANGE: - case STRATEGY_TIGHTEN_OUTER_BOUNDS: - case STRATEGY_PAGE_RANGE_SPLIT: - break; - default: - throw new IllegalArgumentException( - "datafusion.indexed.tree_collector_strategy must be one of " - + "[full_range, tighten_outer_bounds, page_range_split], got: " - + value - ); - } - }, - Setting.Property.NodeScope, - Setting.Property.Dynamic - ); + private static void validateForceStrategy(String value) { + forceStrategyToWire(value); + } - /** - * Maximum number of Collector-leaf FFM calls issued in parallel per row-group - * prefetch. 1 = fully sequential (lowest CPU, fastest short-circuit). Higher - * values sacrifice short-circuit savings in AND/OR groups but reduce latency - * for independent collector leaves. - */ - public static final Setting INDEXED_MAX_COLLECTOR_PARALLELISM = Setting.intSetting( - "datafusion.indexed.max_collector_parallelism", - 1, - 1, - Setting.Property.NodeScope, - Setting.Property.Dynamic - ); + /** Maps the {@link #INDEXED_FORCE_STRATEGY} string to the Rust wire code (-1/0/1). */ + static int forceStrategyToWire(String value) { + switch (value) { + case "none": + return -1; + case "row_selection": + return 0; + case "boolean_mask": + return 1; + default: + throw new IllegalArgumentException( + "Setting [datafusion.indexed.force_strategy] must be one of [none, row_selection, boolean_mask], got [" + value + "]" + ); + } + } // ── Concurrency gate settings ── @@ -220,47 +173,6 @@ public final class DatafusionSettings { Setting.Property.Dynamic ); - // Query strategy constants - public static final String QUERY_STRATEGY_NONE = "none"; - public static final String QUERY_STRATEGY_LISTING_TABLE = "listing_table"; - public static final String QUERY_STRATEGY_INDEXED = "indexed"; - - /** - * Query strategy for query-then-fetch (QTF) row ID computation. - *

    - * Controls how shard-global row IDs are computed when the query projects {@code __row_id__}. - *

      - *
    • {@code none} — No global row ID computation. Reads {@code __row_id__} as a regular - * column from parquet (per-file 0-based values, NOT shard-global). Useful for debugging.
    • - *
    • {@code listing_table} — Uses ShardTableProvider with a {@code row_base} partition column. - * Reads {@code __row_id__} from parquet and adds the file's cumulative row offset via - * ProjectRowIdOptimizer ({@code __row_id__ + row_base = global_id}). Works with standard - * DataFusion ListingTable scan path.
    • - *
    • {@code indexed} — Uses the indexed pipeline (segment partitioning, PositionMap). - * Computes row IDs from position ({@code global_base + rg.first_row + position_in_rg}). - * Zero I/O for the row ID column. Fastest path when the indexed executor is available.
    • - *
    - * Default: {@code indexed}. - */ - public static final Setting INDEXED_QUERY_STRATEGY = Setting.simpleString( - "datafusion.indexed.query_strategy", - QUERY_STRATEGY_INDEXED, - value -> { - switch (value) { - case QUERY_STRATEGY_NONE: - case QUERY_STRATEGY_LISTING_TABLE: - case QUERY_STRATEGY_INDEXED: - break; - default: - throw new IllegalArgumentException( - "datafusion.indexed.query_strategy must be one of " + "[none, listing_table, indexed], got: " + value - ); - } - }, - Setting.Property.NodeScope, - Setting.Property.Dynamic - ); - /** * Computes the default for a pool min as a percentage of * {@link ResourceTrackerSettings#NODE_NATIVE_MEMORY_LIMIT_SETTING}. @@ -276,6 +188,69 @@ static String derivePoolMinDefault(Settings settings, int percent) { // ── All settings registered by the plugin ── + /** + * Dynamically enables or disables Liquid Cache for new queries. + * When false, optimizers are not injected and queries bypass the cache. + * The cache remains initialized (for fast re-enable) but idle. + */ + public static final Setting LIQUID_CACHE_ENABLED = Setting.boolSetting( + "datafusion.liquid_cache.enabled", + true, + Setting.Property.NodeScope, + Setting.Property.Dynamic + ); + + /** + * Controls the Liquid Cache max memory size in bytes for byte-level Parquet caching. + * Only used when liquid cache is enabled via the experimental feature flag. + */ + public static final Setting LIQUID_CACHE_SIZE = Setting.longSetting( + "datafusion.liquid_cache.size_bytes", + 1L * 1024 * 1024 * 1024, // 1GB default + 0L, + Setting.Property.NodeScope, + Setting.Property.Dynamic + ); + + /** + * Liquid Cache eviction policy. + *
      + *
    • {@code liquid} (default) — Independent FIFO queues per batch type (LiquidPolicy)
    • + *
    • {@code lru} — Standard LRU eviction across all entry types
    • + *
    + */ + public static final Setting LIQUID_CACHE_EVICTION_POLICY = Setting.simpleString( + "datafusion.liquid_cache.eviction_policy", + "lru", + Setting.Property.NodeScope, + Setting.Property.Final + ); + + /** + * Selectivity threshold for LC STREAM vs DELEGATE (0.0 to 1.0). + * Files with selectivity below this threshold are delegated to plain parquet. + */ + public static final Setting LIQUID_CACHE_SELECTIVITY_THRESHOLD = Setting.floatSetting( + "datafusion.liquid_cache.selectivity_threshold", + 0.8f, + 0.0f, + 1.0f, + Setting.Property.NodeScope, + Setting.Property.Dynamic + ); + + /** + * Maximum number of output columns for LC engagement. + * Queries projecting more columns than this are skipped by the optimizer. + */ + public static final Setting LIQUID_CACHE_MAX_COLUMNS = Setting.intSetting( + "datafusion.liquid_cache.max_columns", + 10, + 1, + Setting.Property.NodeScope, + Setting.Property.Dynamic + ); + public static final List> ALL_SETTINGS = List.of( // Runtime settings — memory pool, spill, reduce input mode, and budget tuning @@ -289,30 +264,41 @@ static String derivePoolMinDefault(Settings settings, int percent) { DataFusionPlugin.DATAFUSION_MEMORY_GUARD_ADMISSION_REJECT_THRESHOLD, DataFusionPlugin.DATAFUSION_MEMORY_GUARD_EXECUTION_SPILL_THRESHOLD, DataFusionPlugin.DATAFUSION_MEMORY_GUARD_EXECUTION_CRITICAL_THRESHOLD, + DataFusionPlugin.DATAFUSION_MEMORY_GUARD_SPILL_EXEMPT_CAP, DATAFUSION_MEMORY_POOL_MIN, - // Cache settings — metadata and statistics cache configuration - CacheSettings.METADATA_CACHE_SIZE_LIMIT, - CacheSettings.STATISTICS_CACHE_SIZE_LIMIT, + // Cache settings — metadata, statistics, column index, offset index configuration CacheSettings.METADATA_CACHE_EVICTION_TYPE, CacheSettings.STATISTICS_CACHE_EVICTION_TYPE, + CacheSettings.COLUMN_INDEX_CACHE_EVICTION_TYPE, + CacheSettings.OFFSET_INDEX_CACHE_EVICTION_TYPE, CacheSettings.METADATA_CACHE_ENABLED, CacheSettings.STATISTICS_CACHE_ENABLED, + CacheSettings.METADATA_INDEX_CACHE_TOTAL_SIZE, + CacheSettings.FOOTER_METADATA_CACHE_PERCENT, + CacheSettings.OFFSET_INDEX_CACHE_PERCENT, + CacheSettings.COLUMN_INDEX_CACHE_PERCENT, + CacheSettings.STATISTICS_CACHE_PERCENT, + + // Scoped page-index feature flag + DataFusionPlugin.SCOPED_PAGE_INDEX_ENABLED, // Concurrency gate settings CONCURRENCY_DATANODE_MULTIPLIER, // Indexed query settings — per-query tuning knobs for the indexed execution path - INDEXED_BATCH_SIZE, - INDEXED_PARQUET_PUSHDOWN_FILTERS, - INDEXED_BLOOM_FILTER_ON_READ, + BATCH_SIZE, + LISTING_TABLE_PUSHDOWN_FILTERS, + INDEXED_PUSHDOWN_FILTERS, INDEXED_MIN_SKIP_RUN_DEFAULT, INDEXED_MIN_SKIP_RUN_SELECTIVITY_THRESHOLD, - INDEXED_SINGLE_COLLECTOR_STRATEGY, - INDEXED_TREE_COLLECTOR_STRATEGY, - INDEXED_MAX_COLLECTOR_PARALLELISM, - INDEXED_QUERY_STRATEGY, - INDEXED_DYNAMIC_FILTER_PUSHDOWN + INDEXED_FORCE_STRATEGY, + + LIQUID_CACHE_ENABLED, + LIQUID_CACHE_SIZE, + LIQUID_CACHE_EVICTION_POLICY, + LIQUID_CACHE_SELECTIVITY_THRESHOLD, + LIQUID_CACHE_MAX_COLUMNS ); // ── Snapshot management ── @@ -345,17 +331,13 @@ public DatafusionSettings(ClusterService clusterService) { this.maxSliceCount = SearchService.CONCURRENT_SEGMENT_SEARCH_TARGET_MAX_SLICE_COUNT_SETTING.get(settings); this.snapshot = WireConfigSnapshot.builder() - .batchSize(INDEXED_BATCH_SIZE.get(settings)) + .batchSize(BATCH_SIZE.get(settings)) .targetPartitions(deriveTargetPartitions(this.concurrentSearchMode, this.maxSliceCount)) - .parquetPushdownFilters(INDEXED_PARQUET_PUSHDOWN_FILTERS.get(settings)) - .bloomFilterOnRead(INDEXED_BLOOM_FILTER_ON_READ.get(settings)) + .listingTablePushdownFilters(LISTING_TABLE_PUSHDOWN_FILTERS.get(settings)) + .indexedPushdownFilters(INDEXED_PUSHDOWN_FILTERS.get(settings)) .minSkipRunDefault(INDEXED_MIN_SKIP_RUN_DEFAULT.get(settings)) .minSkipRunSelectivityThreshold(INDEXED_MIN_SKIP_RUN_SELECTIVITY_THRESHOLD.get(settings)) - .singleCollectorStrategy(strategyToWireValue(INDEXED_SINGLE_COLLECTOR_STRATEGY.get(settings))) - .treeCollectorStrategy(strategyToWireValue(INDEXED_TREE_COLLECTOR_STRATEGY.get(settings))) - .maxCollectorParallelism(INDEXED_MAX_COLLECTOR_PARALLELISM.get(settings)) - .queryStrategy(queryStrategyToWireValue(INDEXED_QUERY_STRATEGY.get(settings))) - .indexedDynamicFilterPushdown(INDEXED_DYNAMIC_FILTER_PUSHDOWN.get(settings)) + .forceStrategy(forceStrategyToWire(INDEXED_FORCE_STRATEGY.get(settings))) .build(); registerListeners(clusterSettings); @@ -370,31 +352,27 @@ public DatafusionSettings(ClusterService clusterService) { this.maxSliceCount = SearchService.CONCURRENT_SEGMENT_SEARCH_TARGET_MAX_SLICE_COUNT_SETTING.get(settings); this.snapshot = WireConfigSnapshot.builder() - .batchSize(INDEXED_BATCH_SIZE.get(settings)) + .batchSize(BATCH_SIZE.get(settings)) .targetPartitions(deriveTargetPartitions(this.concurrentSearchMode, this.maxSliceCount)) - .parquetPushdownFilters(INDEXED_PARQUET_PUSHDOWN_FILTERS.get(settings)) - .bloomFilterOnRead(INDEXED_BLOOM_FILTER_ON_READ.get(settings)) + .listingTablePushdownFilters(LISTING_TABLE_PUSHDOWN_FILTERS.get(settings)) + .indexedPushdownFilters(INDEXED_PUSHDOWN_FILTERS.get(settings)) .minSkipRunDefault(INDEXED_MIN_SKIP_RUN_DEFAULT.get(settings)) .minSkipRunSelectivityThreshold(INDEXED_MIN_SKIP_RUN_SELECTIVITY_THRESHOLD.get(settings)) - .singleCollectorStrategy(strategyToWireValue(INDEXED_SINGLE_COLLECTOR_STRATEGY.get(settings))) - .treeCollectorStrategy(strategyToWireValue(INDEXED_TREE_COLLECTOR_STRATEGY.get(settings))) - .maxCollectorParallelism(INDEXED_MAX_COLLECTOR_PARALLELISM.get(settings)) - .queryStrategy(queryStrategyToWireValue(INDEXED_QUERY_STRATEGY.get(settings))) - .indexedDynamicFilterPushdown(INDEXED_DYNAMIC_FILTER_PUSHDOWN.get(settings)) + .forceStrategy(forceStrategyToWire(INDEXED_FORCE_STRATEGY.get(settings))) .build(); } void registerListeners(ClusterSettings clusterSettings) { - clusterSettings.addSettingsUpdateConsumer(INDEXED_BATCH_SIZE, newValue -> { + clusterSettings.addSettingsUpdateConsumer(BATCH_SIZE, newValue -> { snapshot = WireConfigSnapshot.builder(snapshot).batchSize(newValue).build(); }); - clusterSettings.addSettingsUpdateConsumer(INDEXED_PARQUET_PUSHDOWN_FILTERS, newValue -> { - snapshot = WireConfigSnapshot.builder(snapshot).parquetPushdownFilters(newValue).build(); + clusterSettings.addSettingsUpdateConsumer(LISTING_TABLE_PUSHDOWN_FILTERS, newValue -> { + snapshot = WireConfigSnapshot.builder(snapshot).listingTablePushdownFilters(newValue).build(); }); - clusterSettings.addSettingsUpdateConsumer(INDEXED_BLOOM_FILTER_ON_READ, newValue -> { - snapshot = WireConfigSnapshot.builder(snapshot).bloomFilterOnRead(newValue).build(); + clusterSettings.addSettingsUpdateConsumer(INDEXED_PUSHDOWN_FILTERS, newValue -> { + snapshot = WireConfigSnapshot.builder(snapshot).indexedPushdownFilters(newValue).build(); }); clusterSettings.addSettingsUpdateConsumer(INDEXED_MIN_SKIP_RUN_DEFAULT, newValue -> { @@ -405,24 +383,8 @@ void registerListeners(ClusterSettings clusterSettings) { snapshot = WireConfigSnapshot.builder(snapshot).minSkipRunSelectivityThreshold(newValue).build(); }); - clusterSettings.addSettingsUpdateConsumer(INDEXED_SINGLE_COLLECTOR_STRATEGY, newValue -> { - snapshot = WireConfigSnapshot.builder(snapshot).singleCollectorStrategy(strategyToWireValue(newValue)).build(); - }); - - clusterSettings.addSettingsUpdateConsumer(INDEXED_TREE_COLLECTOR_STRATEGY, newValue -> { - snapshot = WireConfigSnapshot.builder(snapshot).treeCollectorStrategy(strategyToWireValue(newValue)).build(); - }); - - clusterSettings.addSettingsUpdateConsumer(INDEXED_MAX_COLLECTOR_PARALLELISM, newValue -> { - snapshot = WireConfigSnapshot.builder(snapshot).maxCollectorParallelism(newValue).build(); - }); - - clusterSettings.addSettingsUpdateConsumer(INDEXED_QUERY_STRATEGY, newValue -> { - snapshot = WireConfigSnapshot.builder(snapshot).queryStrategy(queryStrategyToWireValue(newValue)).build(); - }); - - clusterSettings.addSettingsUpdateConsumer(INDEXED_DYNAMIC_FILTER_PUSHDOWN, newValue -> { - snapshot = WireConfigSnapshot.builder(snapshot).indexedDynamicFilterPushdown(newValue).build(); + clusterSettings.addSettingsUpdateConsumer(INDEXED_FORCE_STRATEGY, newValue -> { + snapshot = WireConfigSnapshot.builder(snapshot).forceStrategy(forceStrategyToWire(newValue)).build(); }); clusterSettings.addSettingsUpdateConsumer(SearchService.CONCURRENT_SEGMENT_SEARCH_TARGET_MAX_SLICE_COUNT_SETTING, newValue -> { @@ -448,37 +410,6 @@ public WireConfigSnapshot getSnapshot() { return snapshot; } - /** - * Converts a strategy string to its wire format integer value. - *

    - * Mapping: full_range = 0, tighten_outer_bounds = 1, page_range_split = 2. - */ - static int strategyToWireValue(String strategy) { - switch (strategy) { - case STRATEGY_FULL_RANGE: - return 0; - case STRATEGY_TIGHTEN_OUTER_BOUNDS: - return 1; - case STRATEGY_PAGE_RANGE_SPLIT: - return 2; - default: - throw new IllegalArgumentException("Unknown strategy: " + strategy); - } - } - - static int queryStrategyToWireValue(String strategy) { - switch (strategy) { - case QUERY_STRATEGY_NONE: - return 0; - case QUERY_STRATEGY_LISTING_TABLE: - return 1; - case QUERY_STRATEGY_INDEXED: - return 2; - default: - throw new IllegalArgumentException("Unknown fetch strategy: " + strategy); - } - } - /** * Derives {@code target_partitions} from the concurrent search mode and * {@code search.concurrent.max_slice_count} setting value. diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/GetService.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/GetService.java new file mode 100644 index 0000000000000..52a7b82f40de1 --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/GetService.java @@ -0,0 +1,245 @@ +/* + * 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.be.datafusion; + +import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.memory.RootAllocator; +import org.apache.arrow.vector.FieldVector; +import org.apache.arrow.vector.VectorSchemaRoot; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.opensearch.analytics.exec.ArrowValues; +import org.opensearch.analytics.spi.DocumentLookupService; +import org.opensearch.analytics.spi.DocumentRowReader; +import org.opensearch.be.datafusion.nativelib.NativeBridge; +import org.opensearch.be.datafusion.nativelib.ReaderHandle; +import org.opensearch.be.datafusion.nativelib.StreamHandle; +import org.opensearch.common.annotation.ExperimentalApi; +import org.opensearch.core.action.ActionListener; +import org.opensearch.index.engine.exec.DocumentMetadataResolver; +import org.opensearch.index.engine.exec.MonoFileWriterSet; +import org.opensearch.index.engine.exec.WriterFileSet; +import org.opensearch.index.engine.exec.coord.CatalogSnapshot; +import org.opensearch.index.mapper.IdFieldMapper; +import org.opensearch.index.mapper.Uid; + +import java.io.Closeable; +import java.io.IOException; +import java.lang.foreign.Arena; +import java.lang.foreign.MemorySegment; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +/** + * DataFusion-backed get-by-id executor. The resolver maps an {@code _id} to a + * {@code (writerGeneration, rowId)}; this locates the parquet file in the {@link CatalogSnapshot} + * and reads that row via a Substrait scan on the native runtime, flattening the Arrow batch into a + * source map. + */ +@ExperimentalApi +public class GetService implements Closeable { + + private static final Logger logger = LogManager.getLogger(GetService.class); + + private final DocumentRowReader executor; + + /** Production constructor. */ + public GetService(DataFusionPlugin dfPlugin) { + this(new NativeBridgeExecutor(dfPlugin)); + } + + GetService(DocumentRowReader executor) { + this.executor = executor; + } + + /** Returns a core-layer DocumentLookupService wired with this backend's executor and the supplied document resolver. */ + public DocumentLookupService documentLookupService(DocumentMetadataResolver resolver) { + return new DocumentLookupService(resolver, executor); + } + + @Override + public void close() throws IOException { + if (executor instanceof Closeable c) { + c.close(); + } + } + + /** + * Production executor driving {@link NativeBridge}. Spins up a short-lived + * reader scoped to one parquet file, executes the plan, imports the single resulting batch + * via the Arrow C Data Interface, and flattens the first row into a Java map. + */ + static final class NativeBridgeExecutor implements DocumentRowReader, Closeable { + + private static final String GET_BY_ID_TABLE_ALIAS = "_t"; + private static final String PARQUET_FORMAT = "parquet"; + /** Empty Substrait plan — the internal-search path builds its plan natively and ignores it. */ + private static final byte[] EMPTY_PLAN = new byte[0]; + + private static final long GET_BY_ID_TIMEOUT_MILLIS = 30_000L; + + private final DataFusionPlugin dfPlugin; + private final BufferAllocator sharedAllocator = new RootAllocator(64 * 1024 * 1024); + + NativeBridgeExecutor(DataFusionPlugin dfPlugin) { + this.dfPlugin = dfPlugin; + } + + @Override + public void close() { + sharedAllocator.close(); + } + + @Override + public String formatName() { + return PARQUET_FORMAT; + } + + @Override + public Map executeSingleRow(long rowId, WriterFileSet parquetSet) throws IOException { + if (rowId < 0) { + throw new IllegalArgumentException("rowId must be non-negative, got: " + rowId); + } + String parquetDir = parquetSet.directory(); + String parquetFile = parquetSet.files().iterator().next(); + long runtimePtr = dfPlugin.getDataFusionService().getNativeRuntime().get(); + // ReaderHandle registers the native pointer with NativeHandle so downstream + // validatePointer() calls in executeQueryAsync() find it in the live set. + MonoFileWriterSet segment = MonoFileWriterSet.of(parquetDir, parquetSet.writerGeneration(), parquetFile, 0L); + try (ReaderHandle readerHandle = new ReaderHandle(parquetDir, List.of(segment), null, List.of(), List.of())) { + long readerPtr = readerHandle.getPointer(); + // Internal-search get-by-row-id: the native side ignores Substrait and builds a + // DataFrame plan filtering `__row_id__ = rowId` with pushdown enabled. __row_id__ is + // the physical row position the parquet writer stamps at flush (sequential 0..N after + // any index sort) and remaps into the Lucene secondary index, so the resolver's rowId + // equals the row's __row_id__. An equality predicate returns exactly that row + // independent of scan order and lets DataFusion prune row-groups/pages via the + // column's min/max statistics. + long streamPtr = executeInternalSearch( + readerPtr, + runtimePtr, + NativeBridge.INTERNAL_SEARCH_BY_ROW_ID, + rowId, + "DataFusion get-by-id query failed" + ); + return readSingleRow(streamPtr); + } + } + + @Override + public List> executeRowsAboveSeqNo(List fileSets, long seqNoFloor) throws IOException { + long runtimePtr = dfPlugin.getDataFusionService().getNativeRuntime().get(); + List> all = new ArrayList<>(); + for (WriterFileSet parquetSet : fileSets) { + String parquetDir = parquetSet.directory(); + String parquetFile = parquetSet.files().iterator().next(); + MonoFileWriterSet writerSet = MonoFileWriterSet.of(parquetDir, parquetSet.writerGeneration(), parquetFile, 0L); + try (ReaderHandle readerHandle = new ReaderHandle(parquetDir, List.of(writerSet), null, List.of(), List.of())) { + long readerPtr = readerHandle.getPointer(); + // Internal-search seq-no scan: the native side ignores Substrait and builds a + // DataFrame plan filtering `_seq_no > seqNoFloor`, projecting only the version + // metadata columns, with pushdown enabled. + long streamPtr = executeInternalSearch( + readerPtr, + runtimePtr, + NativeBridge.INTERNAL_SEARCH_SEQ_NO_ABOVE, + seqNoFloor, + "DataFusion range query failed" + ); + all.addAll(readAllRows(streamPtr)); + } + } + return all; + } + + private List> readAllRows(long streamPtr) { + List> results = new ArrayList<>(); + try ( + StreamHandle streamHandle = new StreamHandle(streamPtr, dfPlugin.getDataFusionService().getNativeRuntime()); + DatafusionResultStream stream = new DatafusionResultStream(streamHandle, sharedAllocator) + ) { + var iter = stream.iterator(); + while (iter.hasNext()) { + var batch = iter.next(); + try (VectorSchemaRoot root = batch.getArrowRoot()) { + FieldVector idVec = root.getVector(IdFieldMapper.NAME); + for (int i = 0; i < root.getRowCount(); i++) { + Map row = ArrowValues.toSourceMap(root, i); + if (idVec != null && !idVec.isNull(i)) { + row.put(IdFieldMapper.NAME, Uid.decodeId((byte[]) idVec.getObject(i))); + } + results.add(row); + } + } + } + } + return results; + } + + private Map readSingleRow(long streamPtr) { + try ( + StreamHandle streamHandle = new StreamHandle(streamPtr, dfPlugin.getDataFusionService().getNativeRuntime()); + DatafusionResultStream stream = new DatafusionResultStream(streamHandle, sharedAllocator) + ) { + var iter = stream.iterator(); + if (!iter.hasNext()) return null; + var batch = iter.next(); + try (VectorSchemaRoot root = batch.getArrowRoot()) { + if (root.getRowCount() == 0) return null; + return ArrowValues.toSourceMap(root, 0); + } + } + } + + /** + * Runs an engine-internal point lookup through {@link NativeBridge#executeQueryAsync} and + * returns the result stream pointer. No Substrait is generated: the native side builds the + * filter plan from {@code mode} + {@code bound} via the DataFrame API. The plan is empty, + * but a valid {@link WireConfigSnapshot} is still required. + */ + private long executeInternalSearch(long readerPtr, long runtimePtr, long mode, long bound, String errorMessage) throws IOException { + CompletableFuture future = new CompletableFuture<>(); + WireConfigSnapshot configSnapshot = WireConfigSnapshot.builder(dfPlugin.getDatafusionSettings().getSnapshot()).build(); + try (Arena arena = Arena.ofConfined()) { + MemorySegment configSegment = arena.allocate(WireConfigSnapshot.BYTE_SIZE); + configSnapshot.writeTo(configSegment); + NativeBridge.executeQueryAsync( + readerPtr, + GET_BY_ID_TABLE_ALIAS, + EMPTY_PLAN, + runtimePtr, + 0L, + configSegment.address(), + mode, + bound, + new ActionListener<>() { + @Override + public void onResponse(Long v) { + future.complete(v); + } + + @Override + public void onFailure(Exception e) { + future.completeExceptionally(e); + } + } + ); + try { + return future.get(GET_BY_ID_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS); + } catch (Exception e) { + throw new IOException(errorMessage, e); + } + } + } + } + +} diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/NativeErrorConverter.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/NativeErrorConverter.java index a58df1a12623d..ccabcf0e86fb5 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/NativeErrorConverter.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/NativeErrorConverter.java @@ -8,6 +8,8 @@ package org.opensearch.be.datafusion; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.opensearch.OpenSearchStatusException; import org.opensearch.core.common.breaker.CircuitBreaker; import org.opensearch.core.common.breaker.CircuitBreakingException; @@ -36,8 +38,20 @@ */ public final class NativeErrorConverter { + private static final Logger logger = LogManager.getLogger(NativeErrorConverter.class); + private NativeErrorConverter() {} + /** + * Logs the verbose native allocator dump ("top memory consumers ... query_untracked(partitions=N,batch=8192)#id + * consumed X MB", reservation ids, plan-operator internals) server-side at WARN. It is useful for operators + * but must not reach the user, so the converted exception carries no cause — its own clean message is the + * whole user-facing story. {@code label} identifies the conversion for the server log. + */ + private static void logRawNativeError(MatchedError match, String label) { + logger.warn("[NativeErrorConverter] {} — raw native error: {}", label, match.message()); + } + /** * A pattern that matches a native error message and converts it to an OpenSearch exception. */ @@ -115,10 +129,10 @@ private static Exception convertPoolLimitExceeded(MatchedError match) { if (parsed == null) { return match.original(); } + logRawNativeError(match, "pool limit exceeded"); String message = "[analytics_backend_datafusion] Failed to allocate " + parsed[0] + " bytes (limit: " + parsed[1] + ")"; - CircuitBreakingException cbe = new CircuitBreakingException(message, parsed[0], parsed[1], CircuitBreaker.Durability.TRANSIENT); - cbe.initCause(match.original()); - return cbe; + // No cause attached: the raw native error carries the allocator dump and stays in the server log only. + return new CircuitBreakingException(message, parsed[0], parsed[1], CircuitBreaker.Durability.TRANSIENT); } private static Exception convertPoolLimitFromControlled(MatchedError match) { @@ -126,25 +140,29 @@ private static Exception convertPoolLimitFromControlled(MatchedError match) { if (parsed == null) { return match.original(); } - CircuitBreakingException cbe = new CircuitBreakingException( - match.message(), - parsed[0], - parsed[1], - CircuitBreaker.Durability.TRANSIENT - ); - cbe.initCause(match.original()); - return cbe; + logRawNativeError(match, "pool limit exceeded (controlled)"); + // Rebuild the message from the parsed numbers only (the matched message may carry an appended + // native consumer dump); never echo match.message() verbatim. + String message = "[analytics_backend_datafusion] Failed to allocate " + parsed[0] + " bytes (limit: " + parsed[1] + ")"; + return new CircuitBreakingException(message, parsed[0], parsed[1], CircuitBreaker.Durability.TRANSIENT); } private static Exception convertAdmissionRejection(MatchedError match) { - return new OpenSearchStatusException(ADMISSION_REJECTED_MSG, RestStatus.TOO_MANY_REQUESTS, match.original()); + // Log the raw native budget detail server-side; don't attach it as a user-facing cause. + logRawNativeError(match, "admission rejected"); + return new OpenSearchStatusException(ADMISSION_REJECTED_MSG, RestStatus.TOO_MANY_REQUESTS); } private static Exception convertSpillPoolExhausted(MatchedError match) { + logRawNativeError(match, "spill pool exhausted"); // Bytes/limit aren't part of this DataFusion message; surface 0/0 to keep the type contract. - CircuitBreakingException cbe = new CircuitBreakingException(match.message(), 0L, 0L, CircuitBreaker.Durability.TRANSIENT); - cbe.initCause(match.original()); - return cbe; + // Use a fixed clean message — the raw DataFusion text can carry operator internals. + return new CircuitBreakingException( + "[analytics_backend_datafusion] memory pool exhausted (spill unavailable)", + 0L, + 0L, + CircuitBreaker.Durability.TRANSIENT + ); } private static Exception convertRecursionLimit(MatchedError match) { diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/WireConfigSnapshot.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/WireConfigSnapshot.java index cb468baf59f56..6f72dd3912bed 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/WireConfigSnapshot.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/WireConfigSnapshot.java @@ -26,32 +26,24 @@ public final class WireConfigSnapshot { /** Total byte size of the wire struct ({@code WireDatafusionQueryConfig}). */ - public static final long BYTE_SIZE = 80; + public static final long BYTE_SIZE = 52; private final int batchSize; private final int targetPartitions; - private final boolean parquetPushdownFilters; - private final boolean bloomFilterOnRead; + private final boolean listingTablePushdownFilters; private final int minSkipRunDefault; private final double minSkipRunSelectivityThreshold; - private final int maxCollectorParallelism; - private final int singleCollectorStrategy; - private final int treeCollectorStrategy; - private final int queryStrategy; - private final boolean indexedDynamicFilterPushdown; + private final boolean indexedPushdownFilters; + private final int forceStrategy; private WireConfigSnapshot(Builder builder) { this.batchSize = builder.batchSize; this.targetPartitions = builder.targetPartitions; - this.parquetPushdownFilters = builder.parquetPushdownFilters; - this.bloomFilterOnRead = builder.bloomFilterOnRead; + this.listingTablePushdownFilters = builder.listingTablePushdownFilters; this.minSkipRunDefault = builder.minSkipRunDefault; this.minSkipRunSelectivityThreshold = builder.minSkipRunSelectivityThreshold; - this.maxCollectorParallelism = builder.maxCollectorParallelism; - this.singleCollectorStrategy = builder.singleCollectorStrategy; - this.treeCollectorStrategy = builder.treeCollectorStrategy; - this.queryStrategy = builder.queryStrategy; - this.indexedDynamicFilterPushdown = builder.indexedDynamicFilterPushdown; + this.indexedPushdownFilters = builder.indexedPushdownFilters; + this.forceStrategy = builder.forceStrategy; } public static Builder builder() { @@ -65,15 +57,11 @@ public static Builder builder() { public static Builder builder(WireConfigSnapshot current) { return new Builder().batchSize(current.batchSize) .targetPartitions(current.targetPartitions) - .parquetPushdownFilters(current.parquetPushdownFilters) - .bloomFilterOnRead(current.bloomFilterOnRead) + .listingTablePushdownFilters(current.listingTablePushdownFilters) .minSkipRunDefault(current.minSkipRunDefault) .minSkipRunSelectivityThreshold(current.minSkipRunSelectivityThreshold) - .maxCollectorParallelism(current.maxCollectorParallelism) - .singleCollectorStrategy(current.singleCollectorStrategy) - .treeCollectorStrategy(current.treeCollectorStrategy) - .queryStrategy(current.queryStrategy) - .indexedDynamicFilterPushdown(current.indexedDynamicFilterPushdown); + .indexedPushdownFilters(current.indexedPushdownFilters) + .forceStrategy(current.forceStrategy); } public int batchSize() { @@ -84,12 +72,8 @@ public int targetPartitions() { return targetPartitions; } - public boolean parquetPushdownFilters() { - return parquetPushdownFilters; - } - - public boolean bloomFilterOnRead() { - return bloomFilterOnRead; + public boolean listingTablePushdownFilters() { + return listingTablePushdownFilters; } public int minSkipRunDefault() { @@ -100,24 +84,13 @@ public double minSkipRunSelectivityThreshold() { return minSkipRunSelectivityThreshold; } - public int maxCollectorParallelism() { - return maxCollectorParallelism; - } - - public int singleCollectorStrategy() { - return singleCollectorStrategy; - } - - public int treeCollectorStrategy() { - return treeCollectorStrategy; - } - - public int queryStrategy() { - return queryStrategy; + public boolean indexedPushdownFilters() { + return indexedPushdownFilters; } - public boolean indexedDynamicFilterPushdown() { - return indexedDynamicFilterPushdown; + /** -1 = None (selectivity heuristic), 0 = RowSelection, 1 = BooleanMask. */ + public int forceStrategy() { + return forceStrategy; } /** @@ -125,7 +98,9 @@ public boolean indexedDynamicFilterPushdown() { * {@code WireDatafusionQueryConfig} {@code #[repr(C)]} layout. *

    * The segment must be at least {@link #BYTE_SIZE} bytes and allocated from - * a confined {@code Arena} scoped to the query lifetime. + * a confined {@code Arena} scoped to the query lifetime. Fields that the Rust + * side no longer exposes as settings ({@code cost_predicate}, + * {@code cost_collector}) are written as their fixed hardcoded values. * *

          * Offset  Size  Field                                Type     Source
    @@ -134,19 +109,13 @@ public boolean indexedDynamicFilterPushdown() {
          * 8       8     target_partitions                    i64      from snapshot
          * 16      8     min_skip_run_default                 i64      from snapshot
          * 24      8     min_skip_run_selectivity_threshold   f64      from snapshot
    -     * 32      4     parquet_pushdown_filters             i32      from snapshot (0/1)
    -     * 36      4     indexed_pushdown_filters             i32      hardcoded 1
    -     * 40      4     force_strategy                       i32      hardcoded -1
    -     * 44      4     force_pushdown                       i32      hardcoded -1
    -     * 48      4     cost_predicate                       i32      hardcoded 1
    -     * 52      4     cost_collector                       i32      hardcoded 10
    -     * 56      4     max_collector_parallelism            i32      from snapshot
    -     * 60      4     single_collector_strategy            i32      from snapshot
    -     * 64      4     tree_collector_strategy              i32      from snapshot
    -     * 68      4     query_strategy                       i32      from snapshot (0/1/2)
    -     * 72      4     bloom_filter_on_read                 i32      from snapshot (0/1)
    +     * 32      4     listing_table_pushdown_filters       i32      from snapshot (0/1)
    +     * 36      4     indexed_pushdown_filters             i32      from snapshot (0/1)
    +     * 40      4     force_strategy                       i32      from snapshot (-1/0/1)
    +     * 44      4     cost_predicate                       i32      hardcoded 1
    +     * 48      4     cost_collector                       i32      hardcoded 10
          * ──────  ────
    -     * Total: 76 bytes
    +     * Total: 52 bytes
          * 
    * * @param segment the target memory segment (at least {@link #BYTE_SIZE} bytes) @@ -160,30 +129,16 @@ public void writeTo(MemorySegment segment) { segment.set(ValueLayout.JAVA_LONG, 16, (long) minSkipRunDefault); // Offset 24: min_skip_run_selectivity_threshold (f64) segment.set(ValueLayout.JAVA_DOUBLE, 24, minSkipRunSelectivityThreshold); - // Offset 32: parquet_pushdown_filters (i32) — 0 = false, 1 = true - segment.set(ValueLayout.JAVA_INT, 32, parquetPushdownFilters ? 1 : 0); - // Offset 36: indexed_pushdown_filters (i32) — always 1 (hardcoded) - segment.set(ValueLayout.JAVA_INT, 36, 1); - // Offset 40: force_strategy (i32) — always -1 (None) - segment.set(ValueLayout.JAVA_INT, 40, -1); - // Offset 44: force_pushdown (i32) — always -1 (None) - segment.set(ValueLayout.JAVA_INT, 44, -1); - // Offset 48: cost_predicate (i32) — hardcoded 1 - segment.set(ValueLayout.JAVA_INT, 48, 1); - // Offset 52: cost_collector (i32) — hardcoded 10 - segment.set(ValueLayout.JAVA_INT, 52, 10); - // Offset 56: max_collector_parallelism (i32) - segment.set(ValueLayout.JAVA_INT, 56, maxCollectorParallelism); - // Offset 60: single_collector_strategy (i32) - segment.set(ValueLayout.JAVA_INT, 60, singleCollectorStrategy); - // Offset 64: tree_collector_strategy (i32) - segment.set(ValueLayout.JAVA_INT, 64, treeCollectorStrategy); - // Offset 68: query_strategy (i32) — 0 = None, 1 = ListingTable, 2 = IndexedPredicateOnly - segment.set(ValueLayout.JAVA_INT, 68, queryStrategy); - // Offset 72: bloom_filter_on_read (i32) — 0 = false, 1 = true - segment.set(ValueLayout.JAVA_INT, 72, bloomFilterOnRead ? 1 : 0); - // Offset 76: indexed_dynamic_filter_pushdown (i32) — 0 = false, 1 = true - segment.set(ValueLayout.JAVA_INT, 76, indexedDynamicFilterPushdown ? 1 : 0); + // Offset 32: listing_table_pushdown_filters (i32) — 0 = false, 1 = true + segment.set(ValueLayout.JAVA_INT, 32, listingTablePushdownFilters ? 1 : 0); + // Offset 36: indexed_pushdown_filters (i32) — 0 = false, 1 = true + segment.set(ValueLayout.JAVA_INT, 36, indexedPushdownFilters ? 1 : 0); + // Offset 40: force_strategy (i32) — -1 = None, 0 = RowSelection, 1 = BooleanMask + segment.set(ValueLayout.JAVA_INT, 40, forceStrategy); + // Offset 44: cost_predicate (i32) — hardcoded 1 + segment.set(ValueLayout.JAVA_INT, 44, 1); + // Offset 48: cost_collector (i32) — hardcoded 10 + segment.set(ValueLayout.JAVA_INT, 48, 10); } /** @@ -193,15 +148,11 @@ public void writeTo(MemorySegment segment) { public static final class Builder { private int batchSize = 8192; private int targetPartitions = 4; - private boolean parquetPushdownFilters = false; - private boolean bloomFilterOnRead = true; + private boolean listingTablePushdownFilters = false; private int minSkipRunDefault = 1024; private double minSkipRunSelectivityThreshold = 0.03; - private int maxCollectorParallelism = 1; - private int singleCollectorStrategy = 2; // PageRangeSplit - private int treeCollectorStrategy = 1; // TightenOuterBounds - private int queryStrategy = 2; // IndexedPredicateOnly (matches DatafusionSettings default "indexed") - private boolean indexedDynamicFilterPushdown = true; // runtime TopK/join RG pruning on by default + private boolean indexedPushdownFilters = true; + private int forceStrategy = -1; private Builder() {} @@ -215,13 +166,8 @@ public Builder targetPartitions(int targetPartitions) { return this; } - public Builder parquetPushdownFilters(boolean parquetPushdownFilters) { - this.parquetPushdownFilters = parquetPushdownFilters; - return this; - } - - public Builder bloomFilterOnRead(boolean bloomFilterOnRead) { - this.bloomFilterOnRead = bloomFilterOnRead; + public Builder listingTablePushdownFilters(boolean listingTablePushdownFilters) { + this.listingTablePushdownFilters = listingTablePushdownFilters; return this; } @@ -235,28 +181,14 @@ public Builder minSkipRunSelectivityThreshold(double minSkipRunSelectivityThresh return this; } - public Builder maxCollectorParallelism(int maxCollectorParallelism) { - this.maxCollectorParallelism = maxCollectorParallelism; - return this; - } - - public Builder singleCollectorStrategy(int singleCollectorStrategy) { - this.singleCollectorStrategy = singleCollectorStrategy; - return this; - } - - public Builder treeCollectorStrategy(int treeCollectorStrategy) { - this.treeCollectorStrategy = treeCollectorStrategy; - return this; - } - - public Builder queryStrategy(int queryStrategy) { - this.queryStrategy = queryStrategy; + public Builder indexedPushdownFilters(boolean indexedPushdownFilters) { + this.indexedPushdownFilters = indexedPushdownFilters; return this; } - public Builder indexedDynamicFilterPushdown(boolean indexedDynamicFilterPushdown) { - this.indexedDynamicFilterPushdown = indexedDynamicFilterPushdown; + /** @param forceStrategy -1 = None (heuristic), 0 = RowSelection, 1 = BooleanMask. */ + public Builder forceStrategy(int forceStrategy) { + this.forceStrategy = forceStrategy; return this; } diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/action/LiquidCacheClearAction.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/action/LiquidCacheClearAction.java new file mode 100644 index 0000000000000..e372a5aa84ed1 --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/action/LiquidCacheClearAction.java @@ -0,0 +1,64 @@ +/* + * 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.be.datafusion.action; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.opensearch.be.datafusion.DataFusionService; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.rest.BaseRestHandler; +import org.opensearch.rest.BytesRestResponse; +import org.opensearch.rest.RestRequest; +import org.opensearch.transport.client.node.NodeClient; + +import java.util.List; + +/** + * REST handler for {@code POST _plugins/analytics_backend_datafusion/liquid_cache/clear}. + *

    + * Clears all Liquid Cache entries (in-memory) and DataFusion internal caches. + */ +public class LiquidCacheClearAction extends BaseRestHandler { + + private static final Logger logger = LogManager.getLogger(LiquidCacheClearAction.class); + private final DataFusionService dataFusionService; + + public LiquidCacheClearAction(DataFusionService dataFusionService) { + this.dataFusionService = dataFusionService; + } + + @Override + public String getName() { + return "liquid_cache_clear_action"; + } + + @Override + public List routes() { + return List.of(new Route(RestRequest.Method.POST, "_plugins/analytics_backend_datafusion/liquid_cache/clear")); + } + + @Override + protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) { + return channel -> { + try { + logger.info("Clearing Liquid Cache via REST endpoint"); + dataFusionService.clearLiquidCache(); + logger.info("Liquid Cache cleared successfully"); + XContentBuilder builder = channel.newBuilder(); + builder.startObject(); + builder.field("acknowledged", true); + builder.endObject(); + channel.sendResponse(new BytesRestResponse(RestStatus.OK, builder)); + } catch (Exception e) { + channel.sendResponse(new BytesRestResponse(channel, e)); + } + }; + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/action/stats/ClearCacheActionType.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/action/stats/ClearCacheActionType.java new file mode 100644 index 0000000000000..abc0bf0cb2a59 --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/action/stats/ClearCacheActionType.java @@ -0,0 +1,30 @@ +/* + * 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.be.datafusion.action.stats; + +import org.opensearch.action.ActionType; + +/** + * Action type for the broadcast "clear datafusion caches" transport action. + * + *

    The datafusion caches are process-global singletons, one per node. Clearing + * them must fan out to every node so a single-node REST handler doesn't leave + * other nodes' caches populated. + * + * @opensearch.internal + */ +public class ClearCacheActionType extends ActionType { + + public static final String NAME = "cluster:admin/_analytics_backend_datafusion/cache/clear"; + public static final ClearCacheActionType INSTANCE = new ClearCacheActionType(); + + private ClearCacheActionType() { + super(NAME, ClearCacheNodesResponse::new); + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/action/stats/ClearCacheNodeRequest.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/action/stats/ClearCacheNodeRequest.java new file mode 100644 index 0000000000000..bd87218685ce5 --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/action/stats/ClearCacheNodeRequest.java @@ -0,0 +1,68 @@ +/* + * 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.be.datafusion.action.stats; + +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.transport.TransportRequest; + +import java.io.IOException; + +/** Per-node request carrying which caches to clear. @opensearch.internal */ +public class ClearCacheNodeRequest extends TransportRequest { + + private boolean footer; + private boolean column; + private boolean offset; + private boolean statistics; + + public ClearCacheNodeRequest(boolean footer, boolean column, boolean offset, boolean statistics) { + this.footer = footer; + this.column = column; + this.offset = offset; + this.statistics = statistics; + } + + public ClearCacheNodeRequest(StreamInput in) throws IOException { + super(in); + this.footer = in.readBoolean(); + this.column = in.readBoolean(); + this.offset = in.readBoolean(); + this.statistics = in.readBoolean(); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + out.writeBoolean(footer); + out.writeBoolean(column); + out.writeBoolean(offset); + out.writeBoolean(statistics); + } + + public boolean isFooter() { + return footer; + } + + public boolean isColumn() { + return column; + } + + public boolean isOffset() { + return offset; + } + + public boolean isStatistics() { + return statistics; + } + + public boolean isClearAll() { + return !footer && !column && !offset && !statistics; + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/action/stats/ClearCacheNodeResponse.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/action/stats/ClearCacheNodeResponse.java new file mode 100644 index 0000000000000..0d7087201a0f8 --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/action/stats/ClearCacheNodeResponse.java @@ -0,0 +1,33 @@ +/* + * 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.be.datafusion.action.stats; + +import org.opensearch.action.support.nodes.BaseNodeResponse; +import org.opensearch.cluster.node.DiscoveryNode; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; + +import java.io.IOException; + +/** Per-node response confirming the caches were cleared. @opensearch.internal */ +public class ClearCacheNodeResponse extends BaseNodeResponse { + + public ClearCacheNodeResponse(DiscoveryNode node) { + super(node); + } + + public ClearCacheNodeResponse(StreamInput in) throws IOException { + super(in); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/action/stats/ClearCacheNodesRequest.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/action/stats/ClearCacheNodesRequest.java new file mode 100644 index 0000000000000..def15dec4f1a4 --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/action/stats/ClearCacheNodesRequest.java @@ -0,0 +1,93 @@ +/* + * 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.be.datafusion.action.stats; + +import org.opensearch.action.support.nodes.BaseNodesRequest; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; + +import java.io.IOException; + +/** + * Cluster-level request to clear DataFusion caches on target nodes. + * + *

    When all flags are false (the default), every cache is cleared. Set individual + * flags to clear only specific caches — mirrors {@code /_cache/clear?query=true} pattern. + * + * @opensearch.internal + */ +public class ClearCacheNodesRequest extends BaseNodesRequest { + + private boolean footer; + private boolean column; + private boolean offset; + private boolean statistics; + + /** Clears ALL caches on the target nodes (no params given). */ + public ClearCacheNodesRequest(String... nodesIds) { + super(nodesIds); + } + + public ClearCacheNodesRequest(StreamInput in) throws IOException { + super(in); + this.footer = in.readBoolean(); + this.column = in.readBoolean(); + this.offset = in.readBoolean(); + this.statistics = in.readBoolean(); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + out.writeBoolean(footer); + out.writeBoolean(column); + out.writeBoolean(offset); + out.writeBoolean(statistics); + } + + /** Whether to clear the footer metadata cache. */ + public boolean isFooter() { + return footer; + } + + public void setFooter(boolean footer) { + this.footer = footer; + } + + /** Whether to clear the ColumnIndex (predicate) cache. */ + public boolean isColumn() { + return column; + } + + public void setColumn(boolean column) { + this.column = column; + } + + /** Whether to clear the OffsetIndex (projection) cache. */ + public boolean isOffset() { + return offset; + } + + public void setOffset(boolean offset) { + this.offset = offset; + } + + public boolean isStatistics() { + return statistics; + } + + public void setStatistics(boolean statistics) { + this.statistics = statistics; + } + + /** True when no specific flag is set — means clear everything. */ + public boolean isClearAll() { + return !footer && !column && !offset && !statistics; + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/action/stats/ClearCacheNodesResponse.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/action/stats/ClearCacheNodesResponse.java new file mode 100644 index 0000000000000..d1f11e5f6ae56 --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/action/stats/ClearCacheNodesResponse.java @@ -0,0 +1,53 @@ +/* + * 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.be.datafusion.action.stats; + +import org.opensearch.action.FailedNodeException; +import org.opensearch.action.support.nodes.BaseNodesResponse; +import org.opensearch.cluster.ClusterName; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContentFragment; +import org.opensearch.core.xcontent.XContentBuilder; + +import java.io.IOException; +import java.util.List; + +/** Aggregated cluster-wide response for the clear-scoped-cache broadcast. @opensearch.internal */ +public class ClearCacheNodesResponse extends BaseNodesResponse implements ToXContentFragment { + + public ClearCacheNodesResponse(ClusterName clusterName, List nodes, List failures) { + super(clusterName, nodes, failures); + } + + public ClearCacheNodesResponse(StreamInput in) throws IOException { + super(in); + } + + @Override + protected List readNodesFrom(StreamInput in) throws IOException { + return in.readList(ClearCacheNodeResponse::new); + } + + @Override + protected void writeNodesTo(StreamOutput out, List nodes) throws IOException { + out.writeList(nodes); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.field("acknowledged", failures() == null || failures().isEmpty()); + builder.startArray("cleared_nodes"); + for (ClearCacheNodeResponse node : getNodes()) { + builder.value(node.getNode().getId()); + } + builder.endArray(); + return builder; + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/action/stats/RestClearCacheAction.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/action/stats/RestClearCacheAction.java new file mode 100644 index 0000000000000..0ca4156a48815 --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/action/stats/RestClearCacheAction.java @@ -0,0 +1,66 @@ +/* + * 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.be.datafusion.action.stats; + +import org.opensearch.rest.BaseRestHandler; +import org.opensearch.rest.RestRequest; +import org.opensearch.rest.action.RestActions.NodesResponseRestListener; +import org.opensearch.transport.client.node.NodeClient; + +import java.io.IOException; +import java.util.List; + +import static java.util.Collections.singletonList; +import static org.opensearch.rest.RestRequest.Method.POST; + +/** + * Clears DataFusion caches across ALL nodes in the cluster. + * + *

    + * POST /_plugins/_analytics_backend_datafusion/cache/_clear           → clears all caches
    + * POST /_plugins/_analytics_backend_datafusion/cache/_clear?footer=true  → footer metadata only
    + * POST /_plugins/_analytics_backend_datafusion/cache/_clear?column=true  → column index only
    + * POST /_plugins/_analytics_backend_datafusion/cache/_clear?offset=true  → offset index only
    + * POST /_plugins/_analytics_backend_datafusion/cache/_clear?statistics=true  → statistics only
    + * 
    + * + *

    Multiple params may be combined. When no param is set, all caches are cleared. + * Mirrors the {@code /_cache/clear?query=true} pattern used by OpenSearch index caches. + * + * @opensearch.internal + */ +public class RestClearCacheAction extends BaseRestHandler { + + private static final String ROUTE = "/_plugins/_analytics_backend_datafusion/cache/_clear"; + + @Override + public String getName() { + return "datafusion_clear_cache_action"; + } + + @Override + public List routes() { + return singletonList(new Route(POST, ROUTE)); + } + + @Override + protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) throws IOException { + ClearCacheNodesRequest nodesRequest = new ClearCacheNodesRequest(); + nodesRequest.setFooter(request.paramAsBoolean("footer", false)); + nodesRequest.setColumn(request.paramAsBoolean("column", false)); + nodesRequest.setOffset(request.paramAsBoolean("offset", false)); + nodesRequest.setStatistics(request.paramAsBoolean("statistics", false)); + return channel -> client.execute(ClearCacheActionType.INSTANCE, nodesRequest, new NodesResponseRestListener<>(channel)); + } + + @Override + public boolean canTripCircuitBreaker() { + return false; + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/action/stats/TransportClearCacheAction.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/action/stats/TransportClearCacheAction.java new file mode 100644 index 0000000000000..5ed9f2494b78c --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/action/stats/TransportClearCacheAction.java @@ -0,0 +1,101 @@ +/* + * 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.be.datafusion.action.stats; + +import org.opensearch.action.FailedNodeException; +import org.opensearch.action.support.ActionFilters; +import org.opensearch.action.support.nodes.TransportNodesAction; +import org.opensearch.be.datafusion.DataFusionService; +import org.opensearch.be.datafusion.cache.CacheManager; +import org.opensearch.be.datafusion.cache.CacheUtils; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.inject.Inject; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.threadpool.ThreadPool; +import org.opensearch.transport.TransportService; + +import java.io.IOException; +import java.util.List; + +/** + * Broadcast transport action that clears the DataFusion parquet caches on every + * target node. Clearing is routed through the node-local {@link CacheManager}, + * which holds the native runtime handle: the metadata (footer) and statistics + * caches are runtime-scoped and can only be cleared through that handle, while + * the ColumnIndex/OffsetIndex scoped caches are cleared alongside them. + * + * @opensearch.internal + */ +public class TransportClearCacheAction extends TransportNodesAction< + ClearCacheNodesRequest, + ClearCacheNodesResponse, + ClearCacheNodeRequest, + ClearCacheNodeResponse> { + + private final DataFusionService dataFusionService; + + @Inject + public TransportClearCacheAction( + ThreadPool threadPool, + ClusterService clusterService, + TransportService transportService, + ActionFilters actionFilters, + DataFusionService dataFusionService + ) { + super( + ClearCacheActionType.NAME, + threadPool, + clusterService, + transportService, + actionFilters, + ClearCacheNodesRequest::new, + ClearCacheNodeRequest::new, + ThreadPool.Names.MANAGEMENT, + ClearCacheNodeResponse.class + ); + this.dataFusionService = dataFusionService; + } + + @Override + protected ClearCacheNodesResponse newResponse( + ClearCacheNodesRequest request, + List responses, + List failures + ) { + return new ClearCacheNodesResponse(clusterService.getClusterName(), responses, failures); + } + + @Override + protected ClearCacheNodeRequest newNodeRequest(ClearCacheNodesRequest request) { + return new ClearCacheNodeRequest(request.isFooter(), request.isColumn(), request.isOffset(), request.isStatistics()); + } + + @Override + protected ClearCacheNodeResponse newNodeResponse(StreamInput in) throws IOException { + return new ClearCacheNodeResponse(in); + } + + @Override + protected ClearCacheNodeResponse nodeOperation(ClearCacheNodeRequest request) { + CacheManager cacheManager = dataFusionService.getCacheManager(); + // Caching may not be configured on this node (DataFusion not started or + // caches disabled). Nothing to clear in that case. + if (cacheManager != null) { + if (request.isClearAll()) { + cacheManager.clearAllCache(); + } else { + if (request.isFooter()) cacheManager.clearCacheForCacheType(CacheUtils.CacheType.METADATA); + if (request.isColumn()) cacheManager.clearCacheForCacheType(CacheUtils.CacheType.COLUMN_INDEX); + if (request.isOffset()) cacheManager.clearCacheForCacheType(CacheUtils.CacheType.OFFSET_INDEX); + if (request.isStatistics()) cacheManager.clearCacheForCacheType(CacheUtils.CacheType.STATISTICS); + } + } + return new ClearCacheNodeResponse(clusterService.localNode()); + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/cache/CacheManager.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/cache/CacheManager.java index fa45809898fc6..a5333d5682383 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/cache/CacheManager.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/cache/CacheManager.java @@ -88,10 +88,10 @@ public long getTotalMemoryConsumed() { public void updateSizeLimit(CacheUtils.CacheType cacheType, long sizeLimit) { try { - // TODO: Add updateSizeLimitForCacheType FFM function when needed - logger.warn("updateSizeLimit not yet implemented for FFM bridge"); + NativeBridge.cacheManagerUpdateSizeLimit(runtimeHandle.get(), cacheType.getCacheTypeName(), sizeLimit); + logger.info("Updated {} cache size limit to {} bytes", cacheType.getCacheTypeName(), sizeLimit); } catch (Exception e) { - logger.error("Error updating size limit", e); + logger.error("Error updating cache size limit", e); } } diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/cache/CacheSettings.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/cache/CacheSettings.java index 3f1f9e969b880..6623f1f1c9dfa 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/cache/CacheSettings.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/cache/CacheSettings.java @@ -8,35 +8,46 @@ package org.opensearch.be.datafusion.cache; +import org.opensearch.cluster.node.DiscoveryNode; import org.opensearch.common.settings.Setting; +import org.opensearch.common.settings.Settings; import org.opensearch.core.common.unit.ByteSizeUnit; import org.opensearch.core.common.unit.ByteSizeValue; +import org.opensearch.node.resource.tracker.ResourceTrackerSettings; -import java.util.Arrays; -import java.util.List; import java.util.Locale; +/** + * Settings for the DataFusion parquet caches. + * + *

    Budget model

    + * + *
    + * node.native_memory.limit = 80% of off-heap
    + *   ├── 71% → datafusion.memory_pool_limit_bytes  (operator pool)
    + *   ├──  9% → datafusion.metadata_index_cache.total_size   (all metadata caches (footer + page indexes), this class)
    + *   │     │       (9% on warm nodes; 3% on all other node types)
    + *   │     ├── 50% → metadata cache   (footer metadata, Rust jemalloc)
    + *   │     ├── 35% → offset index     (projection-driven, Rust jemalloc)
    + *   │     └── 15% → column index     (predicate-driven, Rust jemalloc)
    + *   ├──  4% → Arrow ingest pool                            (4% on warm nodes; 8% on all other node types)
    + *   ├──  5% → Arrow flight pool
    + *   ├──  5% → Arrow query pool
    + *   ├──  3% → Parquet write pool                           (3% on warm nodes; 5% on all other node types)
    + *   └──  3% → Parquet merge pool
    + *   = 100% (warm node)
    + * 
    + * + *

    The three sub-cache percentages must sum to <= 100 (unused headroom is accepted). Changing any one without adjusting + * the others is rejected at validation time. + * + *

    The statistics cache is sized independently (not part of the 9% total) because it + * holds file-level row-group statistics, not page-level indexes — a different working set + * with different eviction characteristics. + */ public class CacheSettings { - public static final String METADATA_CACHE_SIZE_LIMIT_KEY = "datafusion.metadata.cache.size.limit"; - public static final String STATISTICS_CACHE_SIZE_LIMIT_KEY = "datafusion.statistics.cache.size.limit"; - public static final Setting METADATA_CACHE_SIZE_LIMIT = new Setting<>( - METADATA_CACHE_SIZE_LIMIT_KEY, - "250mb", - (s) -> ByteSizeValue.parseBytesSizeValue(s, new ByteSizeValue(1000, ByteSizeUnit.KB), METADATA_CACHE_SIZE_LIMIT_KEY), - Setting.Property.NodeScope, - Setting.Property.Dynamic - ); - - public static final Setting STATISTICS_CACHE_SIZE_LIMIT = new Setting<>( - STATISTICS_CACHE_SIZE_LIMIT_KEY, - "100mb", - (s) -> ByteSizeValue.parseBytesSizeValue(s, new ByteSizeValue(0, ByteSizeUnit.KB), STATISTICS_CACHE_SIZE_LIMIT_KEY), - Setting.Property.NodeScope, - Setting.Property.Dynamic - ); - - public static final Setting METADATA_CACHE_EVICTION_TYPE = new Setting( + public static final Setting METADATA_CACHE_EVICTION_TYPE = new Setting<>( "datafusion.metadata.cache.eviction.type", "LRU", CacheSettings::validateEvictionType, @@ -44,7 +55,7 @@ public class CacheSettings { Setting.Property.Dynamic ); - public static final Setting STATISTICS_CACHE_EVICTION_TYPE = new Setting( + public static final Setting STATISTICS_CACHE_EVICTION_TYPE = new Setting<>( "datafusion.statistics.cache.eviction.type", "LRU", CacheSettings::validateEvictionType, @@ -68,15 +79,164 @@ public class CacheSettings { Setting.Property.Dynamic ); - public static final List> CACHE_SETTINGS = Arrays.asList( - METADATA_CACHE_ENABLED, - METADATA_CACHE_SIZE_LIMIT, - METADATA_CACHE_EVICTION_TYPE, - STATISTICS_CACHE_ENABLED, - STATISTICS_CACHE_SIZE_LIMIT, - STATISTICS_CACHE_EVICTION_TYPE + /** + * Eviction policy for the scoped ColumnIndex cache. + * Currently only {@code FIFO} is supported — the CI/OI caches use a lock-free + * read design (FIFO insert-order eviction under a single write lock). + * {@code S3_FIFO} will be added as a follow-up. + */ + public static final Setting COLUMN_INDEX_CACHE_EVICTION_TYPE = new Setting<>( + "datafusion.column_index.cache.eviction.type", + "FIFO", + CacheSettings::validateScopedEvictionType, + Setting.Property.NodeScope, + Setting.Property.Dynamic ); + /** + * Eviction policy for the scoped OffsetIndex cache. + * Currently only {@code FIFO} is supported. + */ + public static final Setting OFFSET_INDEX_CACHE_EVICTION_TYPE = new Setting<>( + "datafusion.offset_index.cache.eviction.type", + "FIFO", + CacheSettings::validateScopedEvictionType, + Setting.Property.NodeScope, + Setting.Property.Dynamic + ); + + // Page-cache total budget (9% of node.native_memory.limit on warm nodes, 3% otherwise) + + public static final String METADATA_INDEX_CACHE_TOTAL_SIZE_KEY = "datafusion.metadata_index_cache.total_size"; + + /** + * Total byte budget for all three metadata caches (footer metadata, ColumnIndex, + * OffsetIndex). Defaults to 9% of {@code node.native_memory.limit} on warm nodes + * (3% on all other node types); falls back to 500 MB when AC is unconfigured. + */ + public static final Setting METADATA_INDEX_CACHE_TOTAL_SIZE = new Setting<>( + METADATA_INDEX_CACHE_TOTAL_SIZE_KEY, + CacheSettings::deriveMetadataIndexCacheTotalDefault, + s -> ByteSizeValue.parseBytesSizeValue(s, new ByteSizeValue(0, ByteSizeUnit.BYTES), METADATA_INDEX_CACHE_TOTAL_SIZE_KEY), + Setting.Property.NodeScope, + Setting.Property.Dynamic + ); + + // ── Sub-cache percentages (must sum to <= 100) ─────────────────────────────── + + public static final String FOOTER_METADATA_CACHE_PERCENT_KEY = "datafusion.cache.footer_metadata_percent"; + public static final String OFFSET_INDEX_CACHE_PERCENT_KEY = "datafusion.cache.offset_index_percent"; + public static final String COLUMN_INDEX_CACHE_PERCENT_KEY = "datafusion.cache.column_index_percent"; + public static final String STATISTICS_CACHE_PERCENT_KEY = "datafusion.cache.statistics_percent"; + + /** Percentage of {@link #METADATA_INDEX_CACHE_TOTAL_SIZE} allocated to the footer metadata cache. Default 48%. */ + public static final Setting FOOTER_METADATA_CACHE_PERCENT = Setting.intSetting( + FOOTER_METADATA_CACHE_PERCENT_KEY, + 48, + 1, + 98, + Setting.Property.NodeScope, + Setting.Property.Dynamic + ); + + /** + * Percentage of {@link #METADATA_INDEX_CACHE_TOTAL_SIZE} allocated to the OffsetIndex cache. + * Larger than ColumnIndex because it covers predicate ∪ projection ∪ {col 0}. + * Default 34%. + */ + public static final Setting OFFSET_INDEX_CACHE_PERCENT = Setting.intSetting( + OFFSET_INDEX_CACHE_PERCENT_KEY, + 34, + 1, + 98, + Setting.Property.NodeScope, + Setting.Property.Dynamic + ); + + /** Percentage of {@link #METADATA_INDEX_CACHE_TOTAL_SIZE} allocated to the ColumnIndex cache. Default 13%. */ + public static final Setting COLUMN_INDEX_CACHE_PERCENT = Setting.intSetting( + COLUMN_INDEX_CACHE_PERCENT_KEY, + 13, + 1, + 98, + Setting.Property.NodeScope, + Setting.Property.Dynamic + ); + + /** + * Percentage of {@link #METADATA_INDEX_CACHE_TOTAL_SIZE} allocated to the file-level statistics cache + * (row-group min/max/null-count). Default 5%. + */ + public static final Setting STATISTICS_CACHE_PERCENT = Setting.intSetting( + STATISTICS_CACHE_PERCENT_KEY, + 5, + 1, + 98, + Setting.Property.NodeScope, + Setting.Property.Dynamic + ); + + /** + * Validate that the four sub-cache percentages sum to <= 100. Unused headroom (sum < 100) is accepted — mirrors Arrow's pool model where sum(max) <= budget. + * + * @param metaPct footer metadata cache percent + * @param oiPct offset index cache percent + * @param ciPct column index cache percent + * @param statsPct statistics cache percent + */ + public static void validatePercentSum(int metaPct, int oiPct, int ciPct, int statsPct) { + if (metaPct + oiPct + ciPct + statsPct > 100) { + throw new IllegalArgumentException( + "Datafusion cache percentages must sum to <= 100, got: " + + FOOTER_METADATA_CACHE_PERCENT_KEY + + "=" + + metaPct + + ", " + + OFFSET_INDEX_CACHE_PERCENT_KEY + + "=" + + oiPct + + ", " + + COLUMN_INDEX_CACHE_PERCENT_KEY + + "=" + + ciPct + + ", " + + STATISTICS_CACHE_PERCENT_KEY + + "=" + + statsPct + + " (sum=" + + (metaPct + oiPct + ciPct + statsPct) + + ")" + ); + } + } + + /** + * Compute absolute cache sizes from percent values and a total budget. + * Returns {@code long[]{footerMetadataBytes, offsetIndexBytes, columnIndexBytes, statisticsBytes}}. + * Does NOT validate that percents sum to <= 100 — call {@link #validatePercentSum} first. + */ + public static long[] computeCacheSizes(int metaPct, int oiPct, int ciPct, int statsPct, long totalBytes) { + return new long[] { totalBytes * metaPct / 100, totalBytes * oiPct / 100, totalBytes * ciPct / 100, totalBytes * statsPct / 100 }; + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + /** + * Default total page-cache budget: 9% of {@code node.native_memory.limit} on warm nodes, + * 3% on all other node types. Falls back to 500 MB when AC is unconfigured (limit == 0). + */ + static String deriveMetadataIndexCacheTotalDefault(Settings settings) { + ByteSizeValue nativeLimit = ResourceTrackerSettings.NODE_NATIVE_MEMORY_LIMIT_SETTING.get(settings); + if (nativeLimit.getBytes() <= 0) { + return (500L * 1024 * 1024) + "b"; + } + // Warm nodes dedicate a larger share (9%) to metadata caches; other node types retain the 3% default. + int percent = DiscoveryNode.isWarmNode(settings) ? 9 : 3; + long total = Math.max(nativeLimit.getBytes() * percent / 100, 0L); + return total + "b"; + } + + /** Validates eviction type for metadata/statistics caches (LRU or LFU via CachePolicy). */ private static String validateEvictionType(String value) { String upper = value.toUpperCase(Locale.ROOT); if (!upper.equals("LRU") && !upper.equals("LFU")) { @@ -84,4 +244,21 @@ private static String validateEvictionType(String value) { } return upper; } + + /** + * Validates eviction type for the scoped CI/OI caches. + * Only {@code FIFO} is supported today; {@code S3_FIFO} will be added as a follow-up. + */ + private static String validateScopedEvictionType(String value) { + String upper = value.toUpperCase(Locale.ROOT); + if (!upper.equals("FIFO")) { + throw new IllegalArgumentException( + "Invalid eviction type '" + + value + + "' for scoped page-index cache. " + + "Only 'FIFO' is supported. S3_FIFO support is planned as a follow-up." + ); + } + return upper; + } } diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/cache/CacheUtils.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/cache/CacheUtils.java index 1b95478fd83aa..411001173ddcf 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/cache/CacheUtils.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/cache/CacheUtils.java @@ -11,16 +11,16 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.opensearch.be.datafusion.nativelib.NativeBridge; +import org.opensearch.common.Nullable; import org.opensearch.common.settings.ClusterSettings; import org.opensearch.common.settings.Setting; -import org.opensearch.core.common.unit.ByteSizeValue; +import static org.opensearch.be.datafusion.cache.CacheSettings.COLUMN_INDEX_CACHE_EVICTION_TYPE; import static org.opensearch.be.datafusion.cache.CacheSettings.METADATA_CACHE_ENABLED; import static org.opensearch.be.datafusion.cache.CacheSettings.METADATA_CACHE_EVICTION_TYPE; -import static org.opensearch.be.datafusion.cache.CacheSettings.METADATA_CACHE_SIZE_LIMIT; +import static org.opensearch.be.datafusion.cache.CacheSettings.OFFSET_INDEX_CACHE_EVICTION_TYPE; import static org.opensearch.be.datafusion.cache.CacheSettings.STATISTICS_CACHE_ENABLED; import static org.opensearch.be.datafusion.cache.CacheSettings.STATISTICS_CACHE_EVICTION_TYPE; -import static org.opensearch.be.datafusion.cache.CacheSettings.STATISTICS_CACHE_SIZE_LIMIT; /** * Utility class for cache initialization and configuration. @@ -36,47 +36,67 @@ private CacheUtils() {} * Cache type enumeration with associated settings. */ public enum CacheType { - METADATA("METADATA", METADATA_CACHE_ENABLED, METADATA_CACHE_SIZE_LIMIT, METADATA_CACHE_EVICTION_TYPE), + METADATA("METADATA", METADATA_CACHE_ENABLED, METADATA_CACHE_EVICTION_TYPE) { + @Override + public long sizeBytes(long metaLimit, long oiLimit, long ciLimit, long statsLimit) { + return metaLimit; + } + }, + STATISTICS("STATISTICS", STATISTICS_CACHE_ENABLED, STATISTICS_CACHE_EVICTION_TYPE) { + @Override + public long sizeBytes(long metaLimit, long oiLimit, long ciLimit, long statsLimit) { + return statsLimit; + } + }, + COLUMN_INDEX("COLUMN_INDEX", null, COLUMN_INDEX_CACHE_EVICTION_TYPE) { + @Override + public long sizeBytes(long metaLimit, long oiLimit, long ciLimit, long statsLimit) { + return ciLimit; + } + + @Override + public boolean isEnabled(ClusterSettings clusterSettings) { + return clusterSettings.get(org.opensearch.be.datafusion.DataFusionPlugin.SCOPED_PAGE_INDEX_ENABLED); + } + }, + OFFSET_INDEX("OFFSET_INDEX", null, OFFSET_INDEX_CACHE_EVICTION_TYPE) { + @Override + public long sizeBytes(long metaLimit, long oiLimit, long ciLimit, long statsLimit) { + return oiLimit; + } - STATISTICS("STATISTICS", STATISTICS_CACHE_ENABLED, STATISTICS_CACHE_SIZE_LIMIT, STATISTICS_CACHE_EVICTION_TYPE); + @Override + public boolean isEnabled(ClusterSettings clusterSettings) { + return clusterSettings.get(org.opensearch.be.datafusion.DataFusionPlugin.SCOPED_PAGE_INDEX_ENABLED); + } + }; private final String cacheTypeName; + @Nullable private final Setting enabledSetting; - private final Setting sizeLimitSetting; private final Setting evictionTypeSetting; - CacheType( - String cacheTypeName, - Setting enabledSetting, - Setting sizeLimitSetting, - Setting evictionTypeSetting - ) { + CacheType(String cacheTypeName, @Nullable Setting enabledSetting, Setting evictionTypeSetting) { this.cacheTypeName = cacheTypeName; this.enabledSetting = enabledSetting; - this.sizeLimitSetting = sizeLimitSetting; this.evictionTypeSetting = evictionTypeSetting; } + public abstract long sizeBytes(long metaLimit, long oiLimit, long ciLimit, long statsLimit); + public boolean isEnabled(ClusterSettings clusterSettings) { - return clusterSettings.get(enabledSetting); + return enabledSetting != null && clusterSettings.get(enabledSetting); } + @Nullable public Setting getEnabledSetting() { return enabledSetting; } - public Setting getSizeLimitSetting() { - return sizeLimitSetting; - } - public Setting getEvictionTypeSetting() { return evictionTypeSetting; } - public ByteSizeValue getSizeLimit(ClusterSettings clusterSettings) { - return clusterSettings.get(sizeLimitSetting); - } - public String getEvictionType(ClusterSettings clusterSettings) { return clusterSettings.get(evictionTypeSetting); } @@ -97,26 +117,49 @@ public static NativeCacheManagerHandle createCacheConfig(ClusterSettings cluster long cacheManagerPtr = NativeBridge.createCustomCacheManager(); NativeCacheManagerHandle handle = new NativeCacheManagerHandle(cacheManagerPtr); - // Configure each enabled cache type + // All three caches share the unified METADATA_INDEX_CACHE_TOTAL_SIZE budget. + // Compute absolute sizes from the percent model BEFORE creating the caches so + // the footer metadata cache (created in the loop below) gets the correct limit + // rather than the old standalone METADATA_CACHE_SIZE_LIMIT (hardcoded 250 MB). + long total = clusterSettings.get(CacheSettings.METADATA_INDEX_CACHE_TOTAL_SIZE).getBytes(); + int metaPct = clusterSettings.get(CacheSettings.FOOTER_METADATA_CACHE_PERCENT); + int oiPct = clusterSettings.get(CacheSettings.OFFSET_INDEX_CACHE_PERCENT); + int ciPct = clusterSettings.get(CacheSettings.COLUMN_INDEX_CACHE_PERCENT); + int statsPct = clusterSettings.get(CacheSettings.STATISTICS_CACHE_PERCENT); + long metaLimit = total * metaPct / 100; + long oiLimit = total * oiPct / 100; + long ciLimit = total * ciPct / 100; + long statsLimit = total * statsPct / 100; + logger.info( + "Configuring metadata caches: total={} bytes " + + "(footer={}% → {} bytes, offset_index={}% → {} bytes, column_index={}% → {} bytes, statistics={}% → {} bytes)", + total, + metaPct, + metaLimit, + oiPct, + oiLimit, + ciPct, + ciLimit, + statsPct, + statsLimit + ); + + // Configure each enabled cache type using the percent-derived limit. for (CacheType type : CacheType.values()) { if (type.isEnabled(clusterSettings)) { + long sizeLimit = type.sizeBytes(metaLimit, oiLimit, ciLimit, statsLimit); logger.info( "Configuring {} cache: size={} bytes, eviction={}", type.getCacheTypeName(), - type.getSizeLimit(clusterSettings).getBytes(), - type.getEvictionType(clusterSettings) - ); - - NativeBridge.createCache( - handle.getPointer(), - type.cacheTypeName, - type.getSizeLimit(clusterSettings).getBytes(), + sizeLimit, type.getEvictionType(clusterSettings) ); + NativeBridge.createCache(handle.getPointer(), type.cacheTypeName, sizeLimit, type.getEvictionType(clusterSettings)); } else { logger.debug("Cache type {} is disabled", type.getCacheTypeName()); } } + logger.info("Cache configuration completed"); return handle; } diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/nativelib/NativeBridge.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/nativelib/NativeBridge.java index 475f8d228191b..91cfe2fb36444 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/nativelib/NativeBridge.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/nativelib/NativeBridge.java @@ -102,6 +102,7 @@ private static RuntimeException rethrowConverted(RuntimeException e) { private static final MethodHandle SET_SPILL_LIMIT; private static final MethodHandle SET_MIN_TARGET_PARTITIONS; private static final MethodHandle SET_REDUCE_TARGET_PARTITIONS; + private static final MethodHandle SET_SPILL_EXEMPT_CAP_BYTES; private static final MethodHandle SET_MEMORY_GUARD_THRESHOLDS; private static final MethodHandle CREATE_READER; private static final MethodHandle CLOSE_READER; @@ -124,18 +125,29 @@ private static RuntimeException rethrowConverted(RuntimeException e) { private static final MethodHandle DESTROY_CUSTOM_CACHE_MANAGER; private static final MethodHandle CREATE_CACHE; private static final MethodHandle CACHE_MANAGER_ADD_FILES; + private static final MethodHandle CACHE_MANAGER_ADD_FILES_WITH_STORE; private static final MethodHandle CACHE_MANAGER_REMOVE_FILES; private static final MethodHandle CACHE_MANAGER_CLEAR; private static final MethodHandle CACHE_MANAGER_CLEAR_BY_TYPE; private static final MethodHandle CACHE_MANAGER_GET_MEMORY_BY_TYPE; private static final MethodHandle CACHE_MANAGER_GET_TOTAL_MEMORY; private static final MethodHandle CACHE_MANAGER_CONTAINS_BY_TYPE; + private static final MethodHandle CACHE_MANAGER_UPDATE_SIZE_LIMIT; private static final MethodHandle CREATE_SESSION_CONTEXT; private static final MethodHandle CREATE_SESSION_CONTEXT_INDEXED; private static final MethodHandle CLOSE_SESSION_CONTEXT; private static final MethodHandle EXECUTE_WITH_CONTEXT; + private static final MethodHandle SET_COLUMN_INDEX_CACHE_LIMIT; + private static final MethodHandle SET_OFFSET_INDEX_CACHE_LIMIT; + private static final MethodHandle CLEAR_SCOPED_PAGE_INDEX_CACHE; + private static final MethodHandle SET_SCOPED_PAGE_INDEX_ENABLED; private static final MethodHandle CANCEL_QUERY; private static final MethodHandle SET_CANCEL_STATS_THRESHOLD_MS; + private static final MethodHandle CLEAR_LIQUID_CACHE; + private static final MethodHandle SET_LIQUID_CACHE_ENABLED; + private static final MethodHandle SET_LIQUID_CACHE_MEMORY_LIMIT; + private static final MethodHandle SET_LIQUID_CACHE_SELECTIVITY_THRESHOLD; + private static final MethodHandle SET_LIQUID_CACHE_MAX_COLUMNS; private static final MethodHandle STATS; private static final MethodHandle QUERY_REGISTRY_TOP_N_BY_CURRENT; private static final MethodHandle DF_NATIVE_NODE_STATS; @@ -167,6 +179,10 @@ private static RuntimeException rethrowConverted(RuntimeException e) { ValueLayout.JAVA_LONG, ValueLayout.ADDRESS, ValueLayout.JAVA_LONG, + ValueLayout.JAVA_LONG, + ValueLayout.JAVA_LONG, + ValueLayout.JAVA_LONG, + ValueLayout.ADDRESS, ValueLayout.JAVA_LONG ) ); @@ -176,6 +192,31 @@ private static RuntimeException rethrowConverted(RuntimeException e) { FunctionDescriptor.ofVoid(ValueLayout.JAVA_LONG) ); + CLEAR_LIQUID_CACHE = linker.downcallHandle( + lib.find("df_clear_liquid_cache").orElseThrow(), + FunctionDescriptor.ofVoid(ValueLayout.JAVA_LONG) + ); + + SET_LIQUID_CACHE_ENABLED = linker.downcallHandle( + lib.find("df_set_liquid_cache_enabled").orElseThrow(), + FunctionDescriptor.ofVoid(ValueLayout.JAVA_LONG) + ); + + SET_LIQUID_CACHE_MEMORY_LIMIT = linker.downcallHandle( + lib.find("df_set_liquid_cache_memory_limit").orElseThrow(), + FunctionDescriptor.ofVoid(ValueLayout.JAVA_LONG) + ); + + SET_LIQUID_CACHE_SELECTIVITY_THRESHOLD = linker.downcallHandle( + lib.find("df_set_liquid_cache_selectivity_threshold").orElseThrow(), + FunctionDescriptor.ofVoid(ValueLayout.JAVA_LONG) + ); + + SET_LIQUID_CACHE_MAX_COLUMNS = linker.downcallHandle( + lib.find("df_set_liquid_cache_max_columns").orElseThrow(), + FunctionDescriptor.ofVoid(ValueLayout.JAVA_LONG) + ); + GET_MEMORY_POOL_USAGE = linker.downcallHandle( lib.find("df_get_memory_pool_usage").orElseThrow(), FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG) @@ -219,6 +260,11 @@ private static RuntimeException rethrowConverted(RuntimeException e) { FunctionDescriptor.ofVoid(ValueLayout.JAVA_LONG) ); + SET_SPILL_EXEMPT_CAP_BYTES = linker.downcallHandle( + lib.find("df_set_spill_exempt_cap_bytes").orElseThrow(), + FunctionDescriptor.ofVoid(ValueLayout.JAVA_LONG) + ); + SET_MEMORY_GUARD_THRESHOLDS = linker.downcallHandle( lib.find("df_set_memory_guard_thresholds").orElseThrow(), FunctionDescriptor.ofVoid(ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG) @@ -248,15 +294,17 @@ private static RuntimeException rethrowConverted(RuntimeException e) { EXECUTE_QUERY = linker.downcallHandle( lib.find("df_execute_query").orElseThrow(), FunctionDescriptor.of( - ValueLayout.JAVA_LONG, - ValueLayout.JAVA_LONG, - ValueLayout.ADDRESS, - ValueLayout.JAVA_LONG, - ValueLayout.ADDRESS, - ValueLayout.JAVA_LONG, - ValueLayout.JAVA_LONG, - ValueLayout.JAVA_LONG, - ValueLayout.JAVA_LONG + ValueLayout.JAVA_LONG, // returns stream_ptr + ValueLayout.JAVA_LONG, // shard_view_ptr + ValueLayout.ADDRESS, // table_name_ptr + ValueLayout.JAVA_LONG, // table_name_len + ValueLayout.ADDRESS, // plan_ptr + ValueLayout.JAVA_LONG, // plan_len + ValueLayout.JAVA_LONG, // runtime_ptr + ValueLayout.JAVA_LONG, // context_id + ValueLayout.JAVA_LONG, // query_config_ptr + ValueLayout.JAVA_LONG, // internal_search_mode + ValueLayout.JAVA_LONG // internal_search_bound ) ); @@ -456,6 +504,18 @@ private static RuntimeException rethrowConverted(RuntimeException e) { ) ); + CACHE_MANAGER_ADD_FILES_WITH_STORE = linker.downcallHandle( + lib.find("df_cache_manager_add_files_with_store").orElseThrow(), + FunctionDescriptor.of( + ValueLayout.JAVA_LONG, + ValueLayout.JAVA_LONG, // runtime_ptr + ValueLayout.JAVA_LONG, // store_ptr + ValueLayout.ADDRESS, // files_ptr + ValueLayout.ADDRESS, // files_len_ptr + ValueLayout.JAVA_LONG // files_count + ) + ); + CACHE_MANAGER_REMOVE_FILES = linker.downcallHandle( lib.find("df_cache_manager_remove_files").orElseThrow(), FunctionDescriptor.of( @@ -501,6 +561,33 @@ private static RuntimeException rethrowConverted(RuntimeException e) { ) ); + CACHE_MANAGER_UPDATE_SIZE_LIMIT = linker.downcallHandle( + lib.find("df_cache_manager_update_size_limit").orElseThrow(), + FunctionDescriptor.of( + ValueLayout.JAVA_LONG, + ValueLayout.JAVA_LONG, + ValueLayout.ADDRESS, + ValueLayout.JAVA_LONG, + ValueLayout.JAVA_LONG + ) + ); + + SET_COLUMN_INDEX_CACHE_LIMIT = linker.downcallHandle( + lib.find("df_set_column_index_cache_limit").orElseThrow(), + FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG) + ); + SET_OFFSET_INDEX_CACHE_LIMIT = linker.downcallHandle( + lib.find("df_set_offset_index_cache_limit").orElseThrow(), + FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG) + ); + CLEAR_SCOPED_PAGE_INDEX_CACHE = linker.downcallHandle( + lib.find("df_clear_scoped_page_index_cache").orElseThrow(), + FunctionDescriptor.of(ValueLayout.JAVA_LONG) + ); + SET_SCOPED_PAGE_INDEX_ENABLED = linker.downcallHandle( + lib.find("df_set_scoped_page_index_enabled").orElseThrow(), + FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG) + ); CANCEL_QUERY = linker.downcallHandle(lib.find("df_cancel_query").orElseThrow(), FunctionDescriptor.ofVoid(ValueLayout.JAVA_LONG)); SET_CANCEL_STATS_THRESHOLD_MS = linker.downcallHandle( @@ -715,10 +802,30 @@ public static void updateConcurrencyGate(String gateName, int newMaxPermits) { * This pointer is not a MemorySegment — it's a Rust heap address that lives * until {@link #closeGlobalRuntime} is called. */ - public static long createGlobalRuntime(long memoryLimit, long cacheManagerPtr, String spillDir, long spillLimit) { + public static long createGlobalRuntime( + long memoryLimit, + long cacheManagerPtr, + String spillDir, + long spillLimit, + boolean liquidCacheEnabled, + long liquidCacheSize, + String liquidCacheEvictionPolicy + ) { try (var call = new NativeCall()) { var dir = call.str(spillDir); - return call.invoke(CREATE_GLOBAL_RUNTIME, memoryLimit, cacheManagerPtr, dir.segment(), dir.len(), spillLimit); + var eviction = call.str(liquidCacheEvictionPolicy); + return call.invoke( + CREATE_GLOBAL_RUNTIME, + memoryLimit, + cacheManagerPtr, + dir.segment(), + dir.len(), + spillLimit, + liquidCacheEnabled ? 1L : 0L, + liquidCacheSize, + eviction.segment(), + eviction.len() + ); } } @@ -727,6 +834,31 @@ public static void closeGlobalRuntime(long ptr) { NativeCall.invokeVoid(CLOSE_GLOBAL_RUNTIME, ptr); } + /** Clears all Liquid Cache entries and DataFusion internal caches. */ + public static void clearLiquidCache(long runtimePtr) { + NativeCall.invokeVoid(CLEAR_LIQUID_CACHE, runtimePtr); + } + + /** Dynamically enable or disable Liquid Cache for new queries. */ + public static void setLiquidCacheEnabled(boolean enabled) { + NativeCall.invokeVoid(SET_LIQUID_CACHE_ENABLED, enabled ? 1L : 0L); + } + + /** Dynamically update the Liquid Cache memory limit in bytes. */ + public static void setLiquidCacheMemoryLimit(long bytes) { + NativeCall.invokeVoid(SET_LIQUID_CACHE_MEMORY_LIMIT, bytes); + } + + /** Dynamically update the LC selectivity threshold (permille: 800 = 0.8). */ + public static void setLiquidCacheSelectivityThreshold(long permille) { + NativeCall.invokeVoid(SET_LIQUID_CACHE_SELECTIVITY_THRESHOLD, permille); + } + + /** Dynamically update the max columns for LC engagement. */ + public static void setLiquidCacheMaxColumns(long count) { + NativeCall.invokeVoid(SET_LIQUID_CACHE_MAX_COLUMNS, count); + } + // ---- Memory pool observability and dynamic limit ---- /** Returns current memory pool usage in bytes. */ @@ -807,6 +939,19 @@ public static void setReduceTargetPartitions(int value) { } } + /** + * Sets the spill-exemption cap in bytes — the total in-flight allocation allowed through the + * 85% spill gate by spillable consumers so they can finish spilling. Live-tunable; takes effect + * on the next try_grow. + */ + public static void setSpillExemptCapBytes(long bytes) { + try { + SET_SPILL_EXEMPT_CAP_BYTES.invokeExact(bytes); + } catch (Throwable t) { + logger.debug("Failed to set spill exempt cap bytes", t); + } + } + /** Sets the memory guard thresholds (0.0–1.0): admission throttle, admission reject, execution spill, execution critical. */ public static void setMemoryGuardThresholds( double admissionThrottle, @@ -901,6 +1046,35 @@ public static void closeDatafusionReader(long ptr) { // ---- Query execution (confined Arena for tableName + plan bytes) ---- + /** {@code internal_search_mode}: normal query — decode {@code substraitPlan} as Substrait. */ + public static final long INTERNAL_SEARCH_OFF = 0L; + /** {@code internal_search_mode}: get-by-row-id — native plan filters {@code __row_id__ = bound}, {@code substraitPlan} ignored. */ + public static final long INTERNAL_SEARCH_BY_ROW_ID = 1L; + /** {@code internal_search_mode}: seq-no scan — native plan filters {@code _seq_no > bound}, {@code substraitPlan} ignored. */ + public static final long INTERNAL_SEARCH_SEQ_NO_ABOVE = 2L; + + public static void executeQueryAsync( + long readerPtr, + String tableName, + byte[] substraitPlan, + long runtimePtr, + long contextId, + long queryConfigPtr, + ActionListener listener + ) { + executeQueryAsync(readerPtr, tableName, substraitPlan, runtimePtr, contextId, queryConfigPtr, INTERNAL_SEARCH_OFF, 0L, listener); + } + + /** + * Executes a query and returns an opaque stream pointer via {@code listener}. + *

    + * When {@code internalSearchMode} is {@link #INTERNAL_SEARCH_OFF}, {@code substraitPlan} is + * decoded as a Substrait plan (normal search). When it is {@link #INTERNAL_SEARCH_BY_ROW_ID} + * or {@link #INTERNAL_SEARCH_SEQ_NO_ABOVE}, the native side ignores {@code substraitPlan} and + * builds a single pushed-down filter plan via the DataFusion DataFrame API, using + * {@code internalSearchBound} as the {@code __row_id__} value or the {@code _seq_no} floor. + * The returned stream is drained identically in all modes. + */ public static void executeQueryAsync( long readerPtr, String tableName, @@ -908,6 +1082,8 @@ public static void executeQueryAsync( long runtimePtr, long contextId, long queryConfigPtr, + long internalSearchMode, + long internalSearchBound, ActionListener listener ) { try { @@ -928,7 +1104,9 @@ public static void executeQueryAsync( (long) substraitPlan.length, runtimePtr, contextId, - queryConfigPtr + queryConfigPtr, + internalSearchMode, + internalSearchBound ); listener.onResponse(result); } catch (Throwable t) { @@ -1548,6 +1726,22 @@ public static void cacheManagerAddFiles(long runtimePtr, String[] filePaths) { } } + /** + * Load metadata for files through the given TieredObjectStore. + * Reads footer (lightweight) into heap cache, fetches page/offset index bytes + * through the store (populating data Foyer), and returns for promotion to metadata Foyer. + * + * @param runtimePtr pointer from createGlobalRuntime + * @param storePtr Box<Arc<dyn ObjectStore>> pointer (from TieredStorageBridge.getObjectStoreBoxPtr) + * @param filePaths array of absolute file paths + */ + public static void cacheManagerAddFilesWithStore(long runtimePtr, long storePtr, String[] filePaths) { + try (var call = new NativeCall()) { + var f = call.strArray(filePaths); + call.invoke(CACHE_MANAGER_ADD_FILES_WITH_STORE, runtimePtr, storePtr, f.ptrs(), f.lens(), f.count()); + } + } + public static void cacheManagerRemoveFiles(long runtimePtr, String[] filePaths) { try (var call = new NativeCall()) { var f = call.strArray(filePaths); @@ -1590,5 +1784,69 @@ public static boolean cacheManagerGetItemByCacheType(long runtimePtr, String cac } } + public static void cacheManagerUpdateSizeLimit(long runtimePtr, String cacheType, long newLimit) { + try (var call = new NativeCall()) { + var type = call.str(cacheType); + call.invoke(CACHE_MANAGER_UPDATE_SIZE_LIMIT, runtimePtr, type.segment(), type.len(), newLimit); + } + } + + /** + * Sets the byte budget of the process-global scoped ColumnIndex cache. + * Shrinking evicts LRU entries immediately. Zero is ignored. + */ + public static void setColumnIndexCacheLimit(long sizeLimitBytes) { + try (var call = new NativeCall()) { + call.invoke(SET_COLUMN_INDEX_CACHE_LIMIT, sizeLimitBytes); + } + } + + /** + * Sets the byte budget of the process-global scoped OffsetIndex cache. + * Shrinking evicts LRU entries immediately. Zero is ignored. + */ + public static void setOffsetIndexCacheLimit(long sizeLimitBytes) { + try (var call = new NativeCall()) { + call.invoke(SET_OFFSET_INDEX_CACHE_LIMIT, sizeLimitBytes); + } + } + + /** + * Clears the process-global scoped page-index cache (drops entries + resets + * counters, keeps the budget). For operational testing. + */ + public static void clearScopedPageIndexCache() { + try (var call = new NativeCall()) { + call.invoke(CLEAR_SCOPED_PAGE_INDEX_CACHE); + } + } + + /** Clears the scoped ColumnIndex (predicate) cache. */ + public static void clearColumnIndexCache() { + // TODO(PR1): wire to df_clear_column_index_cache when available + try (var call = new NativeCall()) { + call.invoke(CLEAR_SCOPED_PAGE_INDEX_CACHE); + } + } + + /** Clears the scoped OffsetIndex (projection) cache. */ + public static void clearOffsetIndexCache() { + // TODO(PR1): wire to df_clear_offset_index_cache when available + try (var call = new NativeCall()) { + call.invoke(CLEAR_SCOPED_PAGE_INDEX_CACHE); + } + } + + /** + * Enable or disable the scoped page-index feature. + * When disabled, the metadata cache retains the full page index (fallback mode) + * and CI/OI scoped caches are bypassed entirely. + */ + public static void setScopedPageIndexEnabled(boolean enabled) { + try (var call = new NativeCall()) { + call.invoke(SET_SCOPED_PAGE_INDEX_ENABLED, enabled ? 1L : 0L); + } + } + public static void initLogger() {} } diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/nativelib/StatsLayout.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/nativelib/StatsLayout.java index 8e90357fcac53..5bf4a36480f7a 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/nativelib/StatsLayout.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/nativelib/StatsLayout.java @@ -29,7 +29,7 @@ * *

    The layout contains 10 named groups (2 runtime × 9 fields + 4 task monitor × 5 fields * + 1 partition gate × 8 fields + 1 adaptive budget × 2 fields + 1 cache stats × 10 fields - * + 1 search stats × 17 fields = 75 longs = 600 bytes). + * + 1 search stats × 17 fields = 85 longs = 680 bytes). The cache stats group holds three sub-caches × 5 fields each: metadata, statistics, and scoped page-index.. */ public final class StatsLayout { @@ -99,8 +99,8 @@ public final class StatsLayout { ); static { - if (LAYOUT.byteSize() != 75 * Long.BYTES) { - throw new AssertionError("StatsLayout size mismatch: expected " + (75 * Long.BYTES) + " but got " + LAYOUT.byteSize()); + if (LAYOUT.byteSize() != 85 * Long.BYTES) { + throw new AssertionError("StatsLayout size mismatch: expected " + (85 * Long.BYTES) + " but got " + LAYOUT.byteSize()); } } @@ -182,6 +182,20 @@ public final class StatsLayout { private static final VarHandle CACHE_STATS_MEMORY_BYTES = cacheHandle("statistics_cache", "memory_bytes"); private static final VarHandle CACHE_STATS_SIZE_LIMIT_BYTES = cacheHandle("statistics_cache", "size_limit_bytes"); + // ---- VarHandles for cache_stats.column_index_cache fields ---- + private static final VarHandle CACHE_CI_HIT_COUNT = cacheHandle("column_index_cache", "hit_count"); + private static final VarHandle CACHE_CI_MISS_COUNT = cacheHandle("column_index_cache", "miss_count"); + private static final VarHandle CACHE_CI_ENTRY_COUNT = cacheHandle("column_index_cache", "entry_count"); + private static final VarHandle CACHE_CI_MEMORY_BYTES = cacheHandle("column_index_cache", "memory_bytes"); + private static final VarHandle CACHE_CI_SIZE_LIMIT_BYTES = cacheHandle("column_index_cache", "size_limit_bytes"); + + // ---- VarHandles for cache_stats.offset_index_cache fields ---- + private static final VarHandle CACHE_OI_HIT_COUNT = cacheHandle("offset_index_cache", "hit_count"); + private static final VarHandle CACHE_OI_MISS_COUNT = cacheHandle("offset_index_cache", "miss_count"); + private static final VarHandle CACHE_OI_ENTRY_COUNT = cacheHandle("offset_index_cache", "entry_count"); + private static final VarHandle CACHE_OI_MEMORY_BYTES = cacheHandle("offset_index_cache", "memory_bytes"); + private static final VarHandle CACHE_OI_SIZE_LIMIT_BYTES = cacheHandle("offset_index_cache", "size_limit_bytes"); + // ---- VarHandles for search_stats fields ---- private static final VarHandle SS_LISTING_TABLE_SCAN = handle("search_stats", "listing_table_scan"); private static final VarHandle SS_SINGLE_COLLECTOR_SCAN = handle("search_stats", "single_collector_scan"); @@ -311,7 +325,21 @@ public static CacheStats readCacheStats(MemorySegment seg) { (long) CACHE_STATS_MEMORY_BYTES.get(seg, 0L), (long) CACHE_STATS_SIZE_LIMIT_BYTES.get(seg, 0L) ); - return new CacheStats(metadata, statistics); + CacheGroupStats columnIndex = new CacheGroupStats( + (long) CACHE_CI_HIT_COUNT.get(seg, 0L), + (long) CACHE_CI_MISS_COUNT.get(seg, 0L), + (long) CACHE_CI_ENTRY_COUNT.get(seg, 0L), + (long) CACHE_CI_MEMORY_BYTES.get(seg, 0L), + (long) CACHE_CI_SIZE_LIMIT_BYTES.get(seg, 0L) + ); + CacheGroupStats offsetIndex = new CacheGroupStats( + (long) CACHE_OI_HIT_COUNT.get(seg, 0L), + (long) CACHE_OI_MISS_COUNT.get(seg, 0L), + (long) CACHE_OI_ENTRY_COUNT.get(seg, 0L), + (long) CACHE_OI_MEMORY_BYTES.get(seg, 0L), + (long) CACHE_OI_SIZE_LIMIT_BYTES.get(seg, 0L) + ); + return new CacheStats(metadata, statistics, columnIndex, offsetIndex); } /** @@ -402,7 +430,12 @@ private static StructLayout cacheGroup(String name) { } private static StructLayout cacheStatsGroup(String name) { - return MemoryLayout.structLayout(cacheGroup("metadata_cache"), cacheGroup("statistics_cache")).withName(name); + return MemoryLayout.structLayout( + cacheGroup("metadata_cache"), + cacheGroup("statistics_cache"), + cacheGroup("column_index_cache"), + cacheGroup("offset_index_cache") + ).withName(name); } private static StructLayout searchStatsGroup(String name) { diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/stats/CacheStats.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/stats/CacheStats.java index 3f01c221f3c68..9307ce46e59dd 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/stats/CacheStats.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/stats/CacheStats.java @@ -18,44 +18,52 @@ import java.util.Objects; /** - * Stats for the two parquet caches owned by {@code CustomCacheManager}: - * the parquet metadata (footer) cache and the column-statistics cache. + * Stats for the four DataFusion parquet caches: + *

      + *
    • {@code metadata_cache} — footer metadata (per-file parquet footer, row-group stats)
    • + *
    • {@code statistics_cache} — file-level row-group statistics
    • + *
    • {@code column_index_cache} — predicate-driven ColumnIndex (per-page string min/max), + * keyed per {@code (file, col, rg)} cell
    • + *
    • {@code offset_index_cache} — projection-driven OffsetIndex (page byte offsets), + * keyed per {@code (file, col)} cell
    • + *
    * - *

    Each sub-group reports five fields. Disabled caches surface as all-zero - * groups (in particular {@code size_limit_bytes == 0}); the JSON shape stays - * symmetric so clients can render the response uniformly. + *

    Each sub-group reports five fields. Disabled caches surface as all-zero groups + * (in particular {@code size_limit_bytes == 0}); the JSON shape stays symmetric + * so clients can render the response uniformly. */ public class CacheStats implements Writeable, ToXContentFragment { private final CacheGroupStats metadataCache; private final CacheGroupStats statisticsCache; + private final CacheGroupStats columnIndexCache; + private final CacheGroupStats offsetIndexCache; - /** - * Construct from individual sub-group stats. - * - * @param metadataCache metadata cache counters (must not be null) - * @param statisticsCache statistics cache counters (must not be null) - */ - public CacheStats(CacheGroupStats metadataCache, CacheGroupStats statisticsCache) { + public CacheStats( + CacheGroupStats metadataCache, + CacheGroupStats statisticsCache, + CacheGroupStats columnIndexCache, + CacheGroupStats offsetIndexCache + ) { this.metadataCache = Objects.requireNonNull(metadataCache); this.statisticsCache = Objects.requireNonNull(statisticsCache); + this.columnIndexCache = Objects.requireNonNull(columnIndexCache); + this.offsetIndexCache = Objects.requireNonNull(offsetIndexCache); } - /** - * Deserialize from stream. - * - * @param in the stream input - * @throws IOException if deserialization fails - */ public CacheStats(StreamInput in) throws IOException { this.metadataCache = new CacheGroupStats(in); this.statisticsCache = new CacheGroupStats(in); + this.columnIndexCache = new CacheGroupStats(in); + this.offsetIndexCache = new CacheGroupStats(in); } @Override public void writeTo(StreamOutput out) throws IOException { metadataCache.writeTo(out); statisticsCache.writeTo(out); + columnIndexCache.writeTo(out); + offsetIndexCache.writeTo(out); } @Override @@ -67,30 +75,45 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws builder.startObject("statistics_cache"); statisticsCache.toXContent(builder); builder.endObject(); + builder.startObject("column_index_cache"); + columnIndexCache.toXContent(builder); + builder.endObject(); + builder.startObject("offset_index_cache"); + offsetIndexCache.toXContent(builder); + builder.endObject(); builder.endObject(); return builder; } - /** Returns the metadata cache counters. */ public CacheGroupStats getMetadataCache() { return metadataCache; } - /** Returns the statistics cache counters. */ public CacheGroupStats getStatisticsCache() { return statisticsCache; } + public CacheGroupStats getColumnIndexCache() { + return columnIndexCache; + } + + public CacheGroupStats getOffsetIndexCache() { + return offsetIndexCache; + } + @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; CacheStats that = (CacheStats) o; - return Objects.equals(metadataCache, that.metadataCache) && Objects.equals(statisticsCache, that.statisticsCache); + return Objects.equals(metadataCache, that.metadataCache) + && Objects.equals(statisticsCache, that.statisticsCache) + && Objects.equals(columnIndexCache, that.columnIndexCache) + && Objects.equals(offsetIndexCache, that.offsetIndexCache); } @Override public int hashCode() { - return Objects.hash(metadataCache, statisticsCache); + return Objects.hash(metadataCache, statisticsCache, columnIndexCache, offsetIndexCache); } } diff --git a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/ConcatVariadicAdapterTests.java b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/ConcatVariadicAdapterTests.java index 421b2ada82468..0ed7f728933bf 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/ConcatVariadicAdapterTests.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/ConcatVariadicAdapterTests.java @@ -18,6 +18,7 @@ import org.apache.calcite.rex.RexCall; import org.apache.calcite.rex.RexNode; import org.apache.calcite.sql.fun.SqlLibraryOperators; +import org.apache.calcite.sql.fun.SqlStdOperatorTable; import org.apache.calcite.sql.type.SqlTypeName; import org.opensearch.test.OpenSearchTestCase; @@ -121,4 +122,48 @@ public void testAdaptAllVarcharIsNoOp() { assertSame("all-VARCHAR call must pass through unchanged", original, adapted); } + + public void testAdaptCastsCharLiteralBetweenScalarFunctionOperands() { + // CONCAT(SUBSTRING(f0,1,3), charLiteral, SUBSTRING(f1,1,3)): the outer operands are + // scalar-function calls (RexCall), only the middle FixedChar literal needs casting. + RexNode substr0 = substringCall(0); + RexNode charLiteral = rexBuilder.makeInputRef(charType, 2); + RexNode substr1 = substringCall(1); + RexCall original = (RexCall) rexBuilder.makeCall(SqlLibraryOperators.CONCAT_FUNCTION, substr0, charLiteral, substr1); + + RexCall adapted = (RexCall) adapter.adapt(original, List.of(), cluster); + + assertSame("operator identity must be preserved", original.getOperator(), adapted.getOperator()); + for (int i = 0; i < adapted.getOperands().size(); i++) { + assertEquals( + "operand " + i + " must be VARCHAR after normalisation", + SqlTypeName.VARCHAR, + adapted.getOperands().get(i).getType().getSqlTypeName() + ); + } + // The two scalar-function operands were already VARCHAR, so they must pass through by + // reference — only the middle literal is rewritten. + assertSame("VARCHAR substring operand 0 must pass through unchanged", substr0, adapted.getOperands().get(0)); + assertSame("VARCHAR substring operand 2 must pass through unchanged", substr1, adapted.getOperands().get(2)); + assertNotSame("CHAR literal operand must be rewritten to a VARCHAR cast", charLiteral, adapted.getOperands().get(1)); + } + + public void testAdaptTwoScalarFunctionOperandsIsNoOp() { + // CONCAT(SUBSTRING(f0,1,3), SUBSTRING(f1,1,3)): two VARCHAR-typed scalar-function operands, + // nothing to cast, so the original call passes through by reference. + RexCall original = (RexCall) rexBuilder.makeCall(SqlLibraryOperators.CONCAT_FUNCTION, substringCall(0), substringCall(1)); + + RexNode adapted = adapter.adapt(original, List.of(), cluster); + + assertSame("all-VARCHAR scalar-fn operand call must pass through unchanged", original, adapted); + } + + /** {@code SUBSTRING(field#idx, 1, 3)} typed VARCHAR — a RexCall operand for concat. */ + private RexCall substringCall(int fieldIndex) { + RexNode field = rexBuilder.makeInputRef(varcharType, fieldIndex); + RelDataType intType = typeFactory.createSqlType(SqlTypeName.INTEGER); + RexNode start = rexBuilder.makeExactLiteral(java.math.BigDecimal.ONE, intType); + RexNode len = rexBuilder.makeExactLiteral(java.math.BigDecimal.valueOf(3), intType); + return (RexCall) rexBuilder.makeCall(varcharType, SqlStdOperatorTable.SUBSTRING, List.of(field, start, len)); + } } diff --git a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DataFusionNativeBridgeTests.java b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DataFusionNativeBridgeTests.java index 7f93b4d9b9a81..9c333025862b4 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DataFusionNativeBridgeTests.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DataFusionNativeBridgeTests.java @@ -51,7 +51,10 @@ public void testRuntimeLifecycle() { 64 * 1024 * 1024, // 64MB 0L, spillDir.toString(), - 32 * 1024 * 1024 // 32MB spill + 32 * 1024 * 1024, // 32MB spill + false, + 0L, + "lru" ); assertTrue("Runtime pointer should be non-zero", runtimePtr != 0); @@ -62,7 +65,7 @@ public void testRuntimeLifecycle() { public void testReaderLifecycle() throws Exception { NativeBridge.initTokioRuntimeManager(2); Path spillDir = createTempDir("datafusion-spill"); - long runtimePtr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024); + long runtimePtr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024, false, 0L, "lru"); // Copy test parquet to a temp dir Path dataDir = createTempDir("datafusion-data"); @@ -88,7 +91,7 @@ public void testReaderLifecycle() throws Exception { public void testSessionContextCreationAndTableRegistration() throws Exception { NativeBridge.initTokioRuntimeManager(2); Path spillDir = createTempDir("datafusion-spill"); - long runtimePtr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024); + long runtimePtr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024, false, 0L, "lru"); NativeRuntimeHandle runtimeHandle = new NativeRuntimeHandle(runtimePtr); Path dataDir = createTempDir("datafusion-data"); @@ -166,7 +169,7 @@ public void onFailure(Exception exception) { public void testReaderWithRealNativeStoreHandle() throws Exception { NativeBridge.initTokioRuntimeManager(2); Path spillDir = createTempDir("datafusion-spill"); - long runtimePtr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024); + long runtimePtr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024, false, 0L, "lru"); // Copy test parquet to a temp dir Path dataDir = createTempDir("datafusion-data"); diff --git a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DataFusionPluginSettingsTests.java b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DataFusionPluginSettingsTests.java index 43b89b3ccee86..24148b5dc9f91 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DataFusionPluginSettingsTests.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DataFusionPluginSettingsTests.java @@ -10,6 +10,7 @@ import org.opensearch.common.settings.Setting; import org.opensearch.common.settings.Settings; +import org.opensearch.env.Environment; import org.opensearch.test.OpenSearchTestCase; import java.nio.file.Files; @@ -98,15 +99,10 @@ public void testGetSettingsReturnsAllIndexedSettings() { List> settings = plugin.getSettings(); Set settingKeys = settings.stream().map(Setting::getKey).collect(Collectors.toSet()); - assertTrue(settingKeys.contains("datafusion.indexed.batch_size")); - assertTrue(settingKeys.contains("datafusion.indexed.parquet_pushdown_filters")); - assertTrue(settingKeys.contains("datafusion.indexed.bloom_filter_on_read")); + assertTrue(settingKeys.contains("datafusion.batch_size")); + assertTrue(settingKeys.contains("datafusion.listing_table.pushdown_filters")); assertTrue(settingKeys.contains("datafusion.indexed.min_skip_run_default")); assertTrue(settingKeys.contains("datafusion.indexed.min_skip_run_selectivity_threshold")); - assertTrue(settingKeys.contains("datafusion.indexed.single_collector_strategy")); - assertTrue(settingKeys.contains("datafusion.indexed.tree_collector_strategy")); - assertTrue(settingKeys.contains("datafusion.indexed.max_collector_parallelism")); - assertTrue(settingKeys.contains("datafusion.indexed.query_strategy")); } catch (Exception e) { throw new AssertionError(e); } @@ -115,7 +111,7 @@ public void testGetSettingsReturnsAllIndexedSettings() { public void testGetSettingsReturnsTotalExpectedCount() { try (DataFusionPlugin plugin = new DataFusionPlugin()) { List> settings = plugin.getSettings(); - assertEquals(28, settings.size()); + assertEquals(36, settings.size()); } catch (Exception e) { throw new AssertionError(e); } @@ -203,11 +199,11 @@ public void testDeriveMemoryPoolLimitDefaultUnsetReturnsLongMaxValue() { } public void testDeriveMemoryPoolLimitDefaultUsesNativeMemoryLimit() { - // 10 GiB native memory limit — default takes 74% straight from limit, not + // 10 GiB native memory limit — default takes 71% straight from limit, not // from limit - buffer_percent (which is AC's throttle margin, not a framework - // budget reduction). 74% of 10 GiB. + // budget reduction). 71% of 10 GiB. Settings s = Settings.builder().put("node.native_memory.limit", "10gb").build(); - long expected = (10L * 1024 * 1024 * 1024) * 74 / 100; + long expected = (10L * 1024 * 1024 * 1024) * 71 / 100; assertEquals(Long.toString(expected), DataFusionPlugin.deriveMemoryPoolLimitDefault(s)); } @@ -215,14 +211,14 @@ public void testDeriveMemoryPoolLimitDefaultIgnoresBufferPercent() { // node.native_memory.buffer_percent is AC's throttle margin. The framework default // takes its fraction off node.native_memory.limit directly so the buffer can sit // between AC's throttle threshold and the framework's hard cap. - // 1000 bytes limit, 20% buffer => pool max still 74% of 1000 = 740. + // 1000 bytes limit, 20% buffer => pool max still 71% of 1000 = 710. Settings s = Settings.builder().put("node.native_memory.limit", "1000b").put("node.native_memory.buffer_percent", 20).build(); - assertEquals("740", DataFusionPlugin.deriveMemoryPoolLimitDefault(s)); + assertEquals("710", DataFusionPlugin.deriveMemoryPoolLimitDefault(s)); } public void testMemoryPoolLimitSettingExposesDerivedDefault() { Settings s = Settings.builder().put("node.native_memory.limit", "10gb").build(); - long expected = (10L * 1024 * 1024 * 1024) * 74 / 100; + long expected = (10L * 1024 * 1024 * 1024) * 71 / 100; assertEquals(Long.valueOf(expected), DataFusionPlugin.DATAFUSION_MEMORY_POOL_LIMIT.get(s)); } @@ -239,4 +235,130 @@ public void testMemoryPoolLimitRejectsNegative() { ); assertTrue(e.getMessage().contains("must be >= 0")); } + + public void testDeriveSpillLimitDefaultWithEmptySpillDirReturnsZero() { + Settings settings = Settings.builder().put("datafusion.spill_directory", "").build(); + String defaultValue = DataFusionPlugin.deriveSpillLimitDefault(settings); + assertEquals("Empty spill_directory must yield default 0 (spill disabled)", "0", defaultValue); + } + + public void testDeriveSpillLimitDefaultWithValidSpillDirReturnsFractionOfTotal() throws Exception { + Path spillDir = createTempDir(); + Settings settings = Settings.builder().put("datafusion.spill_directory", spillDir.toString()).build(); + long total = Environment.getFileStore(spillDir).getTotalSpace(); + assertTrue("test host must report a non-zero total space for the temp dir", total > 0); + long expected = (long) (total * 0.90); + long actual = Long.parseLong(DataFusionPlugin.deriveSpillLimitDefault(settings)); + assertEquals("Default must be 90% of the spill volume's total space", expected, actual); + } + + public void testDeriveSpillLimitDefaultWithMissingSpillDirReturnsFallback() { + // Path that doesn't exist → getFileStore throws IOException → fallback applies. + Settings settings = Settings.builder() + .put("datafusion.spill_directory", "/nonexistent/path/that/does/not/exist/spill-test-12345") + .build(); + long actual = Long.parseLong(DataFusionPlugin.deriveSpillLimitDefault(settings)); + assertEquals("Probe failure must fall back to 8 GiB", 8L * 1024 * 1024 * 1024, actual); + } + + public void testValidateSpillLimitAcceptsValueAtOrBelowDiskCapacity() throws Exception { + Path spillDir = createTempDir(); + long total = Environment.getFileStore(spillDir).getTotalSpace(); + Settings settings = Settings.builder() + .put("datafusion.spill_directory", spillDir.toString()) + .put("datafusion.spill_memory_limit_bytes", total / 2) + .build(); + // get() runs the parser AND the validator; no throw = pass. + long parsed = DataFusionPlugin.DATAFUSION_SPILL_MEMORY_LIMIT.get(settings); + assertEquals(total / 2, parsed); + } + + public void testValidateSpillLimitRejectsValueExceedingDiskCapacity() throws Exception { + Path spillDir = createTempDir(); + long total = Environment.getFileStore(spillDir).getTotalSpace(); + Settings settings = Settings.builder() + .put("datafusion.spill_directory", spillDir.toString()) + .put("datafusion.spill_memory_limit_bytes", total + 1) + .build(); + IllegalArgumentException e = expectThrows( + IllegalArgumentException.class, + () -> DataFusionPlugin.DATAFUSION_SPILL_MEMORY_LIMIT.get(settings) + ); + assertTrue( + "expected message to mention exceeding capacity, got: " + e.getMessage(), + e.getMessage().contains("exceeds spill volume capacity") + ); + } + + public void testValidateSpillLimitAcceptsNonZeroWhenSpillDirUnset() { + // Spill disabled (empty directory): a non-zero limit is inert, so it must be accepted rather + // than failing node startup. Mirrors a node configured with a spill cap but no spill volume. + Settings settings = Settings.builder() + .put("datafusion.spill_directory", "") + .put("datafusion.spill_memory_limit_bytes", 1024L * 1024 * 1024) + .build(); + long parsed = DataFusionPlugin.DATAFUSION_SPILL_MEMORY_LIMIT.get(settings); + assertEquals("non-zero limit with spill disabled must be accepted as a no-op", 1024L * 1024 * 1024, parsed); + } + + public void testValidateSpillLimitAcceptsZeroWhenSpillDirUnset() { + Settings settings = Settings.builder().put("datafusion.spill_directory", "").put("datafusion.spill_memory_limit_bytes", 0L).build(); + long parsed = DataFusionPlugin.DATAFUSION_SPILL_MEMORY_LIMIT.get(settings); + assertEquals("Zero with spill disabled is valid", 0L, parsed); + } + + public void testValidateSpillLimitSkipsCapacityCheckWhenProbeFails() { + // When the spill directory cannot be probed (e.g. a path that doesn't exist), + // the validator must fail open: skip the capacity check rather than block the + // operator. Same fail-open behavior as deriveSpillLimitDefault uses for its 8 GiB fallback. + Settings settings = Settings.builder() + .put("datafusion.spill_directory", "/nonexistent/path/that/does/not/exist/spill-test-12345") + .put("datafusion.spill_memory_limit_bytes", 1024L * 1024 * 1024 * 1024) // 1 TiB + .build(); + // Even though 1 TiB would likely exceed any real disk, the probe fails so the check is skipped. + long parsed = DataFusionPlugin.DATAFUSION_SPILL_MEMORY_LIMIT.get(settings); + assertEquals("probe failure must skip the volume-capacity check", 1024L * 1024 * 1024 * 1024, parsed); + } + + // ── datafusion.memory_guard.spill_exempt_cap_bytes ── + + public void testSpillExemptCapIsRegistered() { + try (DataFusionPlugin plugin = new DataFusionPlugin()) { + List> settings = plugin.getSettings(); + assertTrue( + "Plugin must register DATAFUSION_MEMORY_GUARD_SPILL_EXEMPT_CAP via getSettings()", + settings.contains(DataFusionPlugin.DATAFUSION_MEMORY_GUARD_SPILL_EXEMPT_CAP) + ); + } catch (Exception e) { + throw new AssertionError(e); + } + } + + public void testSpillExemptCapIsDynamicAndNodeScope() { + assertTrue( + "datafusion.memory_guard.spill_exempt_cap_bytes must be dynamic so it can be tuned at runtime", + DataFusionPlugin.DATAFUSION_MEMORY_GUARD_SPILL_EXEMPT_CAP.isDynamic() + ); + assertTrue( + "datafusion.memory_guard.spill_exempt_cap_bytes must have node scope", + DataFusionPlugin.DATAFUSION_MEMORY_GUARD_SPILL_EXEMPT_CAP.hasNodeScope() + ); + } + + public void testSpillExemptCapDefaultIs512MiB() { + assertEquals( + "Default spill-exempt cap must be 512 MiB in raw bytes", + 536870912L, + DataFusionPlugin.DATAFUSION_MEMORY_GUARD_SPILL_EXEMPT_CAP.get(Settings.EMPTY).longValue() + ); + } + + public void testSpillExemptCapRejectsNegative() { + Settings s = Settings.builder().put("datafusion.memory_guard.spill_exempt_cap_bytes", -1L).build(); + IllegalArgumentException e = expectThrows( + IllegalArgumentException.class, + () -> DataFusionPlugin.DATAFUSION_MEMORY_GUARD_SPILL_EXEMPT_CAP.get(s) + ); + assertTrue(e.getMessage().contains("must be >= 0")); + } } diff --git a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DataFusionQueryExecutionTests.java b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DataFusionQueryExecutionTests.java index 307d0e5e15421..bb06c83361b77 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DataFusionQueryExecutionTests.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DataFusionQueryExecutionTests.java @@ -53,7 +53,7 @@ public void setUp() throws Exception { NativeBridge.initTokioRuntimeManager(2); Path spillDir = createTempDir("datafusion-spill"); runtimeHandle = new NativeRuntimeHandle( - NativeBridge.createGlobalRuntime(128 * 1024 * 1024, 0L, spillDir.toString(), 64 * 1024 * 1024) + NativeBridge.createGlobalRuntime(128 * 1024 * 1024, 0L, spillDir.toString(), 64 * 1024 * 1024, false, 0L, "lru") ); // Create a real TieredObjectStore (local-only) and wrap in NativeStoreHandle diff --git a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DataFusionServiceTests.java b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DataFusionServiceTests.java index 002e8e2e1e976..a0a5738fca301 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DataFusionServiceTests.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DataFusionServiceTests.java @@ -82,7 +82,7 @@ public void testGetNativeRuntimeBeforeStartThrows() { public void testNativeRuntimeHandleCloseIsIdempotent() { ensureTokioInit(); Path spillDir = createTempDir("spill"); - long ptr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024); + long ptr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024, false, 0L, "lru"); NativeRuntimeHandle handle = new NativeRuntimeHandle(ptr); assertTrue(handle.isOpen()); @@ -96,7 +96,7 @@ public void testNativeRuntimeHandleCloseIsIdempotent() { public void testNativeRuntimeHandleGetAfterCloseThrows() { ensureTokioInit(); Path spillDir = createTempDir("spill"); - long ptr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024); + long ptr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024, false, 0L, "lru"); NativeRuntimeHandle handle = new NativeRuntimeHandle(ptr); handle.close(); expectThrows(IllegalStateException.class, handle::get); @@ -166,12 +166,18 @@ public void testServiceWithoutCacheReturnsNullCacheManager() { public void testPluginRegistersAllCacheSettings() { List> settings = new DataFusionPlugin().getSettings(); - assertTrue(settings.contains(CacheSettings.METADATA_CACHE_SIZE_LIMIT)); - assertTrue(settings.contains(CacheSettings.STATISTICS_CACHE_SIZE_LIMIT)); assertTrue(settings.contains(CacheSettings.METADATA_CACHE_EVICTION_TYPE)); assertTrue(settings.contains(CacheSettings.STATISTICS_CACHE_EVICTION_TYPE)); + assertTrue(settings.contains(CacheSettings.COLUMN_INDEX_CACHE_EVICTION_TYPE)); + assertTrue(settings.contains(CacheSettings.OFFSET_INDEX_CACHE_EVICTION_TYPE)); assertTrue(settings.contains(CacheSettings.METADATA_CACHE_ENABLED)); assertTrue(settings.contains(CacheSettings.STATISTICS_CACHE_ENABLED)); + assertTrue(settings.contains(DataFusionPlugin.SCOPED_PAGE_INDEX_ENABLED)); + assertTrue(settings.contains(CacheSettings.METADATA_INDEX_CACHE_TOTAL_SIZE)); + assertTrue(settings.contains(CacheSettings.FOOTER_METADATA_CACHE_PERCENT)); + assertTrue(settings.contains(CacheSettings.OFFSET_INDEX_CACHE_PERCENT)); + assertTrue(settings.contains(CacheSettings.COLUMN_INDEX_CACHE_PERCENT)); + assertTrue(settings.contains(CacheSettings.STATISTICS_CACHE_PERCENT)); } public void testNativeBridgeCacheManagerLifecycle() { @@ -196,7 +202,15 @@ public void testRuntimeWithCacheManagerPointer() { NativeBridge.createCache(cachePtr, "STATISTICS", 100 * 1024 * 1024, "LRU"); Path spillDir = createTempDir("spill"); - long runtimePtr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, cachePtr, spillDir.toString(), 32 * 1024 * 1024); + long runtimePtr = NativeBridge.createGlobalRuntime( + 64 * 1024 * 1024, + cachePtr, + spillDir.toString(), + 32 * 1024 * 1024, + false, + 0L, + "lru" + ); assertTrue(runtimePtr != 0); NativeBridge.closeGlobalRuntime(runtimePtr); @@ -211,7 +225,15 @@ public void testCacheManagerHandleConsumedAfterRuntimeCreation() { assertTrue(org.opensearch.analytics.backend.jni.NativeHandle.isLivePointer(ptrBefore)); Path spillDir = createTempDir("spill"); - long runtimePtr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, handle.getPointer(), spillDir.toString(), 32 * 1024 * 1024); + long runtimePtr = NativeBridge.createGlobalRuntime( + 64 * 1024 * 1024, + handle.getPointer(), + spillDir.toString(), + 32 * 1024 * 1024, + false, + 0L, + "lru" + ); handle.markConsumed(); assertFalse(org.opensearch.analytics.backend.jni.NativeHandle.isLivePointer(ptrBefore)); @@ -223,11 +245,17 @@ public void testCacheManagerHandleConsumedAfterRuntimeCreation() { private ClusterSettings createCacheClusterSettings(Settings settings) { Set> all = new HashSet<>(BUILT_IN_CLUSTER_SETTINGS); all.add(CacheSettings.METADATA_CACHE_ENABLED); - all.add(CacheSettings.METADATA_CACHE_SIZE_LIMIT); all.add(CacheSettings.METADATA_CACHE_EVICTION_TYPE); all.add(CacheSettings.STATISTICS_CACHE_ENABLED); - all.add(CacheSettings.STATISTICS_CACHE_SIZE_LIMIT); all.add(CacheSettings.STATISTICS_CACHE_EVICTION_TYPE); + all.add(CacheSettings.COLUMN_INDEX_CACHE_EVICTION_TYPE); + all.add(CacheSettings.OFFSET_INDEX_CACHE_EVICTION_TYPE); + all.add(DataFusionPlugin.SCOPED_PAGE_INDEX_ENABLED); + all.add(CacheSettings.METADATA_INDEX_CACHE_TOTAL_SIZE); + all.add(CacheSettings.FOOTER_METADATA_CACHE_PERCENT); + all.add(CacheSettings.OFFSET_INDEX_CACHE_PERCENT); + all.add(CacheSettings.COLUMN_INDEX_CACHE_PERCENT); + all.add(CacheSettings.STATISTICS_CACHE_PERCENT); all.add(DataFusionPlugin.DATAFUSION_MEMORY_POOL_LIMIT); all.add(DataFusionPlugin.DATAFUSION_SPILL_MEMORY_LIMIT); return new ClusterSettings(settings, all); diff --git a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DatafusionCacheManagerTests.java b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DatafusionCacheManagerTests.java index f09497c72564c..df7b162270e2a 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DatafusionCacheManagerTests.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DatafusionCacheManagerTests.java @@ -36,11 +36,17 @@ private void setup() { Set> clusterSettingsToAdd = new HashSet<>(BUILT_IN_CLUSTER_SETTINGS); clusterSettingsToAdd.add(CacheSettings.METADATA_CACHE_ENABLED); - clusterSettingsToAdd.add(CacheSettings.METADATA_CACHE_SIZE_LIMIT); clusterSettingsToAdd.add(CacheSettings.METADATA_CACHE_EVICTION_TYPE); clusterSettingsToAdd.add(CacheSettings.STATISTICS_CACHE_ENABLED); - clusterSettingsToAdd.add(CacheSettings.STATISTICS_CACHE_SIZE_LIMIT); clusterSettingsToAdd.add(CacheSettings.STATISTICS_CACHE_EVICTION_TYPE); + clusterSettingsToAdd.add(CacheSettings.COLUMN_INDEX_CACHE_EVICTION_TYPE); + clusterSettingsToAdd.add(CacheSettings.OFFSET_INDEX_CACHE_EVICTION_TYPE); + clusterSettingsToAdd.add(DataFusionPlugin.SCOPED_PAGE_INDEX_ENABLED); + clusterSettingsToAdd.add(CacheSettings.METADATA_INDEX_CACHE_TOTAL_SIZE); + clusterSettingsToAdd.add(CacheSettings.FOOTER_METADATA_CACHE_PERCENT); + clusterSettingsToAdd.add(CacheSettings.OFFSET_INDEX_CACHE_PERCENT); + clusterSettingsToAdd.add(CacheSettings.COLUMN_INDEX_CACHE_PERCENT); + clusterSettingsToAdd.add(CacheSettings.STATISTICS_CACHE_PERCENT); clusterSettingsToAdd.add(DataFusionPlugin.DATAFUSION_MEMORY_POOL_LIMIT); clusterSettingsToAdd.add(DataFusionPlugin.DATAFUSION_SPILL_MEMORY_LIMIT); diff --git a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DatafusionMemtableReduceSinkTests.java b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DatafusionMemtableReduceSinkTests.java index fa41ad2d42fe7..02dccfa8a06ee 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DatafusionMemtableReduceSinkTests.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DatafusionMemtableReduceSinkTests.java @@ -59,7 +59,7 @@ public void testInputIdConstantMatchesDesign() { public void testFeedDrainsSumToDownstream() throws Exception { NativeBridge.initTokioRuntimeManager(2); Path spillDir = createTempDir("datafusion-spill"); - long runtimePtr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024); + long runtimePtr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024, false, 0L, "lru"); assertTrue("runtime ptr non-zero", runtimePtr != 0); NativeRuntimeHandle runtimeHandle = new NativeRuntimeHandle(runtimePtr); diff --git a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DatafusionReduceSinkTests.java b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DatafusionReduceSinkTests.java index b2c8800558d20..4a310b96155aa 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DatafusionReduceSinkTests.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DatafusionReduceSinkTests.java @@ -107,7 +107,7 @@ public void testReceiverDroppedSentinelIsPositiveAndDistinctFromSuccess() { public void testFeedDrainsSumToDownstream() throws Exception { NativeBridge.initTokioRuntimeManager(2); Path spillDir = createTempDir("datafusion-spill"); - long runtimePtr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024); + long runtimePtr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024, false, 0L, "lru"); assertTrue("runtime ptr non-zero", runtimePtr != 0); NativeRuntimeHandle runtimeHandle = new NativeRuntimeHandle(runtimePtr); @@ -169,7 +169,7 @@ public void testFeedDrainsSumToDownstream() throws Exception { public void testReduceWithLimitProducesLimitedOutput() throws Exception { NativeBridge.initTokioRuntimeManager(2); Path spillDir = createTempDir("datafusion-spill"); - long runtimePtr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024); + long runtimePtr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024, false, 0L, "lru"); NativeRuntimeHandle runtimeHandle = new NativeRuntimeHandle(runtimePtr); try (RootAllocator alloc = new RootAllocator(Long.MAX_VALUE)) { @@ -214,7 +214,7 @@ public void testReduceWithLimitProducesLimitedOutput() throws Exception { public void testDrainTaskKeepsUpWithProducer() throws Exception { NativeBridge.initTokioRuntimeManager(2); Path spillDir = createTempDir("datafusion-spill"); - long runtimePtr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024); + long runtimePtr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024, false, 0L, "lru"); NativeRuntimeHandle runtimeHandle = new NativeRuntimeHandle(runtimePtr); try (RootAllocator alloc = new RootAllocator(Long.MAX_VALUE)) { @@ -268,7 +268,7 @@ public void testDrainTaskKeepsUpWithProducer() throws Exception { public void testReduceProducesOutputIncrementallyForPipelinedPlan() throws Exception { NativeBridge.initTokioRuntimeManager(2); Path spillDir = createTempDir("datafusion-spill"); - long runtimePtr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024); + long runtimePtr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024, false, 0L, "lru"); NativeRuntimeHandle runtimeHandle = new NativeRuntimeHandle(runtimePtr); try (RootAllocator alloc = new RootAllocator(Long.MAX_VALUE)) { @@ -344,7 +344,7 @@ public void testReduceProducesOutputIncrementallyForPipelinedPlan() throws Excep public void testCloseWhileFeederParkedOnFullChannelDoesNotDeadlock() throws Exception { NativeBridge.initTokioRuntimeManager(2); Path spillDir = createTempDir("datafusion-spill"); - long runtimePtr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024); + long runtimePtr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024, false, 0L, "lru"); NativeRuntimeHandle runtimeHandle = new NativeRuntimeHandle(runtimePtr); try (RootAllocator alloc = new RootAllocator(Long.MAX_VALUE)) { @@ -421,7 +421,7 @@ public void testCloseWhileFeederParkedOnFullChannelDoesNotDeadlock() throws Exce public void testCancelBeforeFirstBatchUnwindsDrain() throws Exception { NativeBridge.initTokioRuntimeManager(2); Path spillDir = createTempDir("datafusion-spill"); - long runtimePtr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024); + long runtimePtr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024, false, 0L, "lru"); NativeRuntimeHandle runtimeHandle = new NativeRuntimeHandle(runtimePtr); try (RootAllocator alloc = new RootAllocator(Long.MAX_VALUE)) { @@ -473,7 +473,7 @@ public void testCancelBeforeFirstBatchUnwindsDrain() throws Exception { public void testCancelAfterFirstBatchUnwindsDrain() throws Exception { NativeBridge.initTokioRuntimeManager(2); Path spillDir = createTempDir("datafusion-spill"); - long runtimePtr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024); + long runtimePtr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024, false, 0L, "lru"); NativeRuntimeHandle runtimeHandle = new NativeRuntimeHandle(runtimePtr); try (RootAllocator alloc = new RootAllocator(Long.MAX_VALUE)) { @@ -519,7 +519,7 @@ public void testCancelAfterFirstBatchUnwindsDrain() throws Exception { public void testDoubleCloseIsIdempotent() throws Exception { NativeBridge.initTokioRuntimeManager(2); Path spillDir = createTempDir("datafusion-spill"); - long runtimePtr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024); + long runtimePtr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024, false, 0L, "lru"); NativeRuntimeHandle runtimeHandle = new NativeRuntimeHandle(runtimePtr); try (RootAllocator alloc = new RootAllocator(Long.MAX_VALUE)) { diff --git a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DatafusionResultStreamTests.java b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DatafusionResultStreamTests.java index 28db10793bffc..6dd6010a48e5d 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DatafusionResultStreamTests.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DatafusionResultStreamTests.java @@ -47,7 +47,7 @@ public void setUp() throws Exception { super.setUp(); NativeBridge.initTokioRuntimeManager(2); Path spillDir = createTempDir("spill"); - long ptr = NativeBridge.createGlobalRuntime(128 * 1024 * 1024, 0L, spillDir.toString(), 64 * 1024 * 1024); + long ptr = NativeBridge.createGlobalRuntime(128 * 1024 * 1024, 0L, spillDir.toString(), 64 * 1024 * 1024, false, 0L, "lru"); runtimeHandle = new NativeRuntimeHandle(ptr); testRootAllocator = new RootAllocator(Long.MAX_VALUE); @@ -237,7 +237,7 @@ public void testCloseAfterNativeStreamNextFailure() throws Exception { // Create a valid stream, close the runtime handle to force streamNext failure, // then verify the stream still closes cleanly Path spillDir2 = createTempDir("spill2"); - long ptr2 = NativeBridge.createGlobalRuntime(128 * 1024 * 1024, 0L, spillDir2.toString(), 64 * 1024 * 1024); + long ptr2 = NativeBridge.createGlobalRuntime(128 * 1024 * 1024, 0L, spillDir2.toString(), 64 * 1024 * 1024, false, 0L, "lru"); NativeRuntimeHandle tempRuntime = new NativeRuntimeHandle(ptr2); byte[] substrait = NativeBridge.sqlToSubstrait( diff --git a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DatafusionSearchExecEngineTests.java b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DatafusionSearchExecEngineTests.java index 48b380ea44056..6892251783fca 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DatafusionSearchExecEngineTests.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DatafusionSearchExecEngineTests.java @@ -44,7 +44,7 @@ public void setUp() throws Exception { super.setUp(); NativeBridge.initTokioRuntimeManager(2); Path spillDir = createTempDir("datafusion-spill"); - long ptr = NativeBridge.createGlobalRuntime(128 * 1024 * 1024, 0L, spillDir.toString(), 64 * 1024 * 1024); + long ptr = NativeBridge.createGlobalRuntime(128 * 1024 * 1024, 0L, spillDir.toString(), 64 * 1024 * 1024, false, 0L, "lru"); runtimeHandle = new NativeRuntimeHandle(ptr); // Create a real TieredObjectStore (local-only) and wrap in NativeStoreHandle diff --git a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DatafusionSettingsPropertyTests.java b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DatafusionSettingsPropertyTests.java index c215c3e02619c..2caf8b4e1c2eb 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DatafusionSettingsPropertyTests.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DatafusionSettingsPropertyTests.java @@ -20,7 +20,6 @@ public class DatafusionSettingsPropertyTests extends OpenSearchTestCase { private static final int ITERATIONS = 200; - private static final String[] STRATEGIES = { "full_range", "tighten_outer_bounds", "page_range_split" }; private ClusterSettings createClusterSettings() { Set> settingsSet = new HashSet<>(DatafusionSettings.ALL_SETTINGS); @@ -37,38 +36,32 @@ public void testSnapshotUpdateConsistencyProperty() { WireConfigSnapshot before = datafusionSettings.getSnapshot(); - int settingIndex = randomIntBetween(0, 8); + int settingIndex = randomIntBetween(0, 5); Settings newSettings; switch (settingIndex) { case 0: // batch_size int newBatchSize = randomIntBetween(1, 1_000_000); - newSettings = Settings.builder().put("datafusion.indexed.batch_size", newBatchSize).build(); + newSettings = Settings.builder().put("datafusion.batch_size", newBatchSize).build(); clusterSettings.applySettings(newSettings); WireConfigSnapshot afterBatch = datafusionSettings.getSnapshot(); assertEquals(newBatchSize, afterBatch.batchSize()); assertEquals(before.targetPartitions(), afterBatch.targetPartitions()); - assertEquals(before.parquetPushdownFilters(), afterBatch.parquetPushdownFilters()); + assertEquals(before.listingTablePushdownFilters(), afterBatch.listingTablePushdownFilters()); assertEquals(before.minSkipRunDefault(), afterBatch.minSkipRunDefault()); assertEquals(before.minSkipRunSelectivityThreshold(), afterBatch.minSkipRunSelectivityThreshold(), 0.0); - assertEquals(before.singleCollectorStrategy(), afterBatch.singleCollectorStrategy()); - assertEquals(before.treeCollectorStrategy(), afterBatch.treeCollectorStrategy()); - assertEquals(before.maxCollectorParallelism(), afterBatch.maxCollectorParallelism()); break; - case 1: // parquet_pushdown_filters - boolean newPushdown = before.parquetPushdownFilters() == false; - newSettings = Settings.builder().put("datafusion.indexed.parquet_pushdown_filters", newPushdown).build(); + case 1: // listing_table.pushdown_filters + boolean newPushdown = before.listingTablePushdownFilters() == false; + newSettings = Settings.builder().put("datafusion.listing_table.pushdown_filters", newPushdown).build(); clusterSettings.applySettings(newSettings); WireConfigSnapshot afterPushdown = datafusionSettings.getSnapshot(); - assertEquals(newPushdown, afterPushdown.parquetPushdownFilters()); + assertEquals(newPushdown, afterPushdown.listingTablePushdownFilters()); assertEquals(before.batchSize(), afterPushdown.batchSize()); assertEquals(before.targetPartitions(), afterPushdown.targetPartitions()); assertEquals(before.minSkipRunDefault(), afterPushdown.minSkipRunDefault()); assertEquals(before.minSkipRunSelectivityThreshold(), afterPushdown.minSkipRunSelectivityThreshold(), 0.0); - assertEquals(before.singleCollectorStrategy(), afterPushdown.singleCollectorStrategy()); - assertEquals(before.treeCollectorStrategy(), afterPushdown.treeCollectorStrategy()); - assertEquals(before.maxCollectorParallelism(), afterPushdown.maxCollectorParallelism()); break; case 2: // min_skip_run_default @@ -79,11 +72,8 @@ public void testSnapshotUpdateConsistencyProperty() { assertEquals(newMinSkipRun, afterSkipRun.minSkipRunDefault()); assertEquals(before.batchSize(), afterSkipRun.batchSize()); assertEquals(before.targetPartitions(), afterSkipRun.targetPartitions()); - assertEquals(before.parquetPushdownFilters(), afterSkipRun.parquetPushdownFilters()); + assertEquals(before.listingTablePushdownFilters(), afterSkipRun.listingTablePushdownFilters()); assertEquals(before.minSkipRunSelectivityThreshold(), afterSkipRun.minSkipRunSelectivityThreshold(), 0.0); - assertEquals(before.singleCollectorStrategy(), afterSkipRun.singleCollectorStrategy()); - assertEquals(before.treeCollectorStrategy(), afterSkipRun.treeCollectorStrategy()); - assertEquals(before.maxCollectorParallelism(), afterSkipRun.maxCollectorParallelism()); break; case 3: // min_skip_run_selectivity_threshold @@ -94,85 +84,31 @@ public void testSnapshotUpdateConsistencyProperty() { assertEquals(newThreshold, afterThreshold.minSkipRunSelectivityThreshold(), 1e-15); assertEquals(before.batchSize(), afterThreshold.batchSize()); assertEquals(before.targetPartitions(), afterThreshold.targetPartitions()); - assertEquals(before.parquetPushdownFilters(), afterThreshold.parquetPushdownFilters()); + assertEquals(before.listingTablePushdownFilters(), afterThreshold.listingTablePushdownFilters()); assertEquals(before.minSkipRunDefault(), afterThreshold.minSkipRunDefault()); - assertEquals(before.singleCollectorStrategy(), afterThreshold.singleCollectorStrategy()); - assertEquals(before.treeCollectorStrategy(), afterThreshold.treeCollectorStrategy()); - assertEquals(before.maxCollectorParallelism(), afterThreshold.maxCollectorParallelism()); break; - case 4: // single_collector_strategy - String newSingle = STRATEGIES[randomIntBetween(0, 2)]; - newSettings = Settings.builder().put("datafusion.indexed.single_collector_strategy", newSingle).build(); - clusterSettings.applySettings(newSettings); - WireConfigSnapshot afterSingle = datafusionSettings.getSnapshot(); - assertEquals(DatafusionSettings.strategyToWireValue(newSingle), afterSingle.singleCollectorStrategy()); - assertEquals(before.batchSize(), afterSingle.batchSize()); - assertEquals(before.targetPartitions(), afterSingle.targetPartitions()); - assertEquals(before.parquetPushdownFilters(), afterSingle.parquetPushdownFilters()); - assertEquals(before.minSkipRunDefault(), afterSingle.minSkipRunDefault()); - assertEquals(before.minSkipRunSelectivityThreshold(), afterSingle.minSkipRunSelectivityThreshold(), 0.0); - assertEquals(before.treeCollectorStrategy(), afterSingle.treeCollectorStrategy()); - assertEquals(before.maxCollectorParallelism(), afterSingle.maxCollectorParallelism()); - break; - - case 5: // tree_collector_strategy - String newTree = STRATEGIES[randomIntBetween(0, 2)]; - newSettings = Settings.builder().put("datafusion.indexed.tree_collector_strategy", newTree).build(); - clusterSettings.applySettings(newSettings); - WireConfigSnapshot afterTree = datafusionSettings.getSnapshot(); - assertEquals(DatafusionSettings.strategyToWireValue(newTree), afterTree.treeCollectorStrategy()); - assertEquals(before.batchSize(), afterTree.batchSize()); - assertEquals(before.targetPartitions(), afterTree.targetPartitions()); - assertEquals(before.parquetPushdownFilters(), afterTree.parquetPushdownFilters()); - assertEquals(before.minSkipRunDefault(), afterTree.minSkipRunDefault()); - assertEquals(before.minSkipRunSelectivityThreshold(), afterTree.minSkipRunSelectivityThreshold(), 0.0); - assertEquals(before.singleCollectorStrategy(), afterTree.singleCollectorStrategy()); - assertEquals(before.maxCollectorParallelism(), afterTree.maxCollectorParallelism()); - break; - - case 6: // max_collector_parallelism - int newMaxParallelism = randomIntBetween(1, 64); - newSettings = Settings.builder().put("datafusion.indexed.max_collector_parallelism", newMaxParallelism).build(); - clusterSettings.applySettings(newSettings); - WireConfigSnapshot afterParallelism = datafusionSettings.getSnapshot(); - assertEquals(newMaxParallelism, afterParallelism.maxCollectorParallelism()); - assertEquals(before.batchSize(), afterParallelism.batchSize()); - assertEquals(before.targetPartitions(), afterParallelism.targetPartitions()); - assertEquals(before.parquetPushdownFilters(), afterParallelism.parquetPushdownFilters()); - assertEquals(before.minSkipRunDefault(), afterParallelism.minSkipRunDefault()); - assertEquals(before.minSkipRunSelectivityThreshold(), afterParallelism.minSkipRunSelectivityThreshold(), 0.0); - assertEquals(before.singleCollectorStrategy(), afterParallelism.singleCollectorStrategy()); - assertEquals(before.treeCollectorStrategy(), afterParallelism.treeCollectorStrategy()); - break; - - case 7: // max_slice_count + case 4: // max_slice_count int newSliceCount = randomIntBetween(1, 32); newSettings = Settings.builder().put("search.concurrent.max_slice_count", newSliceCount).build(); clusterSettings.applySettings(newSettings); WireConfigSnapshot afterSlice = datafusionSettings.getSnapshot(); assertEquals(Math.min(newSliceCount, Runtime.getRuntime().availableProcessors()), afterSlice.targetPartitions()); assertEquals(before.batchSize(), afterSlice.batchSize()); - assertEquals(before.parquetPushdownFilters(), afterSlice.parquetPushdownFilters()); + assertEquals(before.listingTablePushdownFilters(), afterSlice.listingTablePushdownFilters()); assertEquals(before.minSkipRunDefault(), afterSlice.minSkipRunDefault()); assertEquals(before.minSkipRunSelectivityThreshold(), afterSlice.minSkipRunSelectivityThreshold(), 0.0); - assertEquals(before.singleCollectorStrategy(), afterSlice.singleCollectorStrategy()); - assertEquals(before.treeCollectorStrategy(), afterSlice.treeCollectorStrategy()); - assertEquals(before.maxCollectorParallelism(), afterSlice.maxCollectorParallelism()); break; - case 8: // concurrent_search_mode + case 5: // concurrent_search_mode newSettings = Settings.builder().put("search.concurrent_segment_search.mode", "none").build(); clusterSettings.applySettings(newSettings); WireConfigSnapshot afterMode = datafusionSettings.getSnapshot(); assertEquals(1, afterMode.targetPartitions()); assertEquals(before.batchSize(), afterMode.batchSize()); - assertEquals(before.parquetPushdownFilters(), afterMode.parquetPushdownFilters()); + assertEquals(before.listingTablePushdownFilters(), afterMode.listingTablePushdownFilters()); assertEquals(before.minSkipRunDefault(), afterMode.minSkipRunDefault()); assertEquals(before.minSkipRunSelectivityThreshold(), afterMode.minSkipRunSelectivityThreshold(), 0.0); - assertEquals(before.singleCollectorStrategy(), afterMode.singleCollectorStrategy()); - assertEquals(before.treeCollectorStrategy(), afterMode.treeCollectorStrategy()); - assertEquals(before.maxCollectorParallelism(), afterMode.maxCollectorParallelism()); break; default: @@ -188,13 +124,11 @@ public void testSequentialUpdatesAccumulateCorrectly() { datafusionSettings.registerListeners(clusterSettings); int newBatchSize = randomIntBetween(1, 1_000_000); - String newSingleStrategy = STRATEGIES[randomIntBetween(0, 2)]; double newThreshold = randomDoubleBetween(0.0, 1.0, true); clusterSettings.applySettings( Settings.builder() - .put("datafusion.indexed.batch_size", newBatchSize) - .put("datafusion.indexed.single_collector_strategy", newSingleStrategy) + .put("datafusion.batch_size", newBatchSize) .put("datafusion.indexed.min_skip_run_selectivity_threshold", newThreshold) .build() ); @@ -202,11 +136,9 @@ public void testSequentialUpdatesAccumulateCorrectly() { WireConfigSnapshot finalSnapshot = datafusionSettings.getSnapshot(); assertEquals(newBatchSize, finalSnapshot.batchSize()); - assertEquals(DatafusionSettings.strategyToWireValue(newSingleStrategy), finalSnapshot.singleCollectorStrategy()); assertEquals(newThreshold, finalSnapshot.minSkipRunSelectivityThreshold(), 1e-15); - assertEquals(false, finalSnapshot.parquetPushdownFilters()); + assertEquals(false, finalSnapshot.listingTablePushdownFilters()); assertEquals(1024, finalSnapshot.minSkipRunDefault()); - assertEquals(1, finalSnapshot.maxCollectorParallelism()); } } } diff --git a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DatafusionSettingsTests.java b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DatafusionSettingsTests.java index 24c7303192f2c..fc8181c59109c 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DatafusionSettingsTests.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/DatafusionSettingsTests.java @@ -17,17 +17,24 @@ public class DatafusionSettingsTests extends OpenSearchTestCase { private static final int DEFAULT_PARALLELISM = Math.max(1, Math.min(Runtime.getRuntime().availableProcessors() / 2, 4)); public void testBatchSizeSettingDefinition() { - assertEquals("datafusion.indexed.batch_size", DatafusionSettings.INDEXED_BATCH_SIZE.getKey()); - assertEquals(Integer.valueOf(8192), DatafusionSettings.INDEXED_BATCH_SIZE.get(Settings.EMPTY)); - assertTrue(DatafusionSettings.INDEXED_BATCH_SIZE.isDynamic()); - assertTrue(DatafusionSettings.INDEXED_BATCH_SIZE.hasNodeScope()); + assertEquals("datafusion.batch_size", DatafusionSettings.BATCH_SIZE.getKey()); + assertEquals(Integer.valueOf(8192), DatafusionSettings.BATCH_SIZE.get(Settings.EMPTY)); + assertTrue(DatafusionSettings.BATCH_SIZE.isDynamic()); + assertTrue(DatafusionSettings.BATCH_SIZE.hasNodeScope()); } - public void testParquetPushdownFiltersSettingDefinition() { - assertEquals("datafusion.indexed.parquet_pushdown_filters", DatafusionSettings.INDEXED_PARQUET_PUSHDOWN_FILTERS.getKey()); - assertEquals(Boolean.FALSE, DatafusionSettings.INDEXED_PARQUET_PUSHDOWN_FILTERS.get(Settings.EMPTY)); - assertTrue(DatafusionSettings.INDEXED_PARQUET_PUSHDOWN_FILTERS.isDynamic()); - assertTrue(DatafusionSettings.INDEXED_PARQUET_PUSHDOWN_FILTERS.hasNodeScope()); + public void testListingTablePushdownFiltersSettingDefinition() { + assertEquals("datafusion.listing_table.pushdown_filters", DatafusionSettings.LISTING_TABLE_PUSHDOWN_FILTERS.getKey()); + assertEquals(Boolean.FALSE, DatafusionSettings.LISTING_TABLE_PUSHDOWN_FILTERS.get(Settings.EMPTY)); + assertTrue(DatafusionSettings.LISTING_TABLE_PUSHDOWN_FILTERS.isDynamic()); + assertTrue(DatafusionSettings.LISTING_TABLE_PUSHDOWN_FILTERS.hasNodeScope()); + } + + public void testIndexedPushdownFiltersSettingDefinition() { + assertEquals("datafusion.indexed.pushdown_filters", DatafusionSettings.INDEXED_PUSHDOWN_FILTERS.getKey()); + assertEquals(Boolean.TRUE, DatafusionSettings.INDEXED_PUSHDOWN_FILTERS.get(Settings.EMPTY)); + assertTrue(DatafusionSettings.INDEXED_PUSHDOWN_FILTERS.isDynamic()); + assertTrue(DatafusionSettings.INDEXED_PUSHDOWN_FILTERS.hasNodeScope()); } public void testMinSkipRunDefaultSettingDefinition() { @@ -47,41 +54,43 @@ public void testMinSkipRunSelectivityThresholdSettingDefinition() { assertTrue(DatafusionSettings.INDEXED_MIN_SKIP_RUN_SELECTIVITY_THRESHOLD.hasNodeScope()); } - public void testSingleCollectorStrategySettingDefinition() { - assertEquals("datafusion.indexed.single_collector_strategy", DatafusionSettings.INDEXED_SINGLE_COLLECTOR_STRATEGY.getKey()); - assertEquals("page_range_split", DatafusionSettings.INDEXED_SINGLE_COLLECTOR_STRATEGY.get(Settings.EMPTY)); - assertTrue(DatafusionSettings.INDEXED_SINGLE_COLLECTOR_STRATEGY.isDynamic()); - assertTrue(DatafusionSettings.INDEXED_SINGLE_COLLECTOR_STRATEGY.hasNodeScope()); - } - - public void testTreeCollectorStrategySettingDefinition() { - assertEquals("datafusion.indexed.tree_collector_strategy", DatafusionSettings.INDEXED_TREE_COLLECTOR_STRATEGY.getKey()); - assertEquals("tighten_outer_bounds", DatafusionSettings.INDEXED_TREE_COLLECTOR_STRATEGY.get(Settings.EMPTY)); - assertTrue(DatafusionSettings.INDEXED_TREE_COLLECTOR_STRATEGY.isDynamic()); - assertTrue(DatafusionSettings.INDEXED_TREE_COLLECTOR_STRATEGY.hasNodeScope()); - } - - public void testMaxCollectorParallelismSettingDefinition() { - assertEquals("datafusion.indexed.max_collector_parallelism", DatafusionSettings.INDEXED_MAX_COLLECTOR_PARALLELISM.getKey()); - assertEquals(Integer.valueOf(1), DatafusionSettings.INDEXED_MAX_COLLECTOR_PARALLELISM.get(Settings.EMPTY)); - assertTrue(DatafusionSettings.INDEXED_MAX_COLLECTOR_PARALLELISM.isDynamic()); - assertTrue(DatafusionSettings.INDEXED_MAX_COLLECTOR_PARALLELISM.hasNodeScope()); - } - public void testAllSettingsContainsAllExpectedSettings() { - assertEquals(28, DatafusionSettings.ALL_SETTINGS.size()); + assertEquals(36, DatafusionSettings.ALL_SETTINGS.size()); assertTrue(DatafusionSettings.ALL_SETTINGS.contains(DataFusionPlugin.DATAFUSION_REDUCE_TARGET_PARTITIONS)); + assertTrue(DatafusionSettings.ALL_SETTINGS.contains(DataFusionPlugin.DATAFUSION_MEMORY_GUARD_SPILL_EXEMPT_CAP)); assertTrue(DatafusionSettings.ALL_SETTINGS.contains(DataFusionPlugin.DATAFUSION_SPILL_DIRECTORY)); - assertTrue(DatafusionSettings.ALL_SETTINGS.contains(DatafusionSettings.INDEXED_BATCH_SIZE)); - assertTrue(DatafusionSettings.ALL_SETTINGS.contains(DatafusionSettings.INDEXED_PARQUET_PUSHDOWN_FILTERS)); - assertTrue(DatafusionSettings.ALL_SETTINGS.contains(DatafusionSettings.INDEXED_BLOOM_FILTER_ON_READ)); + assertTrue(DatafusionSettings.ALL_SETTINGS.contains(DatafusionSettings.BATCH_SIZE)); + assertTrue(DatafusionSettings.ALL_SETTINGS.contains(DatafusionSettings.LISTING_TABLE_PUSHDOWN_FILTERS)); + assertTrue(DatafusionSettings.ALL_SETTINGS.contains(DatafusionSettings.INDEXED_PUSHDOWN_FILTERS)); assertTrue(DatafusionSettings.ALL_SETTINGS.contains(DatafusionSettings.INDEXED_MIN_SKIP_RUN_DEFAULT)); assertTrue(DatafusionSettings.ALL_SETTINGS.contains(DatafusionSettings.INDEXED_MIN_SKIP_RUN_SELECTIVITY_THRESHOLD)); - assertTrue(DatafusionSettings.ALL_SETTINGS.contains(DatafusionSettings.INDEXED_SINGLE_COLLECTOR_STRATEGY)); - assertTrue(DatafusionSettings.ALL_SETTINGS.contains(DatafusionSettings.INDEXED_TREE_COLLECTOR_STRATEGY)); - assertTrue(DatafusionSettings.ALL_SETTINGS.contains(DatafusionSettings.INDEXED_MAX_COLLECTOR_PARALLELISM)); - assertTrue(DatafusionSettings.ALL_SETTINGS.contains(DatafusionSettings.INDEXED_QUERY_STRATEGY)); - assertTrue(DatafusionSettings.ALL_SETTINGS.contains(DatafusionSettings.INDEXED_DYNAMIC_FILTER_PUSHDOWN)); + assertTrue(DatafusionSettings.ALL_SETTINGS.contains(DatafusionSettings.INDEXED_FORCE_STRATEGY)); + } + + public void testForceStrategySettingDefaultsAndMapping() { + assertEquals("datafusion.indexed.force_strategy", DatafusionSettings.INDEXED_FORCE_STRATEGY.getKey()); + assertEquals("none", DatafusionSettings.INDEXED_FORCE_STRATEGY.get(Settings.EMPTY)); + assertTrue(DatafusionSettings.INDEXED_FORCE_STRATEGY.isDynamic()); + assertTrue(DatafusionSettings.INDEXED_FORCE_STRATEGY.hasNodeScope()); + + assertEquals(-1, DatafusionSettings.forceStrategyToWire("none")); + assertEquals(0, DatafusionSettings.forceStrategyToWire("row_selection")); + assertEquals(1, DatafusionSettings.forceStrategyToWire("boolean_mask")); + + // Default snapshot encodes None (-1). + assertEquals(-1, new DatafusionSettings(Settings.EMPTY).getSnapshot().forceStrategy()); + + // A configured value flows into the snapshot. + DatafusionSettings ds = new DatafusionSettings(Settings.builder().put("datafusion.indexed.force_strategy", "boolean_mask").build()); + assertEquals(1, ds.getSnapshot().forceStrategy()); + + // Invalid values are rejected at parse time. + expectThrows( + IllegalArgumentException.class, + () -> DatafusionSettings.INDEXED_FORCE_STRATEGY.get( + Settings.builder().put("datafusion.indexed.force_strategy", "bogus").build() + ) + ); } public void testDefaultSnapshotValuesMatchDefaults() { @@ -89,15 +98,10 @@ public void testDefaultSnapshotValuesMatchDefaults() { WireConfigSnapshot snapshot = ds.getSnapshot(); assertEquals(8192, snapshot.batchSize()); - assertEquals(false, snapshot.parquetPushdownFilters()); - assertEquals(true, snapshot.bloomFilterOnRead()); + assertEquals(false, snapshot.listingTablePushdownFilters()); assertEquals(1024, snapshot.minSkipRunDefault()); assertEquals(0.03, snapshot.minSkipRunSelectivityThreshold(), 1e-15); - assertEquals(2, snapshot.singleCollectorStrategy()); // page_range_split - assertEquals(1, snapshot.treeCollectorStrategy()); // tighten_outer_bounds - assertEquals(1, snapshot.maxCollectorParallelism()); assertEquals(DEFAULT_PARALLELISM, snapshot.targetPartitions()); - assertEquals(2, snapshot.queryStrategy()); // indexed } public void testTargetPartitionsPassthroughWhenNonZero() { @@ -135,54 +139,13 @@ public void testTargetPartitionsCappedAtAvailableProcessors() { assertEquals(processors, ds.getSnapshot().targetPartitions()); } - public void testStrategyToWireValueMapping() { - assertEquals(0, DatafusionSettings.strategyToWireValue("full_range")); - assertEquals(1, DatafusionSettings.strategyToWireValue("tighten_outer_bounds")); - assertEquals(2, DatafusionSettings.strategyToWireValue("page_range_split")); - expectThrows(IllegalArgumentException.class, () -> DatafusionSettings.strategyToWireValue("invalid")); - } - - public void testQueryStrategySettingDefinition() { - assertEquals("datafusion.indexed.query_strategy", DatafusionSettings.INDEXED_QUERY_STRATEGY.getKey()); - assertEquals("indexed", DatafusionSettings.INDEXED_QUERY_STRATEGY.get(Settings.EMPTY)); - assertTrue(DatafusionSettings.INDEXED_QUERY_STRATEGY.isDynamic()); - assertTrue(DatafusionSettings.INDEXED_QUERY_STRATEGY.hasNodeScope()); - } - - public void testQueryStrategyToWireValueMapping() { - assertEquals(0, DatafusionSettings.queryStrategyToWireValue("none")); - assertEquals(1, DatafusionSettings.queryStrategyToWireValue("listing_table")); - assertEquals(2, DatafusionSettings.queryStrategyToWireValue("indexed")); - expectThrows(IllegalArgumentException.class, () -> DatafusionSettings.queryStrategyToWireValue("invalid")); - } - - public void testInvalidQueryStrategyIsRejected() { - Settings settings = Settings.builder().put("datafusion.indexed.query_strategy", "bogus").build(); - expectThrows(IllegalArgumentException.class, () -> DatafusionSettings.INDEXED_QUERY_STRATEGY.get(settings)); - } - public void testBatchSizeZeroIsRejected() { - Settings settings = Settings.builder().put("datafusion.indexed.batch_size", 0).build(); - expectThrows(IllegalArgumentException.class, () -> DatafusionSettings.INDEXED_BATCH_SIZE.get(settings)); - } - - public void testMaxCollectorParallelismNegativeIsRejected() { - Settings settings = Settings.builder().put("datafusion.indexed.max_collector_parallelism", -1).build(); - expectThrows(IllegalArgumentException.class, () -> DatafusionSettings.INDEXED_MAX_COLLECTOR_PARALLELISM.get(settings)); + Settings settings = Settings.builder().put("datafusion.batch_size", 0).build(); + expectThrows(IllegalArgumentException.class, () -> DatafusionSettings.BATCH_SIZE.get(settings)); } public void testSelectivityThresholdAboveBoundIsRejected() { Settings settings = Settings.builder().put("datafusion.indexed.min_skip_run_selectivity_threshold", 1.1).build(); expectThrows(IllegalArgumentException.class, () -> DatafusionSettings.INDEXED_MIN_SKIP_RUN_SELECTIVITY_THRESHOLD.get(settings)); } - - public void testInvalidSingleCollectorStrategyIsRejected() { - Settings settings = Settings.builder().put("datafusion.indexed.single_collector_strategy", "bogus").build(); - expectThrows(IllegalArgumentException.class, () -> DatafusionSettings.INDEXED_SINGLE_COLLECTOR_STRATEGY.get(settings)); - } - - public void testInvalidTreeCollectorStrategyIsRejected() { - Settings settings = Settings.builder().put("datafusion.indexed.tree_collector_strategy", "bogus").build(); - expectThrows(IllegalArgumentException.class, () -> DatafusionSettings.INDEXED_TREE_COLLECTOR_STRATEGY.get(settings)); - } } diff --git a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/GetServiceTests.java b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/GetServiceTests.java new file mode 100644 index 0000000000000..7cc4f7f3ad49d --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/GetServiceTests.java @@ -0,0 +1,146 @@ +/* + * 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.be.datafusion; + +import org.opensearch.analytics.spi.DocumentLookupService; +import org.opensearch.analytics.spi.DocumentRowReader; +import org.opensearch.core.index.Index; +import org.opensearch.index.engine.exec.DocumentMetadataResolver; +import org.opensearch.index.engine.exec.DocumentMetadataResolver.DocumentMetadata; +import org.opensearch.index.engine.exec.IndexReaderProvider; +import org.opensearch.index.engine.exec.WriterFileSet; +import org.opensearch.index.engine.exec.coord.CatalogSnapshot; +import org.opensearch.index.get.DocumentLookupResult; +import org.opensearch.test.OpenSearchTestCase; + +import java.io.IOException; +import java.util.Map; +import java.util.Set; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class GetServiceTests extends OpenSearchTestCase { + + private static final Index INDEX = new Index("idx", "uuid"); + + private DocumentRowReader mockExecutor; + + @Override + public void setUp() throws Exception { + super.setUp(); + mockExecutor = mock(DocumentRowReader.class); + } + + public void testGetById_nullResolver_returnsNotFound() throws IOException { + DocumentMetadataResolver resolver = mock(DocumentMetadataResolver.class); + when(resolver.resolveMetadata(any(), eq("doc1"))).thenReturn(null); + + IndexReaderProvider.Reader mockReader = mock(IndexReaderProvider.Reader.class); + DocumentLookupService service = new GetService(mockExecutor).documentLookupService(resolver); + DocumentLookupResult result = service.getById("doc1", mockReader, INDEX); + assertFalse(result.exists()); + assertEquals("doc1", result.id()); + } + + public void testGetById_docFound_returnsFromParquet() throws IOException { + DocumentMetadataResolver resolver = mock(DocumentMetadataResolver.class); + DocumentMetadata metadata = new DocumentMetadata("doc1", 0L, 1L); + when(resolver.resolveMetadata(any(), eq("doc1"))).thenReturn(metadata); + + DocumentRowReader executor = mock(DocumentRowReader.class); + IndexReaderProvider.Reader mockReader = mock(IndexReaderProvider.Reader.class); + WriterFileSet pset = new WriterFileSet("/dir", 1L, Set.of("f.parquet"), 1L, 100L); + CatalogSnapshot snapshot = mock(CatalogSnapshot.class); + when(mockReader.catalogSnapshot()).thenReturn(snapshot); + when(snapshot.findFileSet("parquet", 1L)).thenReturn(pset); + when(executor.formatName()).thenReturn("parquet"); + when(executor.executeSingleRow(0L, pset)).thenReturn( + Map.of("_id", "doc1", "_seq_no", 5L, "_primary_term", 1L, "_version", 2L, "field1", "value1") + ); + + DocumentLookupService service = new GetService(executor).documentLookupService(resolver); + DocumentLookupResult result = service.getById("doc1", mockReader, INDEX); + + assertTrue("doc should be found", result.exists()); + assertEquals("doc1", result.id()); + assertEquals(5L, result.seqNo()); + assertEquals(2L, result.version()); + } + + public void testGetById_executorReturnsNull_throwsISE() throws IOException { + DocumentMetadataResolver resolver = mock(DocumentMetadataResolver.class); + DocumentMetadata metadata = new DocumentMetadata("doc1", 0L, 1L); + when(resolver.resolveMetadata(any(), eq("doc1"))).thenReturn(metadata); + + DocumentRowReader executor = mock(DocumentRowReader.class); + IndexReaderProvider.Reader mockReader = mock(IndexReaderProvider.Reader.class); + WriterFileSet pset = new WriterFileSet("/dir", 1L, Set.of("f.parquet"), 1L, 100L); + CatalogSnapshot snapshot = mock(CatalogSnapshot.class); + when(mockReader.catalogSnapshot()).thenReturn(snapshot); + when(snapshot.findFileSet("parquet", 1L)).thenReturn(pset); + when(executor.formatName()).thenReturn("parquet"); + when(executor.executeSingleRow(0L, pset)).thenReturn(null); + + DocumentLookupService service = new GetService(executor).documentLookupService(resolver); + expectThrows(IllegalStateException.class, () -> service.getById("doc1", mockReader, INDEX)); + } + + public void testGetById_fileSetNull_throwsISE() throws IOException { + DocumentMetadataResolver resolver = mock(DocumentMetadataResolver.class); + DocumentMetadata metadata = new DocumentMetadata("doc1", 0L, 1L); + when(resolver.resolveMetadata(any(), eq("doc1"))).thenReturn(metadata); + + IndexReaderProvider.Reader mockReader = mock(IndexReaderProvider.Reader.class); + CatalogSnapshot snapshot = mock(CatalogSnapshot.class); + when(mockReader.catalogSnapshot()).thenReturn(snapshot); + when(snapshot.findFileSet("parquet", 1L)).thenReturn(null); + when(mockExecutor.formatName()).thenReturn("parquet"); + + DocumentLookupService service = new GetService(mockExecutor).documentLookupService(resolver); + expectThrows(IllegalStateException.class, () -> service.getById("doc1", mockReader, INDEX)); + } + + public void testGetById_resolverThrows_propagates() throws IOException { + DocumentMetadataResolver resolver = mock(DocumentMetadataResolver.class); + when(resolver.resolveMetadata(any(), eq("doc1"))).thenThrow(new IOException("reader closed")); + + IndexReaderProvider.Reader mockReader = mock(IndexReaderProvider.Reader.class); + DocumentLookupService service = new GetService(mockExecutor).documentLookupService(resolver); + expectThrows(IOException.class, () -> service.getById("doc1", mockReader, INDEX)); + } + + public void testExtractLong_number() throws Exception { + assertEquals(42L, invokeExtractLong(Map.of("k", 42), "k", -1L)); + assertEquals(42L, invokeExtractLong(Map.of("k", 42L), "k", -1L)); + assertEquals(42L, invokeExtractLong(Map.of("k", 42.9), "k", -1L)); + } + + public void testExtractLong_string() throws Exception { + assertEquals(99L, invokeExtractLong(Map.of("k", "99"), "k", -1L)); + } + + public void testExtractLong_missing_returnsFallback() throws Exception { + assertEquals(-1L, invokeExtractLong(Map.of(), "k", -1L)); + } + + public void testExtractLong_invalidString_returnsFallback() throws Exception { + assertEquals(-1L, invokeExtractLong(Map.of("k", "abc"), "k", -1L)); + } + + public void testNoopResolver_returnsNull() throws IOException { + assertNull(DocumentMetadataResolver.NOOP.resolveMetadata(mock(IndexReaderProvider.Reader.class), "any")); + } + + private static long invokeExtractLong(Map row, String key, long fallback) { + return DocumentLookupService.extractLong(row, key, fallback); + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/NativeBridgeLocalSessionTests.java b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/NativeBridgeLocalSessionTests.java index c80e2434c5908..fec13a83b8ca5 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/NativeBridgeLocalSessionTests.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/NativeBridgeLocalSessionTests.java @@ -53,7 +53,7 @@ public class NativeBridgeLocalSessionTests extends OpenSearchTestCase { private NativeRuntimeHandle createRuntime() { NativeBridge.initTokioRuntimeManager(2); Path spillDir = createTempDir("datafusion-spill"); - long runtimePtr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024); + long runtimePtr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024, false, 0L, "lru"); assertTrue("runtime ptr non-zero", runtimePtr != 0); return new NativeRuntimeHandle(runtimePtr); } diff --git a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/NativeBridgePreparedPlanTests.java b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/NativeBridgePreparedPlanTests.java index b605fb3bc6527..f542ed012cd72 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/NativeBridgePreparedPlanTests.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/NativeBridgePreparedPlanTests.java @@ -42,7 +42,7 @@ public void testExecuteLocalPreparedPlanRejectsNullPointer() { public void testPrepareFinalPlanWithInvalidBytesThrowsDecodeError() { NativeBridge.initTokioRuntimeManager(2); Path spillDir = createTempDir("datafusion-spill"); - long runtimePtr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024); + long runtimePtr = NativeBridge.createGlobalRuntime(64 * 1024 * 1024, 0L, spillDir.toString(), 32 * 1024 * 1024, false, 0L, "lru"); NativeRuntimeHandle runtimeHandle = new NativeRuntimeHandle(runtimePtr); DatafusionLocalSession session = new DatafusionLocalSession(runtimeHandle.get()); try { diff --git a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/NativeErrorConverterTests.java b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/NativeErrorConverterTests.java index a80a80321ef80..bd985222bd28b 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/NativeErrorConverterTests.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/NativeErrorConverterTests.java @@ -30,7 +30,29 @@ public void testPoolLimitExceededConvertsToCircuitBreakingException() { assertEquals(4294967296L, cbe.getByteLimit()); assertEquals(CircuitBreaker.Durability.TRANSIENT, cbe.getDurability()); assertEquals("[analytics_backend_datafusion] Failed to allocate 1048576 bytes (limit: 4294967296)", cbe.getMessage()); - assertSame(original, cbe.getCause()); + // The raw native error (with allocator internals) must NOT be attached as the user-facing cause. + assertNull("converted exception must carry no cause (raw native detail stays server-side)", cbe.getCause()); + assertNoNativeLeak(cbe); + } + + /** Asserts the rendered exception (message + cause chain) carries no raw native allocator internals. */ + private static void assertNoNativeLeak(Throwable t) { + StringBuilder sb = new StringBuilder(); + for (Throwable c = t; c != null && c != c.getCause(); c = c.getCause()) { + sb.append(c.getClass().getName()).append(':').append(c.getMessage()).append('\n'); + } + String rendered = sb.toString(); + for (String leak : new String[] { + "top memory consumers", + "query_untracked", + "can spill", + "already reserved", + "GroupedHashAggregateStream", + "RepartitionExec", + "batch_size", + "avg_row_bytes" }) { + assertFalse("must not leak native detail '" + leak + "' to the user, got: " + rendered, rendered.contains(leak)); + } } public void testPoolLimitExceededUsesControlledMessage() { @@ -55,7 +77,8 @@ public void testAdmissionRejectionConvertsToStatusException() { OpenSearchStatusException statusEx = (OpenSearchStatusException) result; assertEquals(RestStatus.TOO_MANY_REQUESTS, statusEx.status()); assertEquals("Native query admission rejected: insufficient memory budget available", result.getMessage()); - assertSame(original, result.getCause()); + assertNull("converted exception must carry no cause (raw native detail stays server-side)", result.getCause()); + assertNoNativeLeak(result); } public void testCriticalPressureCancellationConvertsToCircuitBreakingException() { @@ -72,7 +95,8 @@ public void testCriticalPressureCancellationConvertsToCircuitBreakingException() assertEquals(65536L, cbe.getBytesWanted()); assertEquals(4294967296L, cbe.getByteLimit()); assertEquals(CircuitBreaker.Durability.TRANSIENT, cbe.getDurability()); - assertSame(original, cbe.getCause()); + assertNull("converted exception must carry no cause (raw native detail stays server-side)", cbe.getCause()); + assertNoNativeLeak(cbe); } public void testSpillPoolExhaustedConvertsToCircuitBreakingException() { @@ -86,7 +110,28 @@ public void testSpillPoolExhaustedConvertsToCircuitBreakingException() { assertTrue(result instanceof CircuitBreakingException); CircuitBreakingException cbe = (CircuitBreakingException) result; assertEquals(CircuitBreaker.Durability.TRANSIENT, cbe.getDurability()); - assertSame(original, cbe.getCause()); + assertNull("converted exception must carry no cause (raw native detail stays server-side)", cbe.getCause()); + assertNoNativeLeak(cbe); + } + + /** + * The real staging TopK message: a controlled "[analytics_backend_datafusion] Failed to allocate N bytes + * (limit: L)" with the verbose native allocator dump appended. The converted exception must keep the clean + * numeric message and NOT echo the consumer breakdown anywhere in its rendered chain. + */ + public void testTopKConsumerDumpIsSanitized() { + String message = "[analytics_backend_datafusion] Failed to allocate 307848 bytes (limit: 27673548029)\n" + + "Execution error: Resources exhausted: Additional allocation failed for TopK[0] with top memory consumers " + + "(across reservations) as:\n query_untracked(partitions=4,batch=8192)#49492(can spill: true) consumed 20.7 MB, " + + "peak 20.7 MB.\nError: Failed to allocate 307848 bytes for TopK[0] (0 already reserved) " + + "— 0 available out of 27673548029 limit"; + RuntimeException original = new RuntimeException(message); + + Exception result = NativeErrorConverter.convert(original); + + assertTrue(result instanceof CircuitBreakingException); + assertEquals("[analytics_backend_datafusion] Failed to allocate 307848 bytes (limit: 27673548029)", result.getMessage()); + assertNoNativeLeak(result); } public void testRawRecursionLimitConvertsToIllegalArgumentException() { diff --git a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/WireConfigSnapshotTests.java b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/WireConfigSnapshotTests.java index 3bd9a927cfe0d..40beaa0d0f6ee 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/WireConfigSnapshotTests.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/WireConfigSnapshotTests.java @@ -17,21 +17,16 @@ public class WireConfigSnapshotTests extends OpenSearchTestCase { public void testByteSize() { - assertEquals(80L, WireConfigSnapshot.BYTE_SIZE); + assertEquals(52L, WireConfigSnapshot.BYTE_SIZE); } public void testWriteToWritesCorrectValuesAtCorrectOffsets() { WireConfigSnapshot snapshot = WireConfigSnapshot.builder() .batchSize(8192) .targetPartitions(4) - .parquetPushdownFilters(true) + .listingTablePushdownFilters(true) .minSkipRunDefault(1024) .minSkipRunSelectivityThreshold(0.03) - .maxCollectorParallelism(4) - .singleCollectorStrategy(2) - .treeCollectorStrategy(1) - .queryStrategy(2) - .indexedDynamicFilterPushdown(true) .build(); try (Arena arena = Arena.ofConfined()) { @@ -42,17 +37,12 @@ public void testWriteToWritesCorrectValuesAtCorrectOffsets() { assertEquals(4L, segment.get(ValueLayout.JAVA_LONG, 8)); assertEquals(1024L, segment.get(ValueLayout.JAVA_LONG, 16)); assertEquals(0.03, segment.get(ValueLayout.JAVA_DOUBLE, 24), 1e-15); - assertEquals(1, segment.get(ValueLayout.JAVA_INT, 32)); // parquet_pushdown = true - assertEquals(4, segment.get(ValueLayout.JAVA_INT, 56)); // max_collector_parallelism - assertEquals(2, segment.get(ValueLayout.JAVA_INT, 60)); // single_collector_strategy - assertEquals(1, segment.get(ValueLayout.JAVA_INT, 64)); // tree_collector_strategy - assertEquals(2, segment.get(ValueLayout.JAVA_INT, 68)); // query_strategy = IndexedPredicateOnly - assertEquals(1, segment.get(ValueLayout.JAVA_INT, 76)); // indexed_dynamic_filter_pushdown = true + assertEquals(1, segment.get(ValueLayout.JAVA_INT, 32)); // listing_table_pushdown = true } } - public void testWriteToWritesParquetPushdownFalseAsZero() { - WireConfigSnapshot snapshot = WireConfigSnapshot.builder().parquetPushdownFilters(false).build(); + public void testWriteToWritesListingTablePushdownFalseAsZero() { + WireConfigSnapshot snapshot = WireConfigSnapshot.builder().listingTablePushdownFilters(false).build(); try (Arena arena = Arena.ofConfined()) { MemorySegment segment = arena.allocate(WireConfigSnapshot.BYTE_SIZE); @@ -63,17 +53,41 @@ public void testWriteToWritesParquetPushdownFalseAsZero() { } public void testHardcodedFieldsAreWrittenCorrectly() { - WireConfigSnapshot snapshot = WireConfigSnapshot.builder().batchSize(16384).targetPartitions(8).maxCollectorParallelism(6).build(); + WireConfigSnapshot snapshot = WireConfigSnapshot.builder().batchSize(16384).targetPartitions(8).build(); try (Arena arena = Arena.ofConfined()) { MemorySegment segment = arena.allocate(WireConfigSnapshot.BYTE_SIZE); snapshot.writeTo(segment); - assertEquals(1, segment.get(ValueLayout.JAVA_INT, 36)); // indexed_pushdown_filters - assertEquals(-1, segment.get(ValueLayout.JAVA_INT, 40)); // force_strategy - assertEquals(-1, segment.get(ValueLayout.JAVA_INT, 44)); // force_pushdown - assertEquals(1, segment.get(ValueLayout.JAVA_INT, 48)); // cost_predicate (hardcoded) - assertEquals(10, segment.get(ValueLayout.JAVA_INT, 52)); // cost_collector (hardcoded) + assertEquals(1, segment.get(ValueLayout.JAVA_INT, 36)); // indexed_pushdown_filters default (true) + assertEquals(-1, segment.get(ValueLayout.JAVA_INT, 40)); // force_strategy default (None) + assertEquals(1, segment.get(ValueLayout.JAVA_INT, 44)); // cost_predicate (hardcoded) + assertEquals(10, segment.get(ValueLayout.JAVA_INT, 48)); // cost_collector (hardcoded) + } + } + + public void testIndexedPushdownFiltersIsWrittenFromSnapshot() { + for (boolean v : new boolean[] { true, false }) { + WireConfigSnapshot snapshot = WireConfigSnapshot.builder().indexedPushdownFilters(v).build(); + try (Arena arena = Arena.ofConfined()) { + MemorySegment segment = arena.allocate(WireConfigSnapshot.BYTE_SIZE); + snapshot.writeTo(segment); + assertEquals(v ? 1 : 0, segment.get(ValueLayout.JAVA_INT, 36)); + } + assertEquals(v, snapshot.indexedPushdownFilters()); + } + } + + public void testForceStrategyIsWrittenFromSnapshot() { + // 0 = RowSelection, 1 = BooleanMask, -1 = None + for (int wire : new int[] { -1, 0, 1 }) { + WireConfigSnapshot snapshot = WireConfigSnapshot.builder().forceStrategy(wire).build(); + try (Arena arena = Arena.ofConfined()) { + MemorySegment segment = arena.allocate(WireConfigSnapshot.BYTE_SIZE); + snapshot.writeTo(segment); + assertEquals(wire, segment.get(ValueLayout.JAVA_INT, 40)); + } + assertEquals(wire, snapshot.forceStrategy()); } } @@ -82,44 +96,31 @@ public void testBuilderDefaultsMatchExpected() { assertEquals(8192, snapshot.batchSize()); assertEquals(4, snapshot.targetPartitions()); - assertEquals(false, snapshot.parquetPushdownFilters()); - assertEquals(true, snapshot.bloomFilterOnRead()); + assertEquals(false, snapshot.listingTablePushdownFilters()); assertEquals(1024, snapshot.minSkipRunDefault()); assertEquals(0.03, snapshot.minSkipRunSelectivityThreshold(), 1e-15); - assertEquals(1, snapshot.maxCollectorParallelism()); - assertEquals(2, snapshot.singleCollectorStrategy()); // page_range_split - assertEquals(1, snapshot.treeCollectorStrategy()); // tighten_outer_bounds - assertEquals(2, snapshot.queryStrategy()); // IndexedPredicateOnly - assertEquals(true, snapshot.indexedDynamicFilterPushdown()); // on by default + assertEquals(true, snapshot.indexedPushdownFilters()); } public void testBuilderCopyPreservesAllFields() { WireConfigSnapshot original = WireConfigSnapshot.builder() .batchSize(4096) .targetPartitions(16) - .parquetPushdownFilters(true) - .bloomFilterOnRead(false) + .listingTablePushdownFilters(true) .minSkipRunDefault(512) .minSkipRunSelectivityThreshold(0.5) - .maxCollectorParallelism(8) - .singleCollectorStrategy(0) - .treeCollectorStrategy(2) - .queryStrategy(1) - .indexedDynamicFilterPushdown(false) + .indexedPushdownFilters(false) + .forceStrategy(1) .build(); WireConfigSnapshot copy = WireConfigSnapshot.builder(original).build(); assertEquals(original.batchSize(), copy.batchSize()); assertEquals(original.targetPartitions(), copy.targetPartitions()); - assertEquals(original.parquetPushdownFilters(), copy.parquetPushdownFilters()); - assertEquals(original.bloomFilterOnRead(), copy.bloomFilterOnRead()); + assertEquals(original.listingTablePushdownFilters(), copy.listingTablePushdownFilters()); assertEquals(original.minSkipRunDefault(), copy.minSkipRunDefault()); assertEquals(original.minSkipRunSelectivityThreshold(), copy.minSkipRunSelectivityThreshold(), 0.0); - assertEquals(original.maxCollectorParallelism(), copy.maxCollectorParallelism()); - assertEquals(original.singleCollectorStrategy(), copy.singleCollectorStrategy()); - assertEquals(original.treeCollectorStrategy(), copy.treeCollectorStrategy()); - assertEquals(original.queryStrategy(), copy.queryStrategy()); - assertEquals(original.indexedDynamicFilterPushdown(), copy.indexedDynamicFilterPushdown()); + assertEquals(original.indexedPushdownFilters(), copy.indexedPushdownFilters()); + assertEquals(original.forceStrategy(), copy.forceStrategy()); } } diff --git a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/action/stats/ClearCacheRequestTests.java b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/action/stats/ClearCacheRequestTests.java new file mode 100644 index 0000000000000..6f69f7021a2bb --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/action/stats/ClearCacheRequestTests.java @@ -0,0 +1,137 @@ +/* + * 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.be.datafusion.action.stats; + +import org.opensearch.common.io.stream.BytesStreamOutput; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.test.OpenSearchTestCase; + +import java.io.IOException; + +/** + * Unit tests for {@link ClearCacheNodesRequest} and {@link ClearCacheNodeRequest}. + * Verifies flag semantics, isClearAll() logic, and round-trip serialization. + */ +public class ClearCacheRequestTests extends OpenSearchTestCase { + + // ── ClearCacheNodesRequest ──────────────────────────────────────────────── + + public void testDefaultNodesRequestIsClearAll() { + ClearCacheNodesRequest req = new ClearCacheNodesRequest(); + assertFalse(req.isFooter()); + assertFalse(req.isColumn()); + assertFalse(req.isOffset()); + assertFalse(req.isStatistics()); + assertTrue("no flags set → isClearAll()", req.isClearAll()); + } + + public void testSetFooterOnlyIsNotClearAll() { + ClearCacheNodesRequest req = new ClearCacheNodesRequest(); + req.setFooter(true); + assertTrue(req.isFooter()); + assertFalse(req.isColumn()); + assertFalse(req.isOffset()); + assertFalse(req.isStatistics()); + assertFalse("footer=true → not clear-all", req.isClearAll()); + } + + public void testSetColumnOnlyIsNotClearAll() { + ClearCacheNodesRequest req = new ClearCacheNodesRequest(); + req.setColumn(true); + assertFalse(req.isFooter()); + assertTrue(req.isColumn()); + assertFalse(req.isOffset()); + assertFalse(req.isStatistics()); + assertFalse(req.isClearAll()); + } + + public void testSetOffsetOnlyIsNotClearAll() { + ClearCacheNodesRequest req = new ClearCacheNodesRequest(); + req.setOffset(true); + assertFalse(req.isFooter()); + assertFalse(req.isColumn()); + assertTrue(req.isOffset()); + assertFalse(req.isStatistics()); + assertFalse(req.isClearAll()); + } + + public void testSetStatisticsOnlyIsNotClearAll() { + ClearCacheNodesRequest req = new ClearCacheNodesRequest(); + req.setStatistics(true); + assertFalse(req.isFooter()); + assertFalse(req.isColumn()); + assertFalse(req.isOffset()); + assertTrue(req.isStatistics()); + assertFalse(req.isClearAll()); + } + + public void testAllFlagsSetIsNotClearAll() { + ClearCacheNodesRequest req = new ClearCacheNodesRequest(); + req.setFooter(true); + req.setColumn(true); + req.setOffset(true); + req.setStatistics(true); + assertFalse(req.isClearAll()); + } + + public void testNodesRequestRoundTrip() throws IOException { + ClearCacheNodesRequest original = new ClearCacheNodesRequest(); + original.setFooter(true); + original.setColumn(false); + original.setOffset(true); + original.setStatistics(true); + + BytesStreamOutput out = new BytesStreamOutput(); + original.writeTo(out); + StreamInput in = out.bytes().streamInput(); + ClearCacheNodesRequest deserialized = new ClearCacheNodesRequest(in); + + assertEquals(original.isFooter(), deserialized.isFooter()); + assertEquals(original.isColumn(), deserialized.isColumn()); + assertEquals(original.isOffset(), deserialized.isOffset()); + assertEquals(original.isStatistics(), deserialized.isStatistics()); + assertEquals(original.isClearAll(), deserialized.isClearAll()); + } + + // ── ClearCacheNodeRequest ───────────────────────────────────────────────── + + public void testNodeRequestIsClearAllWhenNoFlagsSet() { + ClearCacheNodeRequest req = new ClearCacheNodeRequest(false, false, false, false); + assertTrue(req.isClearAll()); + } + + public void testNodeRequestIsNotClearAllWhenFlagSet() { + ClearCacheNodeRequest req = new ClearCacheNodeRequest(true, false, false, false); + assertFalse(req.isClearAll()); + } + + public void testNodeRequestStatisticsFlag() { + ClearCacheNodeRequest req = new ClearCacheNodeRequest(false, false, false, true); + assertFalse(req.isFooter()); + assertFalse(req.isColumn()); + assertFalse(req.isOffset()); + assertTrue(req.isStatistics()); + assertFalse(req.isClearAll()); + } + + public void testNodeRequestRoundTrip() throws IOException { + ClearCacheNodeRequest original = new ClearCacheNodeRequest(false, true, true, true); + + BytesStreamOutput out = new BytesStreamOutput(); + original.writeTo(out); + StreamInput in = out.bytes().streamInput(); + ClearCacheNodeRequest deserialized = new ClearCacheNodeRequest(in); + + assertFalse(deserialized.isFooter()); + assertTrue(deserialized.isColumn()); + assertTrue(deserialized.isOffset()); + assertTrue(deserialized.isStatistics()); + assertFalse(deserialized.isClearAll()); + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/action/stats/RestClearCacheActionTests.java b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/action/stats/RestClearCacheActionTests.java new file mode 100644 index 0000000000000..9a16fb1386588 --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/action/stats/RestClearCacheActionTests.java @@ -0,0 +1,142 @@ +/* + * 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.be.datafusion.action.stats; + +import org.opensearch.action.ActionRequest; +import org.opensearch.action.ActionType; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.action.ActionResponse; +import org.opensearch.rest.RestHandler.Route; +import org.opensearch.test.OpenSearchTestCase; +import org.opensearch.test.client.NoOpNodeClient; +import org.opensearch.test.rest.FakeRestChannel; +import org.opensearch.test.rest.FakeRestRequest; +import org.opensearch.transport.client.node.NodeClient; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.opensearch.rest.RestRequest.Method.POST; + +/** + * Unit tests for {@link RestClearCacheAction}. + */ +public class RestClearCacheActionTests extends OpenSearchTestCase { + + private RestClearCacheAction action; + + @Override + public void setUp() throws Exception { + super.setUp(); + action = new RestClearCacheAction(); + } + + public void testSinglePostRoute() { + List routes = action.routes(); + assertEquals(1, routes.size()); + assertEquals(POST, routes.get(0).getMethod()); + assertEquals("/_plugins/_analytics_backend_datafusion/cache/_clear", routes.get(0).getPath()); + } + + public void testName() { + assertEquals("datafusion_clear_cache_action", action.getName()); + } + + public void testDoesNotTripCircuitBreaker() { + assertFalse(action.canTripCircuitBreaker()); + } + + public void testNoParamsSendsClearAllRequest() throws Exception { + ClearCacheNodesRequest req = captureRequest(Map.of()); + assertFalse(req.isFooter()); + assertFalse(req.isColumn()); + assertFalse(req.isOffset()); + assertFalse(req.isStatistics()); + assertTrue("no params → isClearAll()", req.isClearAll()); + } + + public void testFooterParamSetsFooterFlag() throws Exception { + ClearCacheNodesRequest req = captureRequest(Map.of("footer", "true")); + assertTrue(req.isFooter()); + assertFalse(req.isColumn()); + assertFalse(req.isOffset()); + assertFalse(req.isStatistics()); + assertFalse(req.isClearAll()); + } + + public void testColumnParamSetsColumnFlag() throws Exception { + ClearCacheNodesRequest req = captureRequest(Map.of("column", "true")); + assertFalse(req.isFooter()); + assertTrue(req.isColumn()); + assertFalse(req.isOffset()); + assertFalse(req.isStatistics()); + assertFalse(req.isClearAll()); + } + + public void testOffsetParamSetsOffsetFlag() throws Exception { + ClearCacheNodesRequest req = captureRequest(Map.of("offset", "true")); + assertFalse(req.isFooter()); + assertFalse(req.isColumn()); + assertTrue(req.isOffset()); + assertFalse(req.isStatistics()); + assertFalse(req.isClearAll()); + } + + public void testStatisticsParamSetsStatisticsFlag() throws Exception { + ClearCacheNodesRequest req = captureRequest(Map.of("statistics", "true")); + assertFalse(req.isFooter()); + assertFalse(req.isColumn()); + assertFalse(req.isOffset()); + assertTrue(req.isStatistics()); + assertFalse(req.isClearAll()); + } + + public void testMultipleParamsSetsMultipleFlags() throws Exception { + ClearCacheNodesRequest req = captureRequest(Map.of("column", "true", "offset", "true")); + assertFalse(req.isFooter()); + assertTrue(req.isColumn()); + assertTrue(req.isOffset()); + assertFalse(req.isStatistics()); + assertFalse(req.isClearAll()); + } + + public void testFalseParamDoesNotSetFlag() throws Exception { + ClearCacheNodesRequest req = captureRequest(Map.of("footer", "false", "column", "true")); + assertFalse(req.isFooter()); + assertTrue(req.isColumn()); + assertFalse(req.isClearAll()); + } + + @SuppressWarnings("unchecked") + private ClearCacheNodesRequest captureRequest(Map params) throws Exception { + AtomicReference captured = new AtomicReference<>(); + try (NodeClient client = new NoOpNodeClient(getTestName()) { + @Override + public void doExecute( + ActionType actionType, + Request request, + ActionListener listener + ) { + if (request instanceof ClearCacheNodesRequest) { + captured.set((ClearCacheNodesRequest) request); + } + } + }) { + FakeRestRequest restRequest = new FakeRestRequest.Builder(xContentRegistry()).withPath( + "/_plugins/_analytics_backend_datafusion/cache/_clear" + ).withParams(new HashMap<>(params)).build(); + FakeRestChannel channel = new FakeRestChannel(restRequest, false, 1); + action.handleRequest(restRequest, channel, client); + } + assertNotNull("request must have been captured", captured.get()); + return captured.get(); + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/cache/CacheSettingsPercentValidationTests.java b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/cache/CacheSettingsPercentValidationTests.java new file mode 100644 index 0000000000000..332556ceeb99f --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/cache/CacheSettingsPercentValidationTests.java @@ -0,0 +1,112 @@ +/* + * 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.be.datafusion.cache; + +import org.opensearch.test.OpenSearchTestCase; + +/** + * Tests for {@link CacheSettings#validatePercentSum} and + * {@link CacheSettings#computeCacheSizes}. + */ +public class CacheSettingsPercentValidationTests extends OpenSearchTestCase { + + // ── validatePercentSum ──────────────────────────────────────────────────── + + public void testDefaultsSumTo100() { + // Defaults: 48 + 34 + 13 + 5 = 100 + CacheSettings.validatePercentSum(48, 34, 13, 5); + } + + public void testSumExactly100Passes() { + CacheSettings.validatePercentSum(60, 25, 10, 5); + CacheSettings.validatePercentSum(33, 34, 28, 5); + CacheSettings.validatePercentSum(1, 1, 97, 1); + } + + public void testSumUnder100PassesHeadroomAccepted() { + CacheSettings.validatePercentSum(48, 34, 13, 1); // 96% — 4% unused + CacheSettings.validatePercentSum(40, 30, 10, 5); // 85% — headroom OK + CacheSettings.validatePercentSum(1, 1, 1, 1); // 4% — valid + } + + public void testSinglePercentRaisedAloneBreaks() { + IllegalArgumentException ex = expectThrows( + IllegalArgumentException.class, + () -> CacheSettings.validatePercentSum(48, 34, 13, 15) // 110 + ); + assertTrue("error must mention sum", ex.getMessage().contains("sum=110")); + assertTrue("error must name statistics key", ex.getMessage().contains("statistics_percent")); + } + + public void testSumOver100Throws() { + IllegalArgumentException ex = expectThrows( + IllegalArgumentException.class, + () -> CacheSettings.validatePercentSum(50, 35, 20, 5) // 110 + ); + assertTrue("error must mention sum", ex.getMessage().contains("sum=110")); + } + + public void testAtomicUpdateAllFourPasses() { + CacheSettings.validatePercentSum(45, 33, 12, 5); // 95% — headroom allowed + CacheSettings.validatePercentSum(40, 40, 15, 5); // 100% — exact + } + + public void testErrorMessageNamesAllKeys() { + IllegalArgumentException ex = expectThrows( + IllegalArgumentException.class, + () -> CacheSettings.validatePercentSum(48, 34, 13, 10) // 105 + ); + assertTrue(ex.getMessage().contains("footer_metadata_percent")); + assertTrue(ex.getMessage().contains("offset_index_percent")); + assertTrue(ex.getMessage().contains("column_index_percent")); + assertTrue(ex.getMessage().contains("statistics_percent")); + assertTrue(ex.getMessage().contains("sum=105")); + } + + // ── computeCacheSizes ───────────────────────────────────────────────────── + + public void testComputeSizesDefaultSplit() { + long total = 1_150_000_000L; // ~1.15 GB sample metadata-index cache total budget + long[] sizes = CacheSettings.computeCacheSizes(48, 34, 13, 5, total); + assertEquals(4, sizes.length); + assertEquals(total * 48 / 100, sizes[0]); // footer ~552 MB + assertEquals(total * 34 / 100, sizes[1]); // offset index ~391 MB + assertEquals(total * 13 / 100, sizes[2]); // column index ~150 MB + assertEquals(total * 5 / 100, sizes[3]); // statistics ~58 MB + } + + public void testComputeSizesStatisticsNotZero() { + long total = 1_000_000L; + long[] sizes = CacheSettings.computeCacheSizes(48, 34, 13, 5, total); + assertTrue("statistics cache must get non-zero bytes", sizes[3] > 0); + assertEquals(50_000L, sizes[3]); // 5% of 1_000_000 + } + + public void testComputeSizesZeroTotalGivesZeros() { + long[] sizes = CacheSettings.computeCacheSizes(48, 34, 13, 5, 0L); + for (long s : sizes) + assertEquals(0L, s); + } + + public void testComputeSizesSumWithinBudget() { + long total = 1_000_000L; + long[] sizes = CacheSettings.computeCacheSizes(48, 34, 13, 5, total); + long sum = sizes[0] + sizes[1] + sizes[2] + sizes[3]; + assertTrue("sum must not exceed total", sum <= total); + // 48+34+13+5=100, integer division may lose up to 3 bytes + assertTrue("sum must be close to total", sum >= total - 3); + } + + public void testStatisticsUsesPercentDerivedLimit() { + // Statistics bytes come from the unified percent budget, not a standalone hardcoded limit. + long total = 1_000_000L; + long[] sizes = CacheSettings.computeCacheSizes(48, 34, 13, 5, total); + assertEquals("statistics cache must use percent-derived limit", 50_000L, sizes[3]); + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/nativelib/StatsLayoutPropertyTests.java b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/nativelib/StatsLayoutPropertyTests.java index 4fd7679ce5b9b..3327d2d9f4863 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/nativelib/StatsLayoutPropertyTests.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/nativelib/StatsLayoutPropertyTests.java @@ -35,7 +35,7 @@ public class StatsLayoutPropertyTests extends OpenSearchTestCase { private static final int TRIES = 100; - private static final int FIELD_COUNT = 75; + private static final int FIELD_COUNT = 85; // ---- Generators ---- @@ -187,26 +187,36 @@ public void testPackThenDecodeRoundTripWithCpu() { assertEquals(values[55], cs.getStatisticsCache().entryCount); assertEquals(values[56], cs.getStatisticsCache().memoryBytes); assertEquals(values[57], cs.getStatisticsCache().sizeLimitBytes); - - // Search stats (offsets 58-74) + assertEquals(values[58], cs.getColumnIndexCache().hitCount); + assertEquals(values[59], cs.getColumnIndexCache().missCount); + assertEquals(values[60], cs.getColumnIndexCache().entryCount); + assertEquals(values[61], cs.getColumnIndexCache().memoryBytes); + assertEquals(values[62], cs.getColumnIndexCache().sizeLimitBytes); + assertEquals(values[63], cs.getOffsetIndexCache().hitCount); + assertEquals(values[64], cs.getOffsetIndexCache().missCount); + assertEquals(values[65], cs.getOffsetIndexCache().entryCount); + assertEquals(values[66], cs.getOffsetIndexCache().memoryBytes); + assertEquals(values[67], cs.getOffsetIndexCache().sizeLimitBytes); + + // Search stats (offsets 68-84) var ss = StatsLayout.readSearchStats(seg); - assertEquals(values[58], ss.listingTableScan); - assertEquals(values[59], ss.singleCollectorScan); - assertEquals(values[60], ss.bitmapTreeScan); - assertEquals(values[61], ss.delegationCalls); - assertEquals(values[62], ss.rgProcessed); - assertEquals(values[63], ss.rgSkipped); - assertEquals(values[64], ss.parquetScanTotalTimeMs); - assertEquals(values[65], ss.parquetScanUntilDataTimeMs); - assertEquals(values[66], ss.parquetProcessingTimeMs); - assertEquals(values[67], ss.parquetBytesScanned); - assertEquals(values[68], ss.prefetchWaitTimeMs); - assertEquals(values[69], ss.prefetchWaitCount); - assertEquals(values[70], ss.elapsedComputeMs); - assertEquals(values[71], ss.buildMaskTimeMs); - assertEquals(values[72], ss.onBatchMaskTimeMs); - assertEquals(values[73], ss.filterRecordBatchTimeMs); - assertEquals(values[74], ss.objectStoreReadTimeMs); + assertEquals(values[68], ss.listingTableScan); + assertEquals(values[69], ss.singleCollectorScan); + assertEquals(values[70], ss.bitmapTreeScan); + assertEquals(values[71], ss.delegationCalls); + assertEquals(values[72], ss.rgProcessed); + assertEquals(values[73], ss.rgSkipped); + assertEquals(values[74], ss.parquetScanTotalTimeMs); + assertEquals(values[75], ss.parquetScanUntilDataTimeMs); + assertEquals(values[76], ss.parquetProcessingTimeMs); + assertEquals(values[77], ss.parquetBytesScanned); + assertEquals(values[78], ss.prefetchWaitTimeMs); + assertEquals(values[79], ss.prefetchWaitCount); + assertEquals(values[80], ss.elapsedComputeMs); + assertEquals(values[81], ss.buildMaskTimeMs); + assertEquals(values[82], ss.onBatchMaskTimeMs); + assertEquals(values[83], ss.filterRecordBatchTimeMs); + assertEquals(values[84], ss.objectStoreReadTimeMs); } } } @@ -328,6 +338,18 @@ public void testDecodeThenReencodeIdentity() { cs.getStatisticsCache().entryCount, cs.getStatisticsCache().memoryBytes, cs.getStatisticsCache().sizeLimitBytes, + // cache_stats.column_index_cache (5) + cs.getColumnIndexCache().hitCount, + cs.getColumnIndexCache().missCount, + cs.getColumnIndexCache().entryCount, + cs.getColumnIndexCache().memoryBytes, + cs.getColumnIndexCache().sizeLimitBytes, + // cache_stats.offset_index_cache (5) + cs.getOffsetIndexCache().hitCount, + cs.getOffsetIndexCache().missCount, + cs.getOffsetIndexCache().entryCount, + cs.getOffsetIndexCache().memoryBytes, + cs.getOffsetIndexCache().sizeLimitBytes, // search_stats (17) ss.listingTableScan, ss.singleCollectorScan, diff --git a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/nativelib/StatsLayoutTests.java b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/nativelib/StatsLayoutTests.java index 9a72c2af21a26..79aa25dd45295 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/nativelib/StatsLayoutTests.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/nativelib/StatsLayoutTests.java @@ -22,10 +22,10 @@ */ public class StatsLayoutTests extends OpenSearchTestCase { - /** 7.1: Layout byte size must be 600 (75 × 8). */ + /** 7.1: Layout byte size must be 640 (85 × 8). */ public void testLayoutByteSize() { - assertEquals(600L, StatsLayout.LAYOUT.byteSize()); - assertEquals(75 * Long.BYTES, (int) StatsLayout.LAYOUT.byteSize()); + assertEquals(680L, StatsLayout.LAYOUT.byteSize()); + assertEquals(85 * Long.BYTES, (int) StatsLayout.LAYOUT.byteSize()); } /** 7.2: readRuntimeMetrics decodes 9 known values from io_runtime group. */ diff --git a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/stats/CacheStatsTests.java b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/stats/CacheStatsTests.java index 77a839c377b9c..1c1796b30de54 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/stats/CacheStatsTests.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/stats/CacheStatsTests.java @@ -90,21 +90,33 @@ public void testCacheGroupStatsEqualsAndHashCode() { // ---- CacheStats ---- public void testCacheStatsConstructorRejectsNullSubGroups() { - expectThrows(NullPointerException.class, () -> new CacheStats(null, new CacheGroupStats(0, 0, 0, 0, 0))); - expectThrows(NullPointerException.class, () -> new CacheStats(new CacheGroupStats(0, 0, 0, 0, 0), null)); + CacheGroupStats g = new CacheGroupStats(0, 0, 0, 0, 0); + expectThrows(NullPointerException.class, () -> new CacheStats(null, g, g, g)); + expectThrows(NullPointerException.class, () -> new CacheStats(g, null, g, g)); + expectThrows(NullPointerException.class, () -> new CacheStats(g, g, null, g)); + expectThrows(NullPointerException.class, () -> new CacheStats(g, g, g, null)); } public void testCacheStatsAccessors() { CacheGroupStats meta = new CacheGroupStats(1, 2, 3, 4, 5); CacheGroupStats stats = new CacheGroupStats(6, 7, 8, 9, 10); - CacheStats c = new CacheStats(meta, stats); + CacheGroupStats scoped = new CacheGroupStats(11, 12, 13, 14, 15); + CacheGroupStats offset = new CacheGroupStats(11, 12, 13, 14, 15); + CacheStats c = new CacheStats(meta, stats, scoped, offset); assertSame(meta, c.getMetadataCache()); assertSame(stats, c.getStatisticsCache()); + assertSame(scoped, c.getColumnIndexCache()); + assertSame(offset, c.getOffsetIndexCache()); } public void testCacheStatsWriteableRoundTrip() throws IOException { - CacheStats original = new CacheStats(new CacheGroupStats(11, 12, 13, 14, 15), new CacheGroupStats(21, 22, 23, 24, 25)); + CacheStats original = new CacheStats( + new CacheGroupStats(11, 12, 13, 14, 15), + new CacheGroupStats(21, 22, 23, 24, 25), + new CacheGroupStats(31, 32, 33, 34, 35), + new CacheGroupStats(41, 42, 43, 44, 45) + ); BytesStreamOutput out = new BytesStreamOutput(); original.writeTo(out); StreamInput in = out.bytes().streamInput(); @@ -113,7 +125,12 @@ public void testCacheStatsWriteableRoundTrip() throws IOException { } public void testCacheStatsToXContentShape() throws IOException { - CacheStats c = new CacheStats(new CacheGroupStats(11, 0, 3, 1024, 250_000_000), new CacheGroupStats(0, 7, 0, 0, 100_000_000)); + CacheStats c = new CacheStats( + new CacheGroupStats(11, 0, 3, 1024, 250_000_000), + new CacheGroupStats(0, 7, 0, 0, 100_000_000), + new CacheGroupStats(5, 1, 2, 512, 64_000_000), + new CacheGroupStats(5, 2, 3, 256, 32_000_000) + ); XContentBuilder builder = XContentFactory.jsonBuilder(); builder.startObject(); c.toXContent(builder, ToXContent.EMPTY_PARAMS); @@ -122,9 +139,11 @@ public void testCacheStatsToXContentShape() throws IOException { // Top-level wrapper assertTrue("expected cache_stats wrapper, got: " + json, json.contains("\"cache_stats\"")); - // Both sub-groups present + // All four sub-groups present assertTrue(json.contains("\"metadata_cache\"")); assertTrue(json.contains("\"statistics_cache\"")); + assertTrue(json.contains("\"column_index_cache\"")); + assertTrue(json.contains("\"offset_index_cache\"")); // Per-group fields assertTrue(json.contains("\"hit_count\":11")); assertTrue(json.contains("\"miss_count\":7")); @@ -132,27 +151,50 @@ public void testCacheStatsToXContentShape() throws IOException { assertTrue(json.contains("\"memory_bytes\":1024")); assertTrue(json.contains("\"size_limit_bytes\":250000000")); assertTrue(json.contains("\"size_limit_bytes\":100000000")); + assertTrue(json.contains("\"size_limit_bytes\":64000000")); + assertTrue(json.contains("\"size_limit_bytes\":32000000")); } public void testCacheStatsZeroedRendersAllZeros() throws IOException { - CacheStats zero = new CacheStats(new CacheGroupStats(0, 0, 0, 0, 0), new CacheGroupStats(0, 0, 0, 0, 0)); + CacheStats zero = new CacheStats( + new CacheGroupStats(0, 0, 0, 0, 0), + new CacheGroupStats(0, 0, 0, 0, 0), + new CacheGroupStats(0, 0, 0, 0, 0), + new CacheGroupStats(0, 0, 0, 0, 0) + ); XContentBuilder builder = XContentFactory.jsonBuilder(); builder.startObject(); zero.toXContent(builder, ToXContent.EMPTY_PARAMS); builder.endObject(); String json = builder.toString(); - // Disabled-cache sentinel: both size_limit_bytes are 0 + // Disabled-cache sentinel: all four size_limit_bytes are 0 long sizeLimitOccurrences = json.split("\"size_limit_bytes\":0", -1).length - 1; - assertEquals("expected size_limit_bytes:0 to appear twice (one per sub-cache)", 2, sizeLimitOccurrences); + assertEquals("expected size_limit_bytes:0 to appear four times (one per sub-cache)", 4, sizeLimitOccurrences); // hit_rate must not be NaN assertFalse("hit_rate must not be NaN: " + json, json.contains("NaN")); } public void testCacheStatsEqualsAndHashCode() { - CacheStats a = new CacheStats(new CacheGroupStats(1, 2, 3, 4, 5), new CacheGroupStats(6, 7, 8, 9, 10)); - CacheStats b = new CacheStats(new CacheGroupStats(1, 2, 3, 4, 5), new CacheGroupStats(6, 7, 8, 9, 10)); - CacheStats c = new CacheStats(new CacheGroupStats(1, 2, 3, 4, 5), new CacheGroupStats(6, 7, 8, 9, 99)); + // c differs only in statisticsCache (sizeLimitBytes 10 → 99) + CacheStats a = new CacheStats( + new CacheGroupStats(1, 2, 3, 4, 5), + new CacheGroupStats(6, 7, 8, 9, 10), + new CacheGroupStats(11, 12, 13, 14, 15), + new CacheGroupStats(16, 17, 18, 19, 20) + ); + CacheStats b = new CacheStats( + new CacheGroupStats(1, 2, 3, 4, 5), + new CacheGroupStats(6, 7, 8, 9, 10), + new CacheGroupStats(11, 12, 13, 14, 15), + new CacheGroupStats(16, 17, 18, 19, 20) + ); + CacheStats c = new CacheStats( + new CacheGroupStats(1, 2, 3, 4, 5), + new CacheGroupStats(6, 7, 8, 9, 99), + new CacheGroupStats(11, 12, 13, 14, 15), + new CacheGroupStats(16, 17, 18, 19, 20) + ); assertEquals(a, b); assertEquals(a.hashCode(), b.hashCode()); diff --git a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/stats/DataFusionStatsPropertyTests.java b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/stats/DataFusionStatsPropertyTests.java index 972deafcbdce7..a200058172870 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/stats/DataFusionStatsPropertyTests.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/stats/DataFusionStatsPropertyTests.java @@ -108,7 +108,7 @@ private CacheGroupStats randomCacheGroupStats() { } private CacheStats randomCacheStats() { - return new CacheStats(randomCacheGroupStats(), randomCacheGroupStats()); + return new CacheStats(randomCacheGroupStats(), randomCacheGroupStats(), randomCacheGroupStats(), randomCacheGroupStats()); } private SearchStats randomSearchStats() { diff --git a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/stats/DataFusionStatsTests.java b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/stats/DataFusionStatsTests.java index 075a48c5ebcc5..4ec614a50a051 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/stats/DataFusionStatsTests.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/stats/DataFusionStatsTests.java @@ -278,7 +278,9 @@ public void testCacheStatsPresentRendersIntoJson() throws IOException { CacheStats cache = new CacheStats( new CacheGroupStats(100, 5, 25, 4096, 250_000_000), - new CacheGroupStats(50, 0, 25, 2048, 100_000_000) + new CacheGroupStats(50, 0, 25, 2048, 100_000_000), + new CacheGroupStats(20, 3, 8, 1024, 64_000_000), + new CacheGroupStats(15, 2, 5, 512, 16_000_000) ); DataFusionStats stats = new DataFusionStats( new NativeExecutorsStats(io, null, taskMonitors), diff --git a/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/LuceneDocumentResolver.java b/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/LuceneDocumentResolver.java new file mode 100644 index 0000000000000..60ab0b41b7972 --- /dev/null +++ b/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/LuceneDocumentResolver.java @@ -0,0 +1,98 @@ +/* + * 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.be.lucene; + +import org.apache.lucene.index.DirectoryReader; +import org.apache.lucene.index.FilterLeafReader; +import org.apache.lucene.index.LeafReader; +import org.apache.lucene.index.LeafReaderContext; +import org.apache.lucene.index.SegmentReader; +import org.apache.lucene.index.SortedNumericDocValues; +import org.apache.lucene.index.Term; +import org.opensearch.common.annotation.ExperimentalApi; +import org.opensearch.common.lucene.uid.VersionsAndSeqNoResolver; +import org.opensearch.index.engine.dataformat.DocumentInput; +import org.opensearch.index.engine.exec.DocumentMetadataResolver; +import org.opensearch.index.engine.exec.IndexReaderProvider; +import org.opensearch.index.mapper.IdFieldMapper; +import org.opensearch.index.mapper.Uid; + +import java.io.IOException; + +/** + * Lucene-backed {@link DocumentMetadataResolver}: an {@code _id} lookup yields the row location + * ({@code rowId} from {@code __row_id__} doc values, {@code writerGeneration} from the segment + * attribute). Version fields are not read here. An empty reader or absent id reports "not found". + * + * @opensearch.experimental + */ +@ExperimentalApi +public final class LuceneDocumentResolver implements DocumentMetadataResolver { + + @Override + public DocumentMetadata resolveMetadata(IndexReaderProvider.Reader reader, String id) throws IOException { + DirectoryReader luceneReader = directoryReader(reader); + if (luceneReader.numDocs() == 0) { + return null; + } + LeafDoc located = locate(luceneReader, id); + if (located == null) { + return null; + } + LeafReader leaf = located.leaf().reader(); + int localDocId = located.localDocId(); + return new DocumentMetadata(id, readRowId(leaf, localDocId), readWriterGeneration(leaf)); + } + + private long readRowId(LeafReader leaf, int localDocId) throws IOException { + return value(leaf.getSortedNumericDocValues(DocumentInput.ROW_ID_FIELD), localDocId); + } + + private long readWriterGeneration(LeafReader leaf) { + LeafReader unwrapped = FilterLeafReader.unwrap(leaf); + if (!(unwrapped instanceof SegmentReader segmentReader)) { + throw new IllegalStateException("Expected SegmentReader leaf, got " + unwrapped.getClass()); + } + String genAttr = segmentReader.getSegmentInfo().info.getAttribute("writer_generation"); + if (genAttr == null) { + throw new IllegalStateException("Leaf segment missing writer_generation attribute"); + } + try { + return Long.parseLong(genAttr); + } catch (NumberFormatException e) { + throw new IllegalStateException("Invalid writer_generation attribute: [" + genAttr + "]", e); + } + } + + private long value(SortedNumericDocValues dv, int docId) throws IOException { + if (dv == null) { + throw new IllegalStateException("Leaf segment missing " + DocumentInput.ROW_ID_FIELD + " doc values"); + } + if (!dv.advanceExact(docId)) { + throw new IllegalStateException("docId " + docId + " has no " + DocumentInput.ROW_ID_FIELD + " value"); + } + return dv.nextValue(); + } + + private DirectoryReader directoryReader(IndexReaderProvider.Reader reader) { + LuceneReader luceneReader = reader.getReader(LucenePlugin.DATA_FORMAT, LuceneReader.class); + return luceneReader.directoryReader(); + } + + private LeafDoc locate(DirectoryReader luceneReader, String id) throws IOException { + Term term = new Term(IdFieldMapper.NAME, Uid.encodeId(id)); + // Reuse the core per-thread, per-segment _id lookup cache (newest-segment-first, liveDocs-aware). + // We only need the doc location here; version/seqNo/primaryTerm come from the parquet row. + VersionsAndSeqNoResolver.DocIdAndSeqNo hit = VersionsAndSeqNoResolver.loadDocId(luceneReader, term); + return hit == null ? null : new LeafDoc(hit.context, hit.docId); + } + + private record LeafDoc(LeafReaderContext leaf, int localDocId) { + } +} diff --git a/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/LucenePlugin.java b/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/LucenePlugin.java index 0fe0d1c5c0191..84b70054a85bc 100644 --- a/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/LucenePlugin.java +++ b/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/LucenePlugin.java @@ -36,7 +36,9 @@ import org.opensearch.index.engine.dataformat.IndexingEngineConfig; import org.opensearch.index.engine.dataformat.IndexingExecutionEngine; import org.opensearch.index.engine.dataformat.ReaderManagerConfig; +import org.opensearch.index.engine.exec.DocumentMetadataResolver; import org.opensearch.index.engine.exec.EngineReaderManager; +import org.opensearch.index.engine.exec.IndexReaderProvider; import org.opensearch.index.engine.exec.commit.Committer; import org.opensearch.index.engine.exec.commit.CommitterFactory; import org.opensearch.index.store.checksum.LuceneChecksumHandler; @@ -69,9 +71,16 @@ * @opensearch.experimental */ @ExperimentalApi -public class LucenePlugin extends Plugin implements DataFormatPlugin, SearchBackEndPlugin, EnginePlugin, ActionPlugin { +public class LucenePlugin extends Plugin + implements + DataFormatPlugin, + SearchBackEndPlugin, + EnginePlugin, + ActionPlugin, + DocumentMetadataResolver { public static final LuceneDataFormat DATA_FORMAT = new LuceneDataFormat(); + private final LuceneDocumentResolver documentResolver = new LuceneDocumentResolver(); /** Creates a new LucenePlugin. */ public LucenePlugin() {} @@ -195,4 +204,12 @@ public List getRestHandlers( ) { return List.of(new LuceneStatsRestAction(), new LuceneNodeStatsRestAction()); } + + // --- DocumentMetadataResolver --- + + /** {@inheritDoc} Resolves a document id to its row location via Lucene. */ + @Override + public DocumentMetadata resolveMetadata(IndexReaderProvider.Reader reader, String id) throws IOException { + return documentResolver.resolveMetadata(reader, id); + } } diff --git a/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/index/LuceneIndexingExecutionEngine.java b/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/index/LuceneIndexingExecutionEngine.java index 99e55702a0903..a64294324e336 100644 --- a/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/index/LuceneIndexingExecutionEngine.java +++ b/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/index/LuceneIndexingExecutionEngine.java @@ -451,6 +451,11 @@ public Map> deleteFiles(Map matchingSegments = findMatchingSegments(segmentInfos, generationsToMerge); @@ -133,6 +136,22 @@ public MergeResult merge(MergeInput mergeInput) throws IOException { ); } + if (matchingSegments.size() != generationsToMerge.size()) { + throw new IllegalStateException( + "Expected " + + generationsToMerge.size() + + " Lucene segments for generations " + + generationsToMerge + + " but found only " + + matchingSegments.size() + + ". Missing segments may have been consumed by a concurrent merge. " + + "Found generations: " + + matchingSegments.stream() + .map(sci -> sci.info.getAttribute(WRITER_GENERATION_ATTRIBUTE)) + .collect(java.util.stream.Collectors.toList()) + ); + } + logger.debug( "LuceneMerger: merging {} segments (generations {}) using merge(OneMerge) + IndexSort", matchingSegments.size(), diff --git a/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/serializers/AbstractRelevanceSerializer.java b/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/serializers/AbstractRelevanceSerializer.java index 09f8ad184693b..500dc507025da 100644 --- a/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/serializers/AbstractRelevanceSerializer.java +++ b/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/serializers/AbstractRelevanceSerializer.java @@ -9,6 +9,7 @@ package org.opensearch.be.lucene.serializers; import org.apache.calcite.rex.RexCall; +import org.opensearch.analytics.spi.FieldReferences; import org.opensearch.analytics.spi.FieldStorageInfo; import org.opensearch.be.lucene.ConversionUtils; import org.opensearch.index.query.QueryBuilder; @@ -17,9 +18,17 @@ import java.util.Map; /** - * Base class for relevance function serializers. Handles the common pattern of - * extracting operands, validating, creating the query builder, and applying - * optional parameters. + * Base class for relevance function serializers ({@code query_string}, {@code simple_query_string}, + * {@code multi_match}). Handles the common pattern of extracting operands, validating, creating the + * query builder, and applying optional parameters. + * + *

    It also implements {@link org.opensearch.analytics.spi.DelegatedPredicateSerializer#referencedFields} + * for these functions — the serializer is the single place that understands the predicate's operand + * shape — but only orchestrates: it extracts the operands and delegates the actual field parsing and + * literal/pattern classification to {@link RelevanceFieldExtractor} (which decides per function whether + * to parse the query string for in-string fields). Both {@link #buildQueryBuilder} and + * {@code referencedFields} read operands through {@link ConversionUtils#extractRelevanceOperands}, so + * plan-time field extraction can never drift from execution. */ public abstract class AbstractRelevanceSerializer extends AbstractQuerySerializer { @@ -33,6 +42,13 @@ public final QueryBuilder buildQueryBuilder(RexCall call, List return qb; } + @Override + public final FieldReferences referencedFields(RexCall call, List fieldStorage) { + ConversionUtils.RelevanceOperands operands = ConversionUtils.extractRelevanceOperands(call, fieldStorage); + Map params = ConversionUtils.extractOptionalParams(call, optionalParamsStartIndex()); + return RelevanceFieldExtractor.referencedFields(functionName(), operands, params); + } + protected abstract QueryBuilder createQueryBuilder(ConversionUtils.RelevanceOperands operands); protected abstract String functionName(); diff --git a/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/serializers/RelevanceFieldExtractor.java b/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/serializers/RelevanceFieldExtractor.java new file mode 100644 index 0000000000000..ebf757543c1ea --- /dev/null +++ b/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/serializers/RelevanceFieldExtractor.java @@ -0,0 +1,174 @@ +/* + * 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.be.lucene.serializers; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.lucene.index.Term; +import org.apache.lucene.queryparser.classic.ParseException; +import org.apache.lucene.queryparser.classic.QueryParser; +import org.apache.lucene.search.Query; +import org.apache.lucene.search.QueryVisitor; +import org.opensearch.analytics.spi.FieldReferences; +import org.opensearch.be.lucene.ConversionUtils; +import org.opensearch.common.lucene.Lucene; +import org.opensearch.common.regex.Regex; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Derives the {@link FieldReferences} a multi-field relevance predicate references, for the planner's + * plan-time field-type validation. Kept separate from the serializers so the serializer base stays + * focused on building {@link org.opensearch.index.query.QueryBuilder}s; the relevance serializers + * simply delegate their {@code referencedFields} to this helper. + * + *

    Explicit field tokens come from the already-parsed {@link ConversionUtils.RelevanceOperands} + * (the same operands the serializer builds its query from, so extraction cannot drift from execution). + * For {@code query_string} only, the query string is additionally parsed with Lucene's classic grammar + * (no-op analyzer) to collect fields written inside it (e.g. {@code category:A}, {@code _exists_:status}). + * Tokens are classified literal vs. pattern via {@link Regex#isSimpleMatchPattern} — the same predicate + * OpenSearch's resolver uses — so plan-time classification matches runtime. Only literals are validated + * by the planner; patterns and fan-out pass through. + * + * @opensearch.internal + */ +final class RelevanceFieldExtractor { + + private static final Logger LOGGER = LogManager.getLogger(RelevanceFieldExtractor.class); + + /** Field name for the EXISTS pseudo-operator: {@code _exists_:status} references field {@code status}. */ + private static final String EXISTS_FIELD = "_exists_"; + + /** + * Sentinel default field for parsing {@code query_string} bodies. Unqualified terms resolve to + * this field; it is skipped rather than emitted as a reference. Uses a control char so it can + * never collide with a real mapping field name. + */ + private static final String DEFAULT_FIELD_SENTINEL = "\u0000__analytics_default_field__"; + + /** + * Relevance functions whose query string carries in-string {@code field:value} syntax and should + * therefore be parsed for in-string fields. Only {@code query_string} qualifies today; + * {@code simple_query_string}/{@code multi_match} have no in-string field grammar (a {@code :} is + * treated literally), so their referenced fields come solely from the {@code fields} operand. Add + * future functions here. Entries match the serializers' {@code functionName()}. + */ + private static final Set QUERY_STRING_FUNCTIONS = Set.of("query_string"); + + private RelevanceFieldExtractor() {} + + /** + * Computes the referenced fields for a relevance predicate. + * + *

    Explicit {@code fields}/{@code field} tokens are collected for every relevance function; the + * query string is additionally parsed for in-string fields only when {@code functionName} is a + * query-string-style function (see {@link #QUERY_STRING_FUNCTIONS}). + * + * @param functionName the relevance function name (e.g. {@code "query_string"}); decides whether + * the query string is parsed for in-string fields, and used for log context + * @param operands the predicate's extracted operands (fields, query, etc.) + * @param optionalParams the predicate's optional key/value params; the {@code lenient} flag is + * read from here (absent → non-lenient, preserving eager rejection) + */ + static FieldReferences referencedFields( + String functionName, + ConversionUtils.RelevanceOperands operands, + Map optionalParams + ) { + // Collect referenced field tokens in first-appearance order, deduped. + Set tokens = new LinkedHashSet<>(); + + // 1. Explicit fields from the `fields`/`field` operand (already mapping-resolved for $ref forms). + if (operands.fields() != null) { + tokens.addAll(operands.fields()); + } + if (operands.fieldName() != null) { + tokens.add(operands.fieldName()); + } + + // 2. Only query_string carries in-string field:value syntax; parse it for in-string fields. + if (QUERY_STRING_FUNCTIONS.contains(functionName) && operands.query() != null) { + collectInStringFields(functionName, operands.query(), tokens); + } + + // 3. Classify literal vs. pattern; only literals are validated by the planner. + List literalFields = new ArrayList<>(); + List patternTokens = new ArrayList<>(); + for (String token : tokens) { + if (Regex.isSimpleMatchPattern(token)) { + patternTokens.add(token); + } else { + literalFields.add(token); + } + } + + return new FieldReferences(literalFields, patternTokens, resolveLenient(optionalParams)); + } + + /** + * Parses the query string with Lucene's classic grammar and records every field a leaf clause + * touches. The sentinel default field marks fan-out; {@code _exists_:field} contributes the + * named field (its term text) rather than {@code _exists_}. + * + *

    Best-effort: the plan-time parser does not implement OpenSearch's {@code query_string} + * extensions (e.g. {@code field:>15} unbounded ranges). On any parse failure, in-string + * extraction is skipped and validation relies on the explicit {@code fields} operand — the + * authoritative parse happens at execution. Unqualified terms resolve to the sentinel default + * field and are not emitted as field references. + */ + private static void collectInStringFields(String functionName, String queryString, Set tokens) { + QueryParser parser = new QueryParser(DEFAULT_FIELD_SENTINEL, Lucene.KEYWORD_ANALYZER); + parser.setAllowLeadingWildcard(true); + Query query; + try { + query = parser.parse(queryString); + } catch (ParseException | RuntimeException e) { + LOGGER.warn( + "[{}] skipping in-string field extraction; query string not parseable by plan-time parser [{}]: {}", + functionName, + queryString, + e.getMessage() + ); + return; + } + query.visit(new QueryVisitor() { + @Override + public boolean acceptField(String field) { + // Skip the sentinel (unqualified terms) and _exists_ (handled via term text below). + if (DEFAULT_FIELD_SENTINEL.equals(field) == false && EXISTS_FIELD.equals(field) == false) { + tokens.add(field); + } + return true; + } + + @Override + public void consumeTerms(Query q, Term... terms) { + for (Term term : terms) { + if (EXISTS_FIELD.equals(term.field())) { + tokens.add(term.text()); + } + } + } + }); + } + + /** + * Effective lenient flag: the explicit {@code lenient} param when present, otherwise {@code false} + * (assume non-lenient when unset). Treating unset as non-lenient preserves the planner's eager + * rejection of explicitly-named non-text fields; an explicit {@code lenient=true} opts out. + */ + private static boolean resolveLenient(Map optionalParams) { + String value = optionalParams.get("lenient"); + return value != null && Boolean.parseBoolean(value); + } +} diff --git a/sandbox/plugins/analytics-backend-lucene/src/test/java/org/opensearch/be/lucene/LuceneDocumentResolverTests.java b/sandbox/plugins/analytics-backend-lucene/src/test/java/org/opensearch/be/lucene/LuceneDocumentResolverTests.java new file mode 100644 index 0000000000000..14dd8fc05b02d --- /dev/null +++ b/sandbox/plugins/analytics-backend-lucene/src/test/java/org/opensearch/be/lucene/LuceneDocumentResolverTests.java @@ -0,0 +1,175 @@ +/* + * 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.be.lucene; + +import org.apache.lucene.codecs.Codec; +import org.apache.lucene.codecs.FilterCodec; +import org.apache.lucene.codecs.SegmentInfoFormat; +import org.apache.lucene.document.Document; +import org.apache.lucene.document.Field; +import org.apache.lucene.document.LongPoint; +import org.apache.lucene.document.NumericDocValuesField; +import org.apache.lucene.document.SortedNumericDocValuesField; +import org.apache.lucene.document.StoredField; +import org.apache.lucene.document.StringField; +import org.apache.lucene.index.DirectoryReader; +import org.apache.lucene.index.IndexWriter; +import org.apache.lucene.index.IndexWriterConfig; +import org.apache.lucene.index.SegmentInfo; +import org.apache.lucene.store.ByteBuffersDirectory; +import org.apache.lucene.store.Directory; +import org.apache.lucene.store.IOContext; +import org.opensearch.index.engine.dataformat.DataFormat; +import org.opensearch.index.engine.dataformat.DocumentInput; +import org.opensearch.index.engine.exec.DocumentMetadataResolver.DocumentMetadata; +import org.opensearch.index.engine.exec.IndexReaderProvider; +import org.opensearch.index.mapper.IdFieldMapper; +import org.opensearch.index.mapper.SeqNoFieldMapper; +import org.opensearch.index.mapper.Uid; +import org.opensearch.index.mapper.VersionFieldMapper; +import org.opensearch.test.OpenSearchTestCase; + +import java.io.IOException; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class LuceneDocumentResolverTests extends OpenSearchTestCase { + + private final LuceneDocumentResolver resolver = new LuceneDocumentResolver(); + + // --- resolveMetadata --- + + /** + * When the index has no Lucene secondary reader, {@code directoryReader(reader)} fails fast: + * resolution cannot proceed without a Lucene reader, so it throws rather than returning a bogus result. + */ + public void testResolveMetadata_emptyReader_returnsNull() throws IOException { + try (Directory dir = new ByteBuffersDirectory()) { + try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) { + w.commit(); + } + try (DirectoryReader dr = DirectoryReader.open(dir)) { + assertNull(resolver.resolveMetadata(readerReturning(dr), "doc1")); + } + } + } + + public void testResolveMetadata_idNotPresent_returnsNull() throws IOException { + try (Directory dir = new ByteBuffersDirectory()) { + try (IndexWriter w = new IndexWriter(dir, iwc(codecWithGeneration("1")))) { + addDoc(w, "other", 1L, 1L, 1L, 0L, true); + } + try (DirectoryReader dr = DirectoryReader.open(dir)) { + assertNull(resolver.resolveMetadata(readerReturning(dr), "missing")); + } + } + } + + public void testResolveMetadata_found_returnsMetadata() throws IOException { + try (Directory dir = new ByteBuffersDirectory()) { + try (IndexWriter w = new IndexWriter(dir, iwc(codecWithGeneration("5")))) { + addDoc(w, "doc1", 7L, 42L, 3L, 9L, true); + } + try (DirectoryReader dr = DirectoryReader.open(dir)) { + DocumentMetadata md = resolver.resolveMetadata(readerReturning(dr), "doc1"); + assertNotNull(md); + assertEquals("doc1", md.id()); + assertEquals(9L, md.rowId()); + assertEquals(5L, md.writerGeneration()); + } + } + } + + public void testResolveMetadata_missingWriterGeneration_throwsISE() throws IOException { + try (Directory dir = new ByteBuffersDirectory()) { + // default codec → no writer_generation segment attribute + try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) { + addDoc(w, "doc1", 1L, 1L, 1L, 0L, true); + } + try (DirectoryReader dr = DirectoryReader.open(dir)) { + IllegalStateException e = expectThrows( + IllegalStateException.class, + () -> resolver.resolveMetadata(readerReturning(dr), "doc1") + ); + assertTrue(e.getMessage().contains("writer_generation")); + } + } + } + + public void testResolveMetadata_missingRowIdDocValues_throwsISE() throws IOException { + try (Directory dir = new ByteBuffersDirectory()) { + try (IndexWriter w = new IndexWriter(dir, iwc(codecWithGeneration("1")))) { + addDoc(w, "doc1", 1L, 1L, 1L, 0L, false); // no __row_id__ doc values + } + try (DirectoryReader dr = DirectoryReader.open(dir)) { + IllegalStateException e = expectThrows( + IllegalStateException.class, + () -> resolver.resolveMetadata(readerReturning(dr), "doc1") + ); + assertTrue(e.getMessage().contains(DocumentInput.ROW_ID_FIELD)); + } + } + } + + // --- helpers --- + + private static IndexReaderProvider.Reader readerReturning(DirectoryReader dr) { + IndexReaderProvider.Reader reader = mock(IndexReaderProvider.Reader.class); + when(reader.getReader(any(DataFormat.class), eq(LuceneReader.class))).thenReturn(new LuceneReader(dr, java.util.Map.of())); + return reader; + } + + private static IndexWriterConfig iwc(Codec codec) { + IndexWriterConfig c = new IndexWriterConfig(); + c.setCodec(codec); + return c; + } + + private static void addDoc(IndexWriter w, String id, long version, long seqNo, long primaryTerm, long rowId, boolean withRowId) + throws IOException { + Document doc = new Document(); + doc.add(new StringField(IdFieldMapper.NAME, Uid.encodeId(id), Field.Store.NO)); // for _id TermQuery + doc.add(new StoredField(IdFieldMapper.NAME, Uid.encodeId(id))); // for decodeId (restore path) + doc.add(new NumericDocValuesField(VersionFieldMapper.NAME, version)); + doc.add(new LongPoint(SeqNoFieldMapper.NAME, seqNo)); // for LongPoint range scan + doc.add(new NumericDocValuesField(SeqNoFieldMapper.NAME, seqNo)); + doc.add(new NumericDocValuesField(SeqNoFieldMapper.PRIMARY_TERM_NAME, primaryTerm)); + if (withRowId) { + doc.add(new SortedNumericDocValuesField(DocumentInput.ROW_ID_FIELD, rowId)); + } + w.addDocument(doc); + w.commit(); + } + + /** Codec that stamps a {@code writer_generation} segment attribute, mirroring the production writer. */ + private static Codec codecWithGeneration(String gen) { + Codec base = Codec.getDefault(); + return new FilterCodec(base.getName(), base) { + @Override + public SegmentInfoFormat segmentInfoFormat() { + SegmentInfoFormat delegate = base.segmentInfoFormat(); + return new SegmentInfoFormat() { + @Override + public SegmentInfo read(Directory d, String name, byte[] id, IOContext ctx) throws IOException { + return delegate.read(d, name, id, ctx); + } + + @Override + public void write(Directory d, SegmentInfo info, IOContext ctx) throws IOException { + info.putAttribute("writer_generation", gen); + delegate.write(d, info, ctx); + } + }; + } + }; + } +} diff --git a/sandbox/plugins/analytics-backend-lucene/src/test/java/org/opensearch/be/lucene/RelevanceSerializerFieldReferenceTests.java b/sandbox/plugins/analytics-backend-lucene/src/test/java/org/opensearch/be/lucene/RelevanceSerializerFieldReferenceTests.java new file mode 100644 index 0000000000000..666838bf2d8c2 --- /dev/null +++ b/sandbox/plugins/analytics-backend-lucene/src/test/java/org/opensearch/be/lucene/RelevanceSerializerFieldReferenceTests.java @@ -0,0 +1,198 @@ +/* + * 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.be.lucene; + +import org.apache.calcite.jdbc.JavaTypeFactoryImpl; +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rel.type.RelDataTypeFactory; +import org.apache.calcite.rex.RexBuilder; +import org.apache.calcite.rex.RexCall; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.sql.SqlFunction; +import org.apache.calcite.sql.SqlFunctionCategory; +import org.apache.calcite.sql.SqlKind; +import org.apache.calcite.sql.fun.SqlStdOperatorTable; +import org.apache.calcite.sql.type.OperandTypes; +import org.apache.calcite.sql.type.ReturnTypes; +import org.apache.calcite.sql.type.SqlTypeName; +import org.opensearch.analytics.spi.FieldReferences; +import org.opensearch.be.lucene.serializers.QueryStringSerializer; +import org.opensearch.be.lucene.serializers.SimpleQueryStringSerializer; +import org.opensearch.test.OpenSearchTestCase; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +/** + * Unit tests for the relevance serializers' {@code referencedFields} implementation + * (in {@code AbstractRelevanceSerializer}) across the design's Problem-statement cases: explicit + * MAP fields, in-string fields, ranges, {@code _exists_}, default-field fan-out, pattern + * classification, lenient resolution, and parse failures. + */ +public class RelevanceSerializerFieldReferenceTests extends OpenSearchTestCase { + + private static final SqlFunction RELEVANCE_FUNCTION = new SqlFunction( + "RELEVANCE_FN", + SqlKind.OTHER_FUNCTION, + ReturnTypes.BOOLEAN, + null, + OperandTypes.ANY, + SqlFunctionCategory.USER_DEFINED_FUNCTION + ); + + // query_string parses the query string for in-string fields; simple_query_string does not. + private final QueryStringSerializer queryStringSerializer = new QueryStringSerializer(); + private final SimpleQueryStringSerializer simpleQueryStringSerializer = new SimpleQueryStringSerializer(); + + private RelDataTypeFactory typeFactory; + private RexBuilder rexBuilder; + + @Override + public void setUp() throws Exception { + super.setUp(); + typeFactory = new JavaTypeFactoryImpl(); + rexBuilder = new RexBuilder(typeFactory); + } + + public void testQueryStringExplicitFields() { + RexCall call = buildCall(List.of("title", "body"), "hello"); + FieldReferences refs = queryStringSerializer.referencedFields(call, List.of()); + + assertEquals(List.of("title", "body"), refs.literalFields()); + assertTrue(refs.patternTokens().isEmpty()); + assertFalse("lenient defaults to false when unset (Option B)", refs.lenient()); + } + + public void testQueryStringInStringFieldNoExplicitFields() { + RexCall call = buildCall(null, "category:A"); + FieldReferences refs = queryStringSerializer.referencedFields(call, List.of()); + + assertEquals(List.of("category"), refs.literalFields()); + } + + public void testQueryStringMergesExplicitAndInStringFields() { + RexCall call = buildCall(List.of("title"), "title:hi AND status:1"); + FieldReferences refs = queryStringSerializer.referencedFields(call, List.of()); + + // title (explicit + in-string, deduped) and status (in-string), first-appearance order. + assertEquals(List.of("title", "status"), refs.literalFields()); + } + + public void testQueryStringPatternTokenNotValidatedAsLiteral() { + RexCall call = buildCall(List.of("cat*"), "hello"); + FieldReferences refs = queryStringSerializer.referencedFields(call, List.of()); + + assertTrue("pattern must not be a literal", refs.literalFields().isEmpty()); + assertEquals(List.of("cat*"), refs.patternTokens()); + } + + public void testQueryStringFieldlessYieldsNoLiterals() { + RexCall call = buildCall(null, "foo bar"); + FieldReferences refs = queryStringSerializer.referencedFields(call, List.of()); + + // Unqualified terms fan out to default_field at execution; nothing for the planner to validate. + assertTrue(refs.literalFields().isEmpty()); + assertTrue(refs.patternTokens().isEmpty()); + } + + public void testQueryStringExistsField() { + RexCall call = buildCall(null, "_exists_:status"); + FieldReferences refs = queryStringSerializer.referencedFields(call, List.of()); + + assertEquals(List.of("status"), refs.literalFields()); + } + + public void testQueryStringRangeField() { + RexCall call = buildCall(null, "value:[1 TO 5]"); + FieldReferences refs = queryStringSerializer.referencedFields(call, List.of()); + + assertEquals(List.of("value"), refs.literalFields()); + } + + public void testSimpleQueryStringIgnoresInStringSyntax() { + // simple_query_string has no field:value syntax; only the `fields` operand contributes. + RexCall call = buildCall(List.of("title", "body"), "category:A"); + FieldReferences refs = simpleQueryStringSerializer.referencedFields(call, List.of()); + + assertEquals(List.of("title", "body"), refs.literalFields()); + } + + public void testExplicitLenientHonored() { + RexCall callFalse = buildCallWithLenient(List.of("intField"), "x", false); + assertFalse(queryStringSerializer.referencedFields(callFalse, List.of()).lenient()); + + RexCall callTrue = buildCallWithLenient(List.of("intField"), "x", true); + assertTrue(queryStringSerializer.referencedFields(callTrue, List.of()).lenient()); + + // Unset → false (Option B): preserves eager rejection. + RexCall callUnset = buildCall(List.of("intField"), "x"); + assertFalse(queryStringSerializer.referencedFields(callUnset, List.of()).lenient()); + } + + public void testParseFailureFallsBackGracefully() { + // OpenSearch query_string supports `field:>15`; Lucene's stock parser does not. On parse + // failure we skip in-string extraction (no throw) and rely on explicit fields. + RexCall call = buildCall(List.of("severityNumber"), "severityNumber:>15"); + FieldReferences refs = queryStringSerializer.referencedFields(call, List.of()); + + assertEquals(List.of("severityNumber"), refs.literalFields()); + assertTrue(refs.patternTokens().isEmpty()); + } + + public void testUnparseableFieldlessQueryFallsBackToFanout() { + RexCall call = buildCall(null, "title:(unbalanced"); + FieldReferences refs = queryStringSerializer.referencedFields(call, List.of()); + + // Parse failed, no explicit fields → nothing for the planner to validate; fan-out resolved at execution. + assertTrue(refs.literalFields().isEmpty()); + assertTrue(refs.patternTokens().isEmpty()); + } + + // ---- builders ---- + + /** Builds {@code fn(MAP('fields', MAP(f1,1.0,...)), MAP('query', queryString))}; omits fields MAP when null. */ + private RexCall buildCall(List fields, String queryString) { + List operands = new ArrayList<>(); + if (fields != null) { + operands.add(fieldsOperand(fields)); + } + operands.add(queryOperand(queryString)); + return (RexCall) rexBuilder.makeCall(RELEVANCE_FUNCTION, operands.toArray(new RexNode[0])); + } + + /** Builds {@code fn(MAP('fields', ...), MAP('query', ...), MAP('lenient', 'true|false'))}. */ + private RexCall buildCallWithLenient(List fields, String queryString, boolean lenient) { + RexNode lenientMap = rexBuilder.makeCall( + SqlStdOperatorTable.MAP_VALUE_CONSTRUCTOR, + rexBuilder.makeLiteral("lenient"), + rexBuilder.makeLiteral(Boolean.toString(lenient)) + ); + return (RexCall) rexBuilder.makeCall(RELEVANCE_FUNCTION, fieldsOperand(fields), queryOperand(queryString), lenientMap); + } + + private RexNode fieldsOperand(List fields) { + RelDataType doubleType = typeFactory.createSqlType(SqlTypeName.DOUBLE); + List nested = new ArrayList<>(); + for (String field : fields) { + nested.add(rexBuilder.makeLiteral(field)); + nested.add(rexBuilder.makeExactLiteral(new BigDecimal("1.0"), doubleType)); + } + RexNode nestedMap = rexBuilder.makeCall(SqlStdOperatorTable.MAP_VALUE_CONSTRUCTOR, nested.toArray(new RexNode[0])); + return rexBuilder.makeCall(SqlStdOperatorTable.MAP_VALUE_CONSTRUCTOR, rexBuilder.makeLiteral("fields"), nestedMap); + } + + private RexNode queryOperand(String queryString) { + return rexBuilder.makeCall( + SqlStdOperatorTable.MAP_VALUE_CONSTRUCTOR, + rexBuilder.makeLiteral("query"), + rexBuilder.makeLiteral(queryString) + ); + } +} diff --git a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/AnalyticsPlugin.java b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/AnalyticsPlugin.java index 4a64ea9e16e54..b3994c0bf0c9f 100644 --- a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/AnalyticsPlugin.java +++ b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/AnalyticsPlugin.java @@ -99,9 +99,11 @@ public class AnalyticsPlugin extends Plugin implements ExtensiblePlugin, ActionP private static final int REDUCE_POOL_SIZE = Math.max(8, Runtime.getRuntime().availableProcessors() * 4); private static final int REDUCE_QUEUE_SIZE = 200; + // Per-query coordinator allocator cap in bytes. 0 (default) → no per-query child allocator; + // queries share the coordinator allocator with no per-query cap. public static final Setting COORDINATOR_BUFFER_LIMIT = Setting.longSetting( "analytics.coordinator.buffer_limit", - 256L * 1024 * 1024, + 0L, 0L, Setting.Property.NodeScope, Setting.Property.Dynamic @@ -258,11 +260,26 @@ static int schedulerPoolSize() { @Override public SearchStats contributeSearchStats() { - AnalyticsStats.LatencyStats elapsed = statsCollector.snapshot().queries().elapsedMs(); - if (elapsed.count() == 0) { + // Contribute per-shard fragment task counts so the node-level search counters + // exposed via _nodes/stats match Lucene's per-shard accounting (one increment + // per shard query phase, not per user query). + // + // The queries.elapsed_ms bucket counts 1-per-query, which undercounts the + // rate by the shard fan-out factor and drops most queries' latency (the + // start/end window in AnalyticsStatsCollector#recordExecution is commonly + // 0 when stage timestamps aren't populated). The SHARD_FRAGMENT stage + // bucket counts 1-per-(query, node-hosting-shards), still missing the + // per-shard granularity Lucene reports. fragments.total walks each + // SHARD_FRAGMENT execution's per-shard StageTasks, giving per-shard + // counts matching Lucene's onPreQueryPhase semantics. + AnalyticsStats snapshot = statsCollector.snapshot(); + AnalyticsStats.Fragments fragments = snapshot.fragments(); + if (fragments == null || fragments.total() == 0) { return null; } - SearchStats.Stats stats = new SearchStats.Stats.Builder().queryCount(elapsed.count()).queryTimeInMillis(elapsed.sumMs()).build(); + SearchStats.Stats stats = new SearchStats.Stats.Builder().queryCount(fragments.total()) + .queryTimeInMillis(fragments.elapsedMs().sumMs()) + .build(); return new SearchStats(stats, 0, null); } diff --git a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/AnalyticsSearchService.java b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/AnalyticsSearchService.java index 5b62f0aff7bfa..8a8aebc4f23f7 100644 --- a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/AnalyticsSearchService.java +++ b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/AnalyticsSearchService.java @@ -12,6 +12,7 @@ import org.apache.arrow.vector.BigIntVector; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.message.ParameterizedMessage; import org.opensearch.OpenSearchException; import org.opensearch.analytics.backend.AnalyticsOperationListener; import org.opensearch.analytics.backend.EngineResultBatch; @@ -147,10 +148,35 @@ private ResolvedExecution executeFragmentStreamingResolved( throw e; } catch (Exception e) { listener.onFragmentFailure(resolved.queryId, resolved.stageId, resolved.shardIdStr, e); + // Log the original failure with its full stack at the origin: the thrown exception is handed + // to async dispatch / stream transport and can be re-wrapped or swallowed downstream, so this + // is the one place guaranteed to see the real cause and stack. + LOGGER.warn( + new ParameterizedMessage( + "[FragmentExecution] failed to start streaming fragment on shard={} queryId={} stageId={}", + shard.shardId(), + resolved.queryId, + resolved.stageId + ), + e + ); + // Convert native errors (e.g. memory-pool / admission trips) to typed exceptions via the + // ACTUALLY-SELECTED backend so a resource-exhaustion failure surfaces as 429 instead of a + // generic 500. Only wrap as a generic RuntimeException when conversion found nothing. + Exception converted = convertWith(resolved.plan().getBackendId(), e); + if (converted != e) { + throw converted instanceof RuntimeException re ? re : new RuntimeException(converted); + } throw new RuntimeException("Failed to start streaming fragment on " + shard.shardId(), e); } } + /** Converts {@code e} via the named backend's exception SPI; returns {@code e} unchanged if the backend is absent or doesn't recognize it. */ + private Exception convertWith(String backendId, Exception e) { + AnalyticsSearchBackendPlugin backend = backends.get(backendId); + return backend == null ? e : backend.convertException(e); + } + private record ResolvedExecution(FragmentResources resources, ResolvedFragment resolved) implements AutoCloseable { @Override public void close() throws Exception { @@ -229,7 +255,7 @@ public void executeFragmentStreamingAsync( } catch (Exception e) { // Query phase failed: no fetch will follow, so free the reader eagerly (no-op if already freed). readerContextStore.freeContext(request.getQueryId(), shard.shardId()); - responseHandler.onFailure(e); + responseHandler.onFailure(convertWith(selectedBackendId(request), e)); } }); } catch (Exception e) { @@ -275,7 +301,8 @@ private void drainFetchByRowIds( AnalyticsShardTask task, StreamingFragmentResponseHandler responseHandler ) { - if (task != null && task.isCancelled()) { + assert task != null : "fetch on " + shard.shardId() + " requires a non-null AnalyticsShardTask"; + if (task.isCancelled()) { responseHandler.onFailure(new TaskCancelledException("Fetch task cancelled before execution: " + task.getReasonCancelled())); return; } @@ -351,9 +378,15 @@ private void drainFetchByRowIds( if (rowIdVector != null) rowIdVector.close(); // Fetch is terminal: free the reader eagerly. readerContextStore.releaseAndFree(request.getQueryId(), shard.shardId()); - responseHandler.onFailure(new RuntimeException("Failed to execute fetch-by-row-ids on " + shard.shardId(), e)); + Exception converted = backend.convertException(e); + responseHandler.onFailure( + converted == e ? new RuntimeException("Failed to execute fetch-by-row-ids on " + shard.shardId(), e) : converted + ); return; } + // On cancel, release a fetch parked in the native pull via cooperative cancellation, not + // stream.close() (which would race the in-flight native pull). + task.setCancellationListener(() -> backend.cancelByContext(task.getId())); try (FragmentResources ctx = resources) { Iterator it = ctx.stream().iterator(); while (it.hasNext()) { @@ -361,7 +394,9 @@ private void drainFetchByRowIds( } responseHandler.onComplete(); } catch (Exception e) { - responseHandler.onFailure(e); + responseHandler.onFailure(backend.convertException(e)); + } finally { + task.clearCancellationListener(); } } @@ -521,6 +556,20 @@ private record ResolvedFragment(IndexReaderProvider readerProvider, FragmentExec int stageId, String shardIdStr) { } + /** + * Backend id of the plan alternative {@code request} will actually run — the first whose backend is + * registered locally. Mirrors {@link #resolveFragment}'s selection so exception conversion uses the + * same backend that produced the failure. Returns null if none is registered. + */ + private String selectedBackendId(FragmentExecutionRequest request) { + for (FragmentExecutionRequest.PlanAlternative alt : request.getPlanAlternatives()) { + if (backends.containsKey(alt.getBackendId())) { + return alt.getBackendId(); + } + } + return null; + } + private ResolvedFragment resolveFragment(FragmentExecutionRequest request, IndexShard shard) { IndexReaderProvider readerProvider = shard.getReaderProvider(); if (readerProvider == null) { diff --git a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/AnalyticsSearchTransportService.java b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/AnalyticsSearchTransportService.java index aca076b796d38..35ddc73226605 100644 --- a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/AnalyticsSearchTransportService.java +++ b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/AnalyticsSearchTransportService.java @@ -30,6 +30,7 @@ import org.opensearch.tasks.Task; import org.opensearch.tasks.TaskResourceTrackingService; import org.opensearch.threadpool.ThreadPool; +import org.opensearch.transport.ConnectTransportException; import org.opensearch.transport.StreamTransportService; import org.opensearch.transport.Transport; import org.opensearch.transport.TransportChannel; @@ -200,7 +201,7 @@ public void onCompleteWithMetrics(byte[] metrics) { @Override public void onFailure(Exception e) { try { - channel.sendResponse(e); + channel.sendResponse(AnalyticsTransportErrors.toWireError(e)); } catch (Exception sendException) { throw new RuntimeException(sendException); } @@ -218,8 +219,13 @@ private static void closeResponseQuietly(FragmentExecutionArrowResponse response } catch (Exception ignore) {} } - Transport.Connection getConnection(String clusterAlias, String nodeId) { - DiscoveryNode node = clusterService.state().nodes().get(nodeId); + Transport.Connection getConnection(DiscoveryNode node) { + if (node == null) { + // The target left the cluster between planning and dispatch. Surface a clean + // ConnectTransportException instead of letting a null node reach the connection + // manager, where it NPEs ("Cannot invoke Object.hashCode() because key is null"). + throw new ConnectTransportException(null, "target node left the cluster before dispatch"); + } return transportService.getConnection(node); } @@ -288,6 +294,9 @@ public void handleStreamResponse(StreamTransportResponse { try { - Transport.Connection connection = getConnection(null, targetNode.getId()); + Transport.Connection connection = getConnection(targetNode); transportService.sendChildRequest(connection, actionName, request, parentTask, options, handler); } catch (Exception e) { try { - listener.onFailure(e); + listener.onFailure(AnalyticsTransportErrors.fromWireError(e)); } finally { pending.finishAndRunNext(); } diff --git a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/AnalyticsTransportErrors.java b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/AnalyticsTransportErrors.java new file mode 100644 index 0000000000000..38ada4e8f848b --- /dev/null +++ b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/AnalyticsTransportErrors.java @@ -0,0 +1,115 @@ +/* + * 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.analytics.exec; + +import org.opensearch.ExceptionsHelper; +import org.opensearch.OpenSearchException; +import org.opensearch.OpenSearchStatusException; +import org.opensearch.core.common.breaker.CircuitBreaker; +import org.opensearch.core.common.breaker.CircuitBreakingException; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.core.tasks.TaskCancelledException; +import org.opensearch.transport.stream.StreamErrorCode; +import org.opensearch.transport.stream.StreamException; + +/** + * Error mapping for the two ends of an analytics shard RPC over Flight stream transport. + * + *

    Flight does not serialize the Java exception type — only a {@link StreamErrorCode} and a + * description string survive the wire. So a typed failure on the data node (e.g. a memory-gate + * {@link CircuitBreakingException}, HTTP 429) would otherwise reach the coordinator as a typeless + * {@code INTERNAL} error and surface to the user as a generic 500. These two methods are a matched + * pair that preserves the status across the wire via the error code: + * + *

      + *
    • {@link #toWireError} — data-node send side: tag a resource-exhaustion failure as + * {@link StreamErrorCode#RESOURCE_EXHAUSTED} before it crosses Flight. + *
    • {@link #fromWireError} — coordinator receive side: rebuild a typed exception with the + * right HTTP status from the {@link StreamException} that crossed the wire. + *
    + * + * Cause-chain walks use {@link ExceptionsHelper#unwrapCausesAndSuppressed} (cycle-safe). + * + * @opensearch.internal + */ +final class AnalyticsTransportErrors { + + private AnalyticsTransportErrors() {} + + /** + * Data-node send side. Tags a typed failure with the right {@link StreamErrorCode} so the signal + * survives Flight (which doesn't serialize the exception type) and the coordinator can rebuild it + * in {@link #fromWireError}: + *
      + *
    • HTTP-429 {@link OpenSearchException} (circuit breaker / admission rejection) → + * {@link StreamErrorCode#RESOURCE_EXHAUSTED}. + *
    • {@link TaskCancelledException} (e.g. SBP cancellation) → {@link StreamErrorCode#CANCELLED} — + * otherwise it crosses as INTERNAL and surfaces as a generic 500/ISE that clients retry. + *
    + * Other failures pass through unchanged. + */ + static Exception toWireError(Exception e) { + Throwable resourceExhausted = ExceptionsHelper.unwrapCausesAndSuppressed( + e, + t -> t instanceof OpenSearchException ose && ose.status() == RestStatus.TOO_MANY_REQUESTS + ).orElse(null); + if (resourceExhausted != null) { + return new StreamException(StreamErrorCode.RESOURCE_EXHAUSTED, resourceExhausted.getMessage(), e); + } + Throwable cancelled = ExceptionsHelper.unwrapCausesAndSuppressed(e, t -> t instanceof TaskCancelledException).orElse(null); + if (cancelled != null) { + return new StreamException(StreamErrorCode.CANCELLED, cancelled.getMessage(), e); + } + return e; + } + + /** + * Coordinator receive side. Inverse of {@link #toWireError}: maps a {@link StreamException} that + * crossed transport back to a typed exception, so a shard-side failure doesn't surface to the user + * as a generic 500: + *
      + *
    • {@link StreamErrorCode#RESOURCE_EXHAUSTED} → {@link CircuitBreakingException} (429) — a + * memory-pool / breaker trip. + *
    • {@link StreamErrorCode#UNAVAILABLE} → 503 {@link OpenSearchStatusException} — a transport + * drop (node gone, connection reset) is "service unavailable", not an internal error. + *
    • {@link StreamErrorCode#CANCELLED} → {@link TaskCancelledException} — a shard cancel (e.g. SBP) + * stays a recognizable cancellation instead of degrading to a generic ISE that clients would retry. + *
    + * Anything else passes through unchanged. + */ + static Exception fromWireError(Exception e) { + StreamException se = ExceptionsHelper.unwrapCausesAndSuppressed( + e, + t -> t instanceof StreamException s + && (s.getErrorCode() == StreamErrorCode.RESOURCE_EXHAUSTED + || s.getErrorCode() == StreamErrorCode.UNAVAILABLE + || s.getErrorCode() == StreamErrorCode.CANCELLED) + ).orElse(null); + if (se == null) { + return e; + } + String message = se.getMessage(); + if (se.getErrorCode() == StreamErrorCode.RESOURCE_EXHAUSTED) { + // CircuitBreakingException has no cause-accepting ctor; attach the wire exception via initCause + // so the original StreamException/stack is kept for server-side troubleshooting. + CircuitBreakingException breaker = new CircuitBreakingException( + message != null ? message : "circuit breaking exception", + CircuitBreaker.Durability.TRANSIENT + ); + breaker.initCause(e); + return breaker; + } + if (se.getErrorCode() == StreamErrorCode.CANCELLED) { + TaskCancelledException cancelled = new TaskCancelledException(message != null ? message : "task cancelled"); + cancelled.initCause(e); + return cancelled; + } + return new OpenSearchStatusException(message != null ? message : "service unavailable", RestStatus.SERVICE_UNAVAILABLE, e); + } +} diff --git a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/ArrowValues.java b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/ArrowValues.java index b62d1c7c7a64c..e79af1e694ff8 100644 --- a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/ArrowValues.java +++ b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/ArrowValues.java @@ -10,8 +10,10 @@ import org.apache.arrow.vector.FieldVector; import org.apache.arrow.vector.VarCharVector; +import org.apache.arrow.vector.VectorSchemaRoot; import org.apache.arrow.vector.complex.ListVector; import org.apache.arrow.vector.complex.MapVector; +import org.apache.arrow.vector.types.TimeUnit; import org.apache.arrow.vector.types.pojo.ArrowType; import org.apache.arrow.vector.types.pojo.Field; import org.apache.arrow.vector.util.Text; @@ -50,6 +52,76 @@ public final class ArrowValues { private ArrowValues() {} + private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter.ISO_INSTANT.withZone(ZoneOffset.UTC); + + /** Converts row {@code rowId} of a {@link VectorSchemaRoot} into a JSON-friendly field map. */ + public static Map toSourceMap(VectorSchemaRoot root, int rowId) { + Map out = new LinkedHashMap<>(); + for (Field field : root.getSchema().getFields()) { + Object converted = toSourceValue(root.getVector(field.getName()), rowId); + if (converted != null) { + out.put(field.getName(), converted); + } + } + return out; + } + + /** + * Reads an Arrow cell as a JSON-friendly scalar: numerics coerced to + * {@code long}/{@code double}, timestamps rendered as ISO-8601 UTC strings. Binary and + * complex (list/struct/decimal) types are not yet supported and return {@code null}. + */ + public static Object toSourceValue(FieldVector vec, int idx) { + if (vec == null || vec.isNull(idx)) return null; + ArrowType type = vec.getField().getType(); + ArrowType.ArrowTypeID id = type.getTypeID(); + switch (id) { + case Binary: + case LargeBinary: + case FixedSizeBinary: + case BinaryView: + return null; + default: + break; + } + Object raw = vec.getObject(idx); + switch (id) { + case Utf8: + case LargeUtf8: + case Utf8View, Date: + return raw == null ? null : raw.toString(); + case Int: + return raw instanceof Number ? ((Number) raw).longValue() : raw; + case FloatingPoint: + return raw instanceof Number ? ((Number) raw).doubleValue() : raw; + case Bool: + return raw; + case Timestamp: + if (raw instanceof Number) { + ArrowType.Timestamp ts = (ArrowType.Timestamp) type; + return ISO_FORMATTER.format(toInstant(((Number) raw).longValue(), ts.getUnit())); + } + return raw == null ? null : raw.toString(); + default: + // TODO type coverage (list, struct, decimal) + return null; + } + } + + private static Instant toInstant(long v, TimeUnit unit) { + switch (unit) { + case SECOND: + return Instant.ofEpochSecond(v); + case MILLISECOND: + return Instant.ofEpochMilli(v); + case MICROSECOND: + return Instant.ofEpochSecond(v / 1_000_000L, (v % 1_000_000L) * 1_000L); + case NANOSECOND: + default: + return Instant.ofEpochSecond(v / 1_000_000_000L, v % 1_000_000_000L); + } + } + public static Object toJavaValue(FieldVector vector, int index) { if (vector.isNull(index)) return null; if (vector instanceof VarCharVector v) { diff --git a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/DefaultPlanExecutor.java b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/DefaultPlanExecutor.java index dd6d0dc9ef403..51a66fe48f04a 100644 --- a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/DefaultPlanExecutor.java +++ b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/DefaultPlanExecutor.java @@ -17,6 +17,8 @@ import org.apache.calcite.rel.metadata.RelMetadataQueryBase; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.opensearch.ExceptionsHelper; +import org.opensearch.OpenSearchException; import org.opensearch.action.support.ActionFilters; import org.opensearch.action.support.HandledTransportAction; import org.opensearch.action.support.TimeoutTaskCancellationUtility; @@ -50,6 +52,7 @@ import org.opensearch.common.inject.Inject; import org.opensearch.common.unit.TimeValue; import org.opensearch.core.action.ActionListener; +import org.opensearch.core.rest.RestStatus; import org.opensearch.core.tasks.TaskId; import org.opensearch.search.SearchService; import org.opensearch.tasks.Task; @@ -268,7 +271,7 @@ private void executeInternal( PlanAlternativeSelector.selectAll(dag, capabilityRegistry, preferMetadataDriver); FragmentConversionDriver.convertAll(dag, capabilityRegistry); final long planningTimeNanos = System.nanoTime() - planStartNanos; - final long planningTimeMs = profile ? TimeUnit.NANOSECONDS.toMillis(planningTimeNanos) : 0; + final long planningTimeMs = TimeUnit.NANOSECONDS.toMillis(planningTimeNanos); logger.debug("[DefaultPlanExecutor] QueryDAG:\n{}", dag); queryListener.onPlanningComplete(dag.queryId(), planningTimeNanos); @@ -409,7 +412,27 @@ protected void doExecute(Task task, AnalyticsQueryRequest request, ActionListene // immediately. The listener is wrapped to convert backend-specific exceptions. ActionListener convertingListener = ActionListener.wrap(listener::onResponse, e -> { Exception converted = e instanceof Exception ex ? contextProvider.convertException(ex) : new RuntimeException(e); - listener.onFailure(converted); + // A typed status (e.g. a 429 breaker) often arrives buried in a wrapper — + // ShardFragmentStageExecution reports shard failures as RuntimeException("Stage N failed", cause), + // so isInternalError (top-level only) would redact it to a generic 500 and drop the chain. Surface + // the buried status-bearing exception directly so 429/503 reach the client instead of an opaque 500. + Exception statusBearing = statusBearingCause(converted); + if (statusBearing != null) { + listener.onFailure(statusBearing); + } else if (converted == e && isInternalError(converted)) { + AnalyticsQueryTask queryTask = (AnalyticsQueryTask) task; + String queryId = queryTask.getQueryId(); + String identifier = "unassigned".equals(queryId) + ? "task_id=" + task.getId() + : "task_id=" + task.getId() + ", query_id=" + queryId; + logger.error( + new org.apache.logging.log4j.message.ParameterizedMessage("[analytics-engine] internal error [{}]", identifier), + converted + ); + listener.onFailure(new RuntimeException("Internal error [" + identifier + "]")); + } else { + listener.onFailure(converted); + } }); ContextAwareExecutor.wrap(searchExecutor, threadPool).execute(() -> { try { @@ -443,6 +466,26 @@ protected void doExecute(Task task, AnalyticsQueryRequest request, ActionListene }); } + /** + * Returns true if the exception would produce a 500 response and should be redacted. + */ + private static boolean isInternalError(Exception e) { + return ExceptionsHelper.status(e) == RestStatus.INTERNAL_SERVER_ERROR; + } + + /** + * Walks the cause/suppressed chain for a typed {@link OpenSearchException} carrying a non-500 status + * (e.g. a 429 breaker wrapped as {@code RuntimeException("Stage N failed", cbe)}). Returns it so the + * real status reaches the client instead of being redacted to a generic 500; null when the failure is + * genuinely internal and the redaction path should run. + */ + static Exception statusBearingCause(Exception converted) { + return ExceptionsHelper.unwrapCausesAndSuppressed( + converted, + t -> t instanceof OpenSearchException ose && ose.status() != RestStatus.INTERNAL_SERVER_ERROR + ).map(t -> (Exception) t).orElse(null); + } + /** * Materializes Arrow batches into row-oriented {@code Object[]}s for the * external query API. The scheduler yields batches (the native wire format); diff --git a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/QueryContext.java b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/QueryContext.java index 5620f7c401c02..2fac28868b329 100644 --- a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/QueryContext.java +++ b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/QueryContext.java @@ -19,9 +19,9 @@ import org.opensearch.common.settings.Settings; import org.opensearch.threadpool.ThreadPool; -import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -62,10 +62,11 @@ public class QueryContext { * {@code Stage} alongside {@code targetResolver}, or reify a typed cross-stage routing * table. Revisit when a second consumer appears or when extending QTF to UNION/JOIN. * - *

    Single-threaded write inside one stage's {@code materializeTasks}; reads happen - * only after that stage SUCCEEDED → plain {@link HashMap} suffices. + *

    The inner map is a {@link java.util.concurrent.ConcurrentHashMap} because + * {@code retargetForRetry} may update entries concurrently when multiple shards + * fail and retry in parallel on the scheduler thread pool. */ - private final Map> resolvedTargetsByStage = new HashMap<>(); + private final Map> resolvedTargetsByStage = new ConcurrentHashMap<>(); /** Full-parameter constructor. Tests use {@link #forTest} factories. */ public QueryContext( @@ -146,13 +147,25 @@ public List operationListeners() { * {@code QueryContext}. */ public void recordResolvedTargets(int stageId, List targets) { - Map byOrdinal = new HashMap<>(targets.size()); + Map byOrdinal = new ConcurrentHashMap<>(targets.size()); for (ShardExecutionTarget t : targets) { byOrdinal.put(t.ordinal(), t); } resolvedTargetsByStage.put(stageId, byOrdinal); } + /** + * Updates a single resolved target after a successful shard retry on a different copy. + * This ensures downstream stages (e.g. LM fetch) route to the node that actually + * executed the query, not the original primary that failed. + */ + public void updateResolvedTarget(int stageId, int ordinal, ShardExecutionTarget target) { + Map byOrdinal = resolvedTargetsByStage.get(stageId); + if (byOrdinal != null) { + byOrdinal.put(ordinal, target); + } + } + /** * Returns the resolved targets for a stage keyed by per-shard ordinal (UGSI), or * {@code null} if that stage hasn't resolved yet (or doesn't have a resolver). The diff --git a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/QueryExecution.java b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/QueryExecution.java index ab0c8bdee7841..908dc0b42d45a 100644 --- a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/QueryExecution.java +++ b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/QueryExecution.java @@ -9,16 +9,21 @@ package org.opensearch.analytics.exec; import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.memory.OutOfMemoryException; import org.apache.arrow.vector.VectorSchemaRoot; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.message.ParameterizedMessage; +import org.opensearch.ExceptionsHelper; import org.opensearch.analytics.exec.stage.DataProducer; import org.opensearch.analytics.exec.stage.StageExecution; import org.opensearch.analytics.spi.ExchangeSink; import org.opensearch.core.action.ActionListener; +import org.opensearch.core.common.breaker.CircuitBreaker; +import org.opensearch.core.common.breaker.CircuitBreakingException; import org.opensearch.core.tasks.TaskCancelledException; import org.opensearch.tasks.CancellableTask; +import org.opensearch.transport.stream.StreamException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; @@ -123,7 +128,6 @@ public ExecutionGraph getGraph() { public void close() { if (closed.compareAndSet(false, true) == false) return; runQuietly("terminal sink close", this::closeTerminalSink); - // TODO: Re-evaluate this per query child allocator logAllocatorState(); runQuietly("query context close", config::close); } @@ -171,17 +175,97 @@ private void fireListener(State terminal) { } /** - * Live parent-task cancellation wins — keeps the user-facing message accurate over the - * downstream FAILED cause. Otherwise propagate the captured stage failure (synthetic - * fallback for CANCELLED with no upstream cause). + * Determines the exception to report for a non-SUCCEEDED terminal. + * + *

    Live parent-task cancellation still wins — the user-facing "query cancelled" message stays + * accurate for genuine top-down cancels. Two exceptions ride past the cancel mask: + *

      + *
    • Breaker masquerading as a cancel — a memory-gate trip ({@link CircuitBreakingException}) + * fails the (reduce) stage and then cancels the parent task via the sibling/child cancel sweep, + * so {@code isCancelled()} is already true by the time we report. Surfacing it unwrapped gives + * {@code status()} = 429 (Garov, PR #22275). + *
    • Arrow allocator exhaustion — {@link OutOfMemoryException} from {@code BufferAllocator} + * is also back-pressure (allocation rejected against {@code native.allocator.pool.query.max}), + * NOT a JVM {@code OutOfMemoryError}. Translate to {@code CircuitBreakingException} so the same + * budget-refusal convention applies: client sees 429 and {@code FailAwareWeightedRouting} skips + * replica retry, preventing retry storms. Covers two propagation paths: + *
        + *
      • In-process — Arrow's {@code OutOfMemoryException} reaches the coordinator with its + * original class identity intact (e.g. coordinator-local materialization, or a same-JVM + * reduce stage). Matched directly via {@link ExceptionsHelper#unwrap}. + *
      • Across Flight RPC — even on a single-node cluster, shard executor → reduce stage + * rides the Flight transport. Flight's wire envelope strips the original class to + * {@code StreamException[errorCode=INTERNAL]} carrying the Arrow message as a string. We + * pattern-match the {@code "Unable to allocate buffer"} marker (the only producer is + * {@code BaseAllocator.wrapForeignAllocation}) so the cross-RPC path also surfaces as 429. + * A reactive shim until proactive {@code AllocationListener} circuit-breaking lands. + *
      + *
    + * A genuine cancel records no stage failure ({@code getFailure()} is {@code null}), so neither peek + * matches and behavior is unchanged. The non-cancel path is the original modulo the same Arrow OOM + * translation, so direct in-process Arrow OOMs (no cancel sweep, e.g. coordinator-local materialization) + * also surface as 429. + * + *

    TODO: replace this reactive translation with proactive circuit-breaking via Arrow's + * {@code AllocationListener} — wired to register against the parent breaker, so the budget check + * happens at allocation request time and a {@code CircuitBreakingException} is raised natively + * (no unwrap dance). Until then, this is the chokepoint. */ private Exception terminalCause(State terminal) { + Exception failure = graph.rootExecution().getFailure(); if (config.parentTask() instanceof CancellableTask ct && ct.isCancelled()) { + Throwable breaker = ExceptionsHelper.unwrap(failure, CircuitBreakingException.class); + if (breaker != null) { + return (CircuitBreakingException) breaker; + } + CircuitBreakingException arrowOom = arrowOomAsBreaker(failure); + if (arrowOom != null) { + return arrowOom; + } return new TaskCancelledException("query cancelled"); } - StageExecution rootExec = graph.rootExecution(); - Exception failure = rootExec.getFailure(); - return failure != null ? failure : new RuntimeException("Stage " + rootExec.getStageId() + " " + terminal); + if (failure != null) { + CircuitBreakingException arrowOom = arrowOomAsBreaker(failure); + if (arrowOom != null) { + return arrowOom; + } + return failure; + } + return new RuntimeException("Stage " + graph.rootExecution().getStageId() + " " + terminal); + } + + /** + * If {@code failure}'s cause chain carries an Arrow allocator exhaustion — either as a real + * {@link OutOfMemoryException} (in-process) or as a {@link StreamException} whose message contains + * Arrow's allocator-refusal marker (post-Flight-RPC, where the wire envelope stripped the class) — + * return a fresh {@link CircuitBreakingException} (HTTP 429). Otherwise {@code null}. + * + *

    Arrow's {@code OutOfMemoryException} is the allocator's budget-refusal signal, not a JVM OOM, + * so it belongs in the same 429/back-pressure class as {@code CircuitBreakingException}. The string + * marker is Arrow's stable {@code BaseAllocator.wrapForeignAllocation} message ("Unable to allocate + * buffer ... due to memory limit"); this is the only producer of that exact prefix in Arrow Java. + */ + private static CircuitBreakingException arrowOomAsBreaker(Throwable failure) { + Throwable arrowOom = ExceptionsHelper.unwrap(failure, OutOfMemoryException.class); + if (arrowOom != null) { + return wrapAsBreaker("native memory allocation rejected: " + arrowOom.getMessage(), failure); + } + Throwable streamFailure = ExceptionsHelper.unwrap(failure, StreamException.class); + if (streamFailure instanceof StreamException se && carriesArrowOomMessage(se)) { + return wrapAsBreaker("native memory allocation rejected: " + se.getMessage(), failure); + } + return null; + } + + private static boolean carriesArrowOomMessage(StreamException se) { + String msg = se.getMessage(); + return msg != null && msg.contains("Unable to allocate buffer"); + } + + private static CircuitBreakingException wrapAsBreaker(String message, Throwable cause) { + CircuitBreakingException cbe = new CircuitBreakingException(message, CircuitBreaker.Durability.TRANSIENT); + cbe.initCause(cause); + return cbe; } /** Releases buffered terminal-sink batches. Arrow leak/double-release surfaces via {@link #runQuietly}. */ diff --git a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/QueryScheduler.java b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/QueryScheduler.java index 3c55e7749fc96..2a2c5846cc055 100644 --- a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/QueryScheduler.java +++ b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/QueryScheduler.java @@ -11,6 +11,7 @@ import org.apache.arrow.vector.VectorSchemaRoot; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.message.ParameterizedMessage; import org.opensearch.analytics.backend.AnalyticsOperationListener; import org.opensearch.analytics.exec.stage.StageExecution; import org.opensearch.analytics.exec.stage.StageExecutionBuilder; @@ -20,6 +21,8 @@ import org.opensearch.analytics.exec.task.TaskRunner; import org.opensearch.common.inject.Inject; import org.opensearch.core.action.ActionListener; +import org.opensearch.tasks.TaskManager; +import org.opensearch.transport.TransportService; import java.util.Map; import java.util.Optional; @@ -36,11 +39,13 @@ public class QueryScheduler implements Scheduler { private static final Logger logger = LogManager.getLogger(QueryScheduler.class); private final StageExecutionBuilder stageExecutionBuilder; + private final TaskManager taskManager; private final Map executions = new ConcurrentHashMap<>(); @Inject - public QueryScheduler(StageExecutionBuilder stageExecutionBuilder) { + public QueryScheduler(StageExecutionBuilder stageExecutionBuilder, TransportService transportService) { this.stageExecutionBuilder = stageExecutionBuilder; + this.taskManager = transportService.getTaskManager(); } @Override @@ -50,6 +55,7 @@ public QueryExecution execute(QueryContext context, ActionListener { opListener.onQueryFailure(queryId, e); listener.onFailure(e); - }), () -> executions.remove(queryId)); + }), () -> { + executions.remove(queryId); + // Cascade a cancel to dispatched data-node fragments before the framework unregisters + // the parent task. Otherwise TaskManager.setBan, which matches children by parent + // TaskId in the cancellable-tasks registry, cannot reach fragments after the parent + // has been unregistered — they survive into orphan state and run to natural + // completion. Idempotent against the already-cancelled path. + try { + taskManager.cancelTaskAndDescendants( + queryTask, + "analytics query terminal — cleaning up dispatched fragments", + false, + ActionListener.wrap( + v -> {}, + ex -> logger.debug( + new ParameterizedMessage("[QueryScheduler] orphan-cleanup cancel failed for queryId={}", queryId), + ex + ) + ) + ); + } catch (Exception ex) { + logger.debug(new ParameterizedMessage("[QueryScheduler] orphan-cleanup invocation failed for queryId={}", queryId), ex); + } + }); QueryExecution execution = new QueryExecution(context, graph, this::scheduleStage, wrapped); executions.put(queryId, execution); @@ -119,6 +148,14 @@ public void onResponse(Void unused) { public void onFailure(Exception cause) { Optional retry = stage.getState().isTerminal() ? Optional.empty() : stage.retargetForRetry(task, cause); if (retry.isPresent()) { + logger.debug( + () -> new ParameterizedMessage( + "[QueryScheduler] task {} on stage {} failed, retrying on {}", + task, + stage.getStageId(), + retry.get() + ) + ); currentAttempt.transitionTo(StageTaskState.FAILED); // previous attempt is now superseded StageTask r = retry.get(); currentAttempt = r; @@ -128,6 +165,15 @@ public void onFailure(Exception cause) { runner.run(r, this); // reuse this listener — retry loop until stage gives up return; } + logger.debug( + () -> new ParameterizedMessage( + "[QueryScheduler] task {} on stage {} failed, no retry available (stageTerminal={}, cause={})", + task, + stage.getStageId(), + stage.getState().isTerminal(), + cause.getMessage() + ) + ); currentAttempt.transitionTo(StageTaskState.FAILED); stage.onTaskTerminal(task, cause); } diff --git a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/RowProducingSink.java b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/RowProducingSink.java index 939e01feae944..b473139ee39f1 100644 --- a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/RowProducingSink.java +++ b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/RowProducingSink.java @@ -39,6 +39,9 @@ * {@code QueryPhaseResultConsumer} for coordinator-reduce in the core * search path. */ +// TODO: refactor this push-based flow — it's brittle with multiple failure points (feed racing +// close, partial buffering on early exit). Revisit the locking as part of that (e.g. tryLock with +// timeout); the current single-monitor synchronization is correct but worth reconsidering then. public class RowProducingSink implements ExchangeSink, ExchangeSource { /** @@ -54,6 +57,8 @@ public class RowProducingSink implements ExchangeSink, ExchangeSource { private final List fieldNames = new ArrayList<>(); private final long maxRows; private long totalRows; + /** Set by {@link #close}; a {@link #feed} after this frees the batch instead of buffering it. */ + private boolean closed; /** * Creates a sink with the default row limit. @@ -71,6 +76,11 @@ public RowProducingSink(long maxRows) { @Override public synchronized void feed(VectorSchemaRoot batch) { + // Feed racing close(): buffering now would strand the batch, so free it here instead. + if (closed) { + batch.close(); + return; + } if (fieldNames.isEmpty() && batch.getSchema().getFields().isEmpty() == false) { for (Field f : batch.getSchema().getFields()) { fieldNames.add(f.getName()); @@ -93,6 +103,7 @@ public synchronized void feed(VectorSchemaRoot batch) { */ @Override public synchronized void close() { + closed = true; for (VectorSchemaRoot batch : batches) { batch.close(); } diff --git a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/profile/QueryProfileBuilder.java b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/profile/QueryProfileBuilder.java index 744f034e1317c..b90404b195de9 100644 --- a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/profile/QueryProfileBuilder.java +++ b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/profile/QueryProfileBuilder.java @@ -57,6 +57,8 @@ public static QueryProfile snapshot(ExecutionGraph graph, QueryContext config, S String distribution = (stage != null && stage.getExchangeInfo() != null) ? stage.getExchangeInfo().distributionType().name() : null; + // TODO(plan-shape): also expose ALL post-fork alternatives + // (stage.getPlanAlternatives() -> each .resolvedFragment()) as `alternatives`. List fragment = stage != null && stage.getFragment() != null ? splitPlanLines(RelOptUtil.toString(stage.getFragment())) : List.of(); diff --git a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/stage/StageExecution.java b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/stage/StageExecution.java index 750dc5ff62944..2fffb540095c9 100644 --- a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/stage/StageExecution.java +++ b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/stage/StageExecution.java @@ -138,8 +138,9 @@ default void closeChildInput(int childStageId) {} * no per-child resources); decrements a counter; on zero, collects * {@link #publishedMetadata} from each child and hands off via {@link #consumeChildMetadata}; * default-mode parents are scheduled here (eager parents already scheduled). - *

  • FAILED — propagates via {@link #failWithCause}. - *
  • CANCELLED — intentionally not propagated (cancel initiator owns the parent's lifecycle). + *
  • FAILED — invokes {@link #closeChildInput} then propagates via {@link #failWithCause}. + *
  • CANCELLED — invokes {@link #closeChildInput} (so a parent reduce drain sees EOF and + * unwinds) then propagates {@link #cancel} to the parent so it can't strand in RUNNING. * * *

    Parent→sibling cancel sweep: on FAILED / CANCELLED, sweep still-running children. @@ -189,8 +190,15 @@ default void attachChildren(List children, Consumer { + closeChildInput(childId); + // A cancelled child can't produce a complete result, so the parent must reach + // terminal too — otherwise pending never drains and it strands in RUNNING (the + // phantom-task leak). Idempotent; no-op if the parent is already terminal. + cancel("child stage " + childId + " cancelled"); + } default -> { - } // CANCELLED intentionally not propagated + } } }); } diff --git a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/stage/shard/ShardFragmentStageExecution.java b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/stage/shard/ShardFragmentStageExecution.java index 4317113103ca7..9e34d602b84d1 100644 --- a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/stage/shard/ShardFragmentStageExecution.java +++ b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/stage/shard/ShardFragmentStageExecution.java @@ -99,6 +99,9 @@ public Optional retargetForRetry(StageTask failed, Exception cause) { if (nextCopy == null) { return Optional.empty(); } + // Update the resolved target so downstream stages (LM fetch) route to the node + // that will run the retry, not the original primary that failed. + config.updateResolvedTarget(getStageId(), shardTarget.ordinal(), nextCopy); return Optional.of(new ShardStageTask(shardTask.id(), nextCopy)); } diff --git a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/task/AnalyticsQueryTask.java b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/task/AnalyticsQueryTask.java index 69e7fce250164..13ff8606b438b 100644 --- a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/task/AnalyticsQueryTask.java +++ b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/task/AnalyticsQueryTask.java @@ -81,11 +81,19 @@ public void setOnCancelCallback(Runnable callback) { if (onCancelCallback.compareAndSet(null, callback) == false) { throw new IllegalStateException("onCancelCallback already set for AnalyticsQueryTask " + queryId); } + // Cancel may have arrived before this install — fire inline if so. Mirrors AnalyticsShardTask.setCancellationListener. + if (isCancelled()) { + runCallbackOnce(); + } } @Override protected void onCancelled() { - Runnable cb = onCancelCallback.get(); + runCallbackOnce(); + } + + private void runCallbackOnce() { + Runnable cb = onCancelCallback.getAndSet(null); if (cb != null) { try { cb.run(); diff --git a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/CapabilityRegistry.java b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/CapabilityRegistry.java index 397fdf1469699..b318bd90471d9 100644 --- a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/CapabilityRegistry.java +++ b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/CapabilityRegistry.java @@ -12,6 +12,7 @@ import org.opensearch.analytics.spi.AggregateFunction; import org.opensearch.analytics.spi.AnalyticsSearchBackendPlugin; import org.opensearch.analytics.spi.BackendCapabilityProvider; +import org.opensearch.analytics.spi.DelegatedPredicateSerializer; import org.opensearch.analytics.spi.DelegationType; import org.opensearch.analytics.spi.EngineCapability; import org.opensearch.analytics.spi.FieldStorageInfo; @@ -73,6 +74,12 @@ public class CapabilityRegistry { private final Map> delegationAcceptors = new HashMap<>(); private final Map> fullTextParamIndex = new HashMap<>(); + // Per-function delegated-predicate serializers, aggregated across backends (first declaration wins). + // Besides producing execution bytes, a serializer can report a predicate's referenced fields at + // planning time (referencedFields). Full-text is Lucene-only today, so a single function -> serializer + // map suffices. + private final Map predicateSerializers = new HashMap<>(); + private final Function fieldStorageFactory; // Backends that declared any capability for each operator — O(1) membership check @@ -92,6 +99,10 @@ public CapabilityRegistry( backendsByName.put(name, backend); BackendCapabilityProvider caps = backend.getCapabilityProvider(); + for (Map.Entry entry : caps.delegatedPredicateSerializers().entrySet()) { + predicateSerializers.putIfAbsent(entry.getKey(), entry.getValue()); + } + for (EngineCapability cap : caps.supportedEngineCapabilities()) { operatorIndex.computeIfAbsent(cap, k -> new ArrayList<>()).add(name); } @@ -303,6 +314,17 @@ public List opaqueBackendsAnyFormat(String name) { return allBackends(opaqueIndex.getOrDefault(name, Map.of())); } + /** + * The {@link DelegatedPredicateSerializer} registered for {@code function}, or {@code null} if no + * backend declared one. Besides producing execution bytes, the serializer exposes a predicate's + * referenced fields at planning time via {@link DelegatedPredicateSerializer#referencedFields}, + * which full-text field-type validation uses to enumerate the fields a relevance predicate + * references (including in-query-string fields). + */ + public DelegatedPredicateSerializer predicateSerializer(ScalarFunction function) { + return predicateSerializers.get(function); + } + // ---- Annotation handling ---- /** diff --git a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/IndexResolution.java b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/IndexResolution.java index 4cf9cdaee1b0c..7cdb19d7aec62 100644 --- a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/IndexResolution.java +++ b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/IndexResolution.java @@ -195,7 +195,7 @@ private static IndexResolution resolveAlias(String aliasName, List T findNode(RelNode node, Class type) { return null; } + /** + * Finds all nodes of the given type reachable from {@code node} (walks the full tree, all inputs). + * Unlike {@link #findNode} (which returns only the topmost match via the first-input chain), this + * sees every match — e.g. a WHERE filter below an Aggregate AND a HAVING filter above it, which + * do not merge (FILTER_MERGE does not cross the Aggregate). Order is pre-order (topmost first). + */ + @SuppressWarnings("unchecked") + public static List findAllNodes(RelNode node, Class type) { + List out = new ArrayList<>(); + if (type.isInstance(node)) { + out.add((T) node); + } + for (RelNode input : node.getInputs()) { + out.addAll(findAllNodes(input, type)); + } + return out; + } + /** * Qualified name of the first {@link OpenSearchTableScan} reachable from {@code node}, * searching all inputs. Returns {@code null} if none is present. @@ -211,7 +231,15 @@ private static boolean collectIndices(RelNode node, java.util.Set indice } if (node instanceof TableScan scan) { java.util.List names = scan.getTable().getQualifiedName(); - indices.add(names.get(names.size() - 1)); + String tableName = names.get(names.size() - 1); + // PPL multi-source queries (source=a,b) produce a single TableScan with a + // comma-delimited table name. Split so each index is evaluated independently + // by the security filter — same logic as IndexResolution. + for (String idx : Strings.splitStringByCommaToArray(tableName)) { + if (!idx.isEmpty()) { + indices.add(idx); + } + } } for (RelNode input : node.getInputs()) { if (!collectIndices(input, indices, depth + 1)) { diff --git a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/UnsupportedFunctionException.java b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/UnsupportedFunctionException.java new file mode 100644 index 0000000000000..164e8f09eb0ea --- /dev/null +++ b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/UnsupportedFunctionException.java @@ -0,0 +1,28 @@ +/* + * 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.analytics.planner; + +import org.opensearch.OpenSearchException; +import org.opensearch.core.rest.RestStatus; + +/** + * Thrown when a query uses a function that is not currently supported by the analytics engine. + * Maps to HTTP 400 (Bad Request) since this is a user error, not a server error. + */ +public class UnsupportedFunctionException extends OpenSearchException { + + public UnsupportedFunctionException(String functionName, String context) { + super("Function [" + functionName + "] is not currently supported" + (context != null ? " " + context : "")); + } + + @Override + public RestStatus status() { + return RestStatus.BAD_REQUEST; + } +} diff --git a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/dag/FragmentConversionDriver.java b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/dag/FragmentConversionDriver.java index bf9464a8463b4..80f6e814af173 100644 --- a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/dag/FragmentConversionDriver.java +++ b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/dag/FragmentConversionDriver.java @@ -113,7 +113,11 @@ private static void convertStage(Stage stage, CapabilityRegistry registry) { // Derive filter tree shape BEFORE stripping (annotations must be intact). The deriver // mirrors the combiner's post-combine shape so the data node's classification matches // the tree it actually receives. - OpenSearchFilter filter = RelNodeUtils.findNode(plan.resolvedFragment(), OpenSearchFilter.class); + // Pushdown+merge rules leave delegated annotations only in the bottommost (WHERE) filter; + // a HAVING stays in a separate un-delegated filter above the Aggregate. Pick the WHERE, + // not findNode's topmost (HAVING → NO_DELEGATION → collector skipped → over-count). + List filters = RelNodeUtils.findAllNodes(plan.resolvedFragment(), OpenSearchFilter.class); + OpenSearchFilter filter = filters.isEmpty() ? null : filters.getLast(); FilterTreeShape treeShape = filter != null ? FilterTreeShapeDeriver.derive(filter, plan.backendId()) : FilterTreeShape.NO_DELEGATION; diff --git a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/rules/OpenSearchAggregateRule.java b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/rules/OpenSearchAggregateRule.java index e988eaf9364e3..e03c8aa9cf19f 100644 --- a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/rules/OpenSearchAggregateRule.java +++ b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/rules/OpenSearchAggregateRule.java @@ -19,6 +19,7 @@ import org.opensearch.analytics.planner.CapabilityRegistry; import org.opensearch.analytics.planner.PlannerContext; import org.opensearch.analytics.planner.RelNodeUtils; +import org.opensearch.analytics.planner.UnsupportedFunctionException; import org.opensearch.analytics.planner.rel.AggregateCallAnnotation; import org.opensearch.analytics.planner.rel.AggregateMode; import org.opensearch.analytics.planner.rel.OpenSearchAggregate; @@ -87,7 +88,7 @@ public void onMatch(RelOptRuleCall call) { AggregateCall aggCall = aggCalls.get(i); List callViable = resolveViableBackendsForCall(aggCall, childFieldStorage); if (callViable.isEmpty()) { - throw new IllegalStateException("No backend supports aggregate function [" + aggCall.getAggregation().getName() + "]"); + throw new UnsupportedFunctionException(aggCall.getAggregation().getName(), "as an aggregate function"); } callAnnotations.put(i, new AggregateCallAnnotation(callViable, context.nextAnnotationId())); } @@ -97,12 +98,7 @@ public void onMatch(RelOptRuleCall call) { if (viableBackends.isEmpty()) { List funcNames = aggCalls.stream().map(aggCall -> aggCall.getAggregation().getName()).toList(); - throw new IllegalStateException( - "No backend can execute aggregate: functions " - + funcNames - + " not supported by any viable backend among " - + childViableBackends - ); + throw new UnsupportedFunctionException(funcNames.toString(), "as aggregate functions in combination"); } LOGGER.debug("Aggregate viable backends: {} (child viable: {})", viableBackends, childViableBackends); diff --git a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/rules/OpenSearchDistinctCountRule.java b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/rules/OpenSearchDistinctCountRule.java index 2ca5323dd17f1..56f8b9b3c0775 100644 --- a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/rules/OpenSearchDistinctCountRule.java +++ b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/rules/OpenSearchDistinctCountRule.java @@ -19,6 +19,7 @@ import org.apache.calcite.rex.RexNode; import org.apache.calcite.sql.SqlKind; import org.apache.calcite.sql.fun.SqlStdOperatorTable; +import org.apache.calcite.sql.type.SqlTypeName; import org.apache.calcite.tools.RelBuilder; import java.util.ArrayList; @@ -58,9 +59,14 @@ public void onMatch(RelOptRuleCall ruleCall) { } } if (!changed) return; + + // Widen sub-32-bit integer args to INTEGER so DataFusion uses HLL (Binary state) + // instead of its bitmap accumulator (List state) which our exchange contract doesn't support. + RelNode input = widenSmallIntArgs(ruleCall, agg.getInput(), rewritten); + LogicalAggregate replacement = (LogicalAggregate) agg.copy( agg.getTraitSet(), - agg.getInput(), + input, agg.getGroupSet(), agg.getGroupSets(), rewritten @@ -111,6 +117,48 @@ private static boolean isPplDistinctCountApproxUdf(AggregateCall call) { && call.getArgList().size() == 1; } + /** + * If any APPROX_COUNT_DISTINCT arg references a sub-32-bit integer column (TINYINT/SMALLINT), + * insert a Project that casts those columns to INTEGER. This forces DataFusion to use the + * HLL accumulator (Binary state) instead of the bitmap accumulator (List state). + */ + private static RelNode widenSmallIntArgs(RelOptRuleCall ruleCall, RelNode input, List calls) { + List fields = input.getRowType().getFieldList(); + boolean needsWiden = false; + for (AggregateCall call : calls) { + if (call.getAggregation() == SqlStdOperatorTable.APPROX_COUNT_DISTINCT) { + for (int argIdx : call.getArgList()) { + SqlTypeName typeName = fields.get(argIdx).getType().getSqlTypeName(); + if (typeName == SqlTypeName.TINYINT || typeName == SqlTypeName.SMALLINT) { + needsWiden = true; + break; + } + } + } + if (needsWiden) break; + } + if (!needsWiden) return input; + + RelBuilder builder = ruleCall.builder(); + builder.push(input); + RexBuilder rexBuilder = builder.getRexBuilder(); + RelDataType intType = rexBuilder.getTypeFactory().createSqlType(SqlTypeName.INTEGER); + List projects = new ArrayList<>(fields.size()); + List names = new ArrayList<>(fields.size()); + for (int i = 0; i < fields.size(); i++) { + RelDataTypeField field = fields.get(i); + RexNode ref = rexBuilder.makeInputRef(input, i); + SqlTypeName typeName = field.getType().getSqlTypeName(); + if (typeName == SqlTypeName.TINYINT || typeName == SqlTypeName.SMALLINT) { + ref = rexBuilder.makeCast(intType, ref); + } + projects.add(ref); + names.add(field.getName()); + } + builder.project(projects, names, true); + return builder.build(); + } + private static AggregateCall rewriteToApprox(AggregateCall call, LogicalAggregate agg) { return AggregateCall.create( SqlStdOperatorTable.APPROX_COUNT_DISTINCT, diff --git a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/rules/OpenSearchFilterRule.java b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/rules/OpenSearchFilterRule.java index 6c71c9929c923..11a00b65b22f1 100644 --- a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/rules/OpenSearchFilterRule.java +++ b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/rules/OpenSearchFilterRule.java @@ -25,7 +25,9 @@ import org.opensearch.analytics.planner.rel.OpenSearchFilter; import org.opensearch.analytics.planner.rel.OpenSearchRelNode; import org.opensearch.analytics.settings.DelegationBlockList; +import org.opensearch.analytics.spi.DelegatedPredicateSerializer; import org.opensearch.analytics.spi.DelegationType; +import org.opensearch.analytics.spi.FieldReferences; import org.opensearch.analytics.spi.FieldStorageInfo; import org.opensearch.analytics.spi.FieldType; import org.opensearch.analytics.spi.ScalarFunction; @@ -166,8 +168,75 @@ private List resolveViableBackends( if (fieldIndices.isEmpty()) { // Multi-field full-text functions (multi_match, query_string, simple_query_string) // encode field names as string literals in nested MAPs rather than RexInputRef. - // Resolve viability against any backend that supports the function on text fields. + // Extract the literal field names and resolve viability per-field using the actual + // FieldStorageInfo lookup — same code path as RexInputRef-based fields. This ensures + // that, e.g., query_string(['severityNumber'], ...) on an INTEGER field doesn't get + // routed to a backend that only declared (QUERY_STRING, TEXT) capability. if (function.getCategory() == ScalarFunction.Category.FULL_TEXT) { + if (TextRelevanceFieldValidator.usesLiteralFieldEncoding(function)) { + // A backend that declares full-text capability for these multi-field functions + // MUST register a DelegatedPredicateSerializer whose referencedFields() surfaces + // the fields named inside the query string (not just the `fields` MAP). A missing + // serializer (or one that does not implement referencedFields) is a wiring error, + // not a query error — fail explicitly rather than under-validating. + DelegatedPredicateSerializer serializer = registry.predicateSerializer(function); + FieldReferences refs = serializer == null ? null : serializer.referencedFields(predicate, fieldStorageInfos); + if (refs == null) { + throw new IllegalStateException( + "No field-reference extraction available for full-text function [" + + predicate.getOperator().getName() + + "]. A backend declaring this function's filter capability must provide a" + + " DelegatedPredicateSerializer that implements referencedFields()." + ); + } + List literalFieldNames = refs.literalFields(); + boolean lenient = refs.lenient(); + if (literalFieldNames.isEmpty()) { + // No explicit literal fields to type-check: only patterns and/or default-field + // fan-out, which OpenSearch resolves best-effort at execution. Fall back to the + // TEXT-type assumption and let the full-text-capable backend handle it. + return new ArrayList<>(registry.filterBackendsAnyFormat(function, FieldType.TEXT)); + } + // Eagerly reject text-relevance functions on non-text/keyword fields, unless the + // caller explicitly set lenient=true + if (lenient == false) { + TextRelevanceFieldValidator.rejectNonTextFieldsForTextFunction( + predicate.getOperator().getName(), + literalFieldNames, + fieldStorageInfos + ); + } + Set viableSet = new HashSet<>(registry.filterCapableBackends()); + for (String fieldName : literalFieldNames) { + FieldStorageInfo storageInfo = null; + for (FieldStorageInfo info : fieldStorageInfos) { + if (fieldName.equals(info.getFieldName())) { + storageInfo = info; + break; + } + } + if (storageInfo == null) { + // An explicitly-named literal field absent from the scan's schema is an + // unknown field. (Wildcard/regex field tokens never reach here: they are + // classified as patterns and handled by the empty-literals branch above.) + throw new IllegalArgumentException("Field [" + fieldName + "] not found."); + } + viableSet.retainAll(registry.filterBackendsForField(function, storageInfo)); + } + if (viableSet.isEmpty()) { + // No viable alternatives is a client error (IllegalArgumentException -> HTTP 400) so the actionable + // message is surfaced to the caller instead of being redacted as a 500. + throw new IllegalArgumentException( + "No backend can evaluate filter predicate [" + + predicate.getKind() + + "] on literal-named fields " + + literalFieldNames + ); + } + return new ArrayList<>(viableSet); + } + // FULL_TEXT but not a literal-field-encoding function (e.g. QUERY no-field variant, + // MATCHALL): no explicit field list to validate — fall back to TEXT type assumption. return new ArrayList<>(registry.filterBackendsAnyFormat(function, FieldType.TEXT)); } // No field reference (non-deterministic, or an unfoldable constant like diff --git a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/rules/OpenSearchProjectRule.java b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/rules/OpenSearchProjectRule.java index ab5e75e631fb8..7f6971ceec7dd 100644 --- a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/rules/OpenSearchProjectRule.java +++ b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/rules/OpenSearchProjectRule.java @@ -20,6 +20,7 @@ import org.opensearch.analytics.planner.CapabilityRegistry; import org.opensearch.analytics.planner.PlannerContext; import org.opensearch.analytics.planner.RelNodeUtils; +import org.opensearch.analytics.planner.UnsupportedFunctionException; import org.opensearch.analytics.planner.rel.AnnotatedProjectExpression; import org.opensearch.analytics.planner.rel.OpenSearchProject; import org.opensearch.analytics.planner.rel.OpenSearchRelNode; @@ -91,7 +92,11 @@ public void onMatch(RelOptRuleCall call) { } if (viableBackends.isEmpty()) { - throw new IllegalStateException("No backend can execute all project expressions among " + childViableBackends); + List funcNames = annotatedExprs.stream() + .filter(e -> e instanceof RexCall) + .map(e -> ((RexCall) e).getOperator().getName()) + .toList(); + throw new UnsupportedFunctionException(funcNames.toString(), "in combination in this query"); } call.transformTo( @@ -131,7 +136,7 @@ private RexNode annotateExpr(RexNode expr, List childViableBackends) { if (isOpaqueOperation(funcName)) { List exprViable = resolveOpaqueViableBackends(funcName, childViableBackends); if (exprViable.isEmpty()) { - throw new IllegalStateException("No backend can evaluate [" + funcName + "] and no delegation path exists"); + throw new UnsupportedFunctionException(funcName, null); } return new AnnotatedProjectExpression(rexCall.getType(), rexCall, exprViable, context.nextAnnotationId()); } @@ -142,7 +147,7 @@ private RexNode annotateExpr(RexNode expr, List childViableBackends) { if (scalarViable.isEmpty()) { ScalarFunction resolved = ScalarFunction.fromSqlOperatorWithFallback(rexCall.getOperator()); String label = resolved != null ? resolved.name() : rexCall.getOperator().getName(); - throw new IllegalStateException("No backend supports scalar function [" + label + "] among " + childViableBackends); + throw new UnsupportedFunctionException(label, "as a scalar function"); } // Recurse into operands @@ -291,7 +296,7 @@ private static Set collectWindowFunctions(List narrowByWindowCapability(List candidates, SetMulti-field full-text functions ({@code multi_match}, {@code query_string}, + * {@code simple_query_string}) encode field names as string literals inside a nested + * {@code MAP_VALUE_CONSTRUCTOR} rather than through {@link org.apache.calcite.rex.RexInputRef}. + * The backend's {@code DelegatedPredicateSerializer.referencedFields} surfaces those names; this class rejects any that + * resolve to a non-text/keyword mapping so users get a precise, actionable planning error (naming + * the field and its type) instead of a generic "no backend can evaluate" failure later in the + * pipeline. + * + *

    Extracted from {@link OpenSearchFilterRule} to keep that rule focused on predicate annotation + * and viable-backend computation. + * + * @opensearch.internal + */ +public final class TextRelevanceFieldValidator { + + /** + * The multi-field/literal-MAP text-relevance functions that encode their field list as string + * literals in a nested MAP. Single-field {@code MATCH} variants are intentionally excluded — + * they reference their field through {@link org.apache.calcite.rex.RexInputRef} and so flow + * through the standard field-index path instead. + */ + private static final Set LITERAL_FIELD_TEXT_FUNCTIONS = Set.of( + ScalarFunction.QUERY_STRING, + ScalarFunction.SIMPLE_QUERY_STRING, + ScalarFunction.MULTI_MATCH + ); + + /** + * Field types a text-relevance function may be applied to: {@code text} family ∪ {@code keyword} + * family. Built once at class init from {@link FieldType#text()} and {@link FieldType#keyword()} + * rather than per validation call. + */ + private static final Set ALLOWED_TEXT_FIELD_TYPES; + + static { + EnumSet allowed = EnumSet.noneOf(FieldType.class); + allowed.addAll(FieldType.text()); + allowed.addAll(FieldType.keyword()); + ALLOWED_TEXT_FIELD_TYPES = allowed; + } + + private TextRelevanceFieldValidator() { + // utility class + } + + /** + * Returns whether {@code function} encodes its field list as string literals in a nested MAP + * (i.e. is one of {@code query_string}, {@code simple_query_string}, {@code multi_match}). Only + * for these functions should {@link #rejectNonTextFieldsForTextFunction} be applied; other + * full-text functions either reference fields via {@code RexInputRef} or carry no explicit + * field list. + */ + public static boolean usesLiteralFieldEncoding(ScalarFunction function) { + return LITERAL_FIELD_TEXT_FUNCTIONS.contains(function); + } + + /** + * Validates that all explicitly named fields used in a text-relevance function + * (e.g. {@code query_string}, {@code simple_query_string}, {@code multi_match}) + * are mapped as {@code text} or {@code keyword}. Throws a descriptive + * {@link IllegalArgumentException} naming the offending field, its mapping type, + * and the function — surfaced at planning so users get a clear, actionable error + * instead of a generic "no backend can evaluate" failure later in the pipeline. + * + *

    Fields whose storage info cannot be resolved (unknown columns, dynamic + * mappings) are skipped — they fall through to the existing capability-based + * matching where they get the conservative TEXT-type assumption. + */ + public static void rejectNonTextFieldsForTextFunction( + String functionName, + List fieldNames, + List fieldStorageInfos + ) { + for (String fieldName : fieldNames) { + FieldStorageInfo storageInfo = findStorageByFieldName(fieldStorageInfos, fieldName); + if (storageInfo == null) { + continue; + } + FieldType type = storageInfo.getFieldType(); + if (type != null && ALLOWED_TEXT_FIELD_TYPES.contains(type) == false) { + String mappingType = storageInfo.getMappingType() != null ? storageInfo.getMappingType() : "unknown"; + throw new IllegalArgumentException( + "Text-relevance function [" + + functionName + + "] cannot be applied to field [" + + fieldName + + "] of type [" + + mappingType + + "]. Only text and keyword fields are supported. " + + "Use a typed comparison (e.g. [" + + fieldName + + " > value]) for non-text fields." + ); + } + } + } + + /** + * Looks up a {@link FieldStorageInfo} by field name. Returns null if no match. + */ + private static FieldStorageInfo findStorageByFieldName(List fieldStorageInfos, String fieldName) { + for (FieldStorageInfo info : fieldStorageInfos) { + if (fieldName.equals(info.getFieldName())) { + return info; + } + } + return null; + } +} diff --git a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/AnalyticsSearchTransportServiceTests.java b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/AnalyticsSearchTransportServiceTests.java index 864dce4bffa20..cbdf8327aedd3 100644 --- a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/AnalyticsSearchTransportServiceTests.java +++ b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/AnalyticsSearchTransportServiceTests.java @@ -19,15 +19,14 @@ import org.opensearch.analytics.exec.action.FragmentExecutionAction; import org.opensearch.analytics.exec.action.FragmentExecutionArrowResponse; import org.opensearch.analytics.exec.action.FragmentExecutionRequest; -import org.opensearch.cluster.ClusterState; import org.opensearch.cluster.node.DiscoveryNode; -import org.opensearch.cluster.node.DiscoveryNodes; import org.opensearch.cluster.service.ClusterService; import org.opensearch.indices.IndicesService; import org.opensearch.tasks.Task; import org.opensearch.tasks.TaskResourceTrackingService; import org.opensearch.test.OpenSearchTestCase; import org.opensearch.threadpool.ThreadPool; +import org.opensearch.transport.ConnectTransportException; import org.opensearch.transport.StreamTransportService; import org.opensearch.transport.Transport; import org.opensearch.transport.TransportResponseHandler; @@ -40,9 +39,11 @@ import static java.util.Collections.singletonList; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -111,7 +112,11 @@ public void onFailure(Exception e) {} if (!isClosed(lastRoot)) { lastRoot.close(); } - verify(stream).close(); + // Consumer threw mid-stream → abnormal exit → cancel() (not plain close()) so the data-node + // producer is notified and tears down its FlightServerChannel; close() alone would strand + // the producer's streamRoot in the allocator ledger. + verify(stream).cancel(anyString(), any()); + verify(stream, never()).close(); } } @@ -125,15 +130,6 @@ private TransportResponseHandler captureHandler( ClusterService clusterService = mock(ClusterService.class); TaskResourceTrackingService taskResourceTrackingService = mock(TaskResourceTrackingService.class); - // getConnection(null, nodeId) -> clusterService.state().nodes().get(nodeId) -> getConnection(node) - DiscoveryNode node = mock(DiscoveryNode.class); - DiscoveryNodes nodes = mock(DiscoveryNodes.class); - ClusterState state = mock(ClusterState.class); - when(clusterService.state()).thenReturn(state); - when(state.nodes()).thenReturn(nodes); - when(nodes.get(any())).thenReturn(node); - when(transportService.getConnection(node)).thenReturn(mock(Transport.Connection.class)); - AnalyticsSearchTransportService service = new AnalyticsSearchTransportService( transportService, clusterService, @@ -157,8 +153,10 @@ private TransportResponseHandler captureHandler( handlerCaptor.capture() ); + // dispatch looks up the connection directly from the passed DiscoveryNode (no cluster-state re-resolution). DiscoveryNode target = mock(DiscoveryNode.class); when(target.getId()).thenReturn("node-1"); + when(transportService.getConnection(target)).thenReturn(mock(Transport.Connection.class)); service.dispatchFragmentStreaming( mock(FragmentExecutionRequest.class), target, @@ -172,6 +170,40 @@ private TransportResponseHandler captureHandler( return handlers.get(handlers.size() - 1); } + /** + * Node churn: the target node has left the cluster by the time we dispatch (a null + * {@link DiscoveryNode}). The connection lookup must surface a clean {@link ConnectTransportException} + * — NOT a NullPointerException ("Cannot invoke Object.hashCode() because key is null") that escapes + * from the connection manager. Guards the production NPE seen under node churn. + */ + public void testGetConnectionWithNullNodeThrowsConnectExceptionNotNpe() { + StreamTransportService transportService = mock(StreamTransportService.class); + AnalyticsSearchService searchService = mock(AnalyticsSearchService.class); + IndicesService indicesService = mock(IndicesService.class); + ClusterService clusterService = mock(ClusterService.class); + TaskResourceTrackingService taskResourceTrackingService = mock(TaskResourceTrackingService.class); + + AnalyticsSearchTransportService service = new AnalyticsSearchTransportService( + transportService, + clusterService, + searchService, + indicesService, + taskResourceTrackingService + ); + + Exception thrown = expectThrows(ConnectTransportException.class, () -> service.getConnection(null)); + assertFalse( + "must not be a NullPointerException (got " + thrown + ")", + thrown instanceof NullPointerException || thrown.getCause() instanceof NullPointerException + ); + + // And a present node is looked up directly via the stream transport (no cluster-state re-resolution). + DiscoveryNode node = mock(DiscoveryNode.class); + Transport.Connection conn = mock(Transport.Connection.class); + when(transportService.getConnection(node)).thenReturn(conn); + assertSame(conn, service.getConnection(node)); + } + private static VectorSchemaRoot newIntRoot(BufferAllocator allocator, String name, int value) { Field field = new Field(name, FieldType.nullable(new ArrowType.Int(32, true)), null); Schema schema = new Schema(singletonList(field)); diff --git a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/AnalyticsTransportErrorsTests.java b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/AnalyticsTransportErrorsTests.java new file mode 100644 index 0000000000000..13e4f0153bb99 --- /dev/null +++ b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/AnalyticsTransportErrorsTests.java @@ -0,0 +1,168 @@ +/* + * 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.analytics.exec; + +import org.opensearch.OpenSearchStatusException; +import org.opensearch.core.common.breaker.CircuitBreaker; +import org.opensearch.core.common.breaker.CircuitBreakingException; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.core.tasks.TaskCancelledException; +import org.opensearch.test.OpenSearchTestCase; +import org.opensearch.transport.stream.StreamErrorCode; +import org.opensearch.transport.stream.StreamException; + +/** + * Tests the shard-send / coordinator-receive error-mapping pair in {@link AnalyticsTransportErrors}. + */ +public class AnalyticsTransportErrorsTests extends OpenSearchTestCase { + + // ── toWireError (data-node send side) ───────────────────────────────── + + public void testToWireErrorTagsBreakerAsResourceExhausted() { + CircuitBreakingException breaker = new CircuitBreakingException("pool full", 100, 50, CircuitBreaker.Durability.TRANSIENT); + + Exception wire = AnalyticsTransportErrors.toWireError(breaker); + + assertTrue(wire instanceof StreamException); + assertEquals(StreamErrorCode.RESOURCE_EXHAUSTED, ((StreamException) wire).getErrorCode()); + assertEquals("pool full", wire.getMessage()); + } + + public void testToWireErrorTagsAny429OpenSearchException() { + OpenSearchStatusException admission = new OpenSearchStatusException("admission rejected", RestStatus.TOO_MANY_REQUESTS); + + Exception wire = AnalyticsTransportErrors.toWireError(admission); + + assertTrue(wire instanceof StreamException); + assertEquals(StreamErrorCode.RESOURCE_EXHAUSTED, ((StreamException) wire).getErrorCode()); + } + + public void testToWireErrorFindsBreakerInCauseChain() { + Exception wrapped = new RuntimeException( + "Stage 0 failed", + new CircuitBreakingException("nested", 1, 1, CircuitBreaker.Durability.TRANSIENT) + ); + + Exception wire = AnalyticsTransportErrors.toWireError(wrapped); + + assertTrue(wire instanceof StreamException); + assertEquals(StreamErrorCode.RESOURCE_EXHAUSTED, ((StreamException) wire).getErrorCode()); + } + + public void testToWireErrorPassesThroughNonResourceFailure() { + Exception other = new IllegalStateException("not a breaker"); + assertSame(other, AnalyticsTransportErrors.toWireError(other)); + } + + public void testToWireErrorTagsTaskCancelledAsCancelled() { + TaskCancelledException cancelled = new TaskCancelledException("sbp cancel"); + + Exception wire = AnalyticsTransportErrors.toWireError(cancelled); + + assertTrue(wire instanceof StreamException); + assertEquals(StreamErrorCode.CANCELLED, ((StreamException) wire).getErrorCode()); + assertEquals("sbp cancel", wire.getMessage()); + } + + public void testToWireErrorFindsTaskCancelledInCauseChain() { + Exception wrapped = new RuntimeException("Stage 0 failed", new TaskCancelledException("sbp cancel nested")); + + Exception wire = AnalyticsTransportErrors.toWireError(wrapped); + + assertTrue(wire instanceof StreamException); + assertEquals(StreamErrorCode.CANCELLED, ((StreamException) wire).getErrorCode()); + } + + public void testToWireErrorIsCycleSafe() { + // A self-referential cause chain must not loop forever (ExceptionsHelper uses an identity set). + Exception a = new RuntimeException("a"); + Exception b = new RuntimeException("b", a); + a.initCause(b); + assertSame(a, AnalyticsTransportErrors.toWireError(a)); + } + + // ── fromWireError (coordinator receive side) ────────────────────────── + + public void testFromWireErrorRebuildsBreakerFromResourceExhausted() { + StreamException wire = new StreamException(StreamErrorCode.RESOURCE_EXHAUSTED, "pool full on shard"); + + Exception recovered = AnalyticsTransportErrors.fromWireError(wire); + + assertTrue(recovered instanceof CircuitBreakingException); + assertEquals(RestStatus.TOO_MANY_REQUESTS, ((CircuitBreakingException) recovered).status()); + assertEquals("pool full on shard", recovered.getMessage()); + } + + public void testFromWireErrorMapsUnavailableTo503() { + StreamException wire = new StreamException(StreamErrorCode.UNAVAILABLE, "Network closed for unknown reason"); + + Exception recovered = AnalyticsTransportErrors.fromWireError(wire); + + assertTrue(recovered instanceof OpenSearchStatusException); + assertEquals(RestStatus.SERVICE_UNAVAILABLE, ((OpenSearchStatusException) recovered).status()); + } + + public void testFromWireErrorFindsCodeInCauseChain() { + Exception wrapped = new RuntimeException("Stage 0 failed", new StreamException(StreamErrorCode.RESOURCE_EXHAUSTED, "breaker")); + + Exception recovered = AnalyticsTransportErrors.fromWireError(wrapped); + + assertTrue(recovered instanceof CircuitBreakingException); + assertEquals(RestStatus.TOO_MANY_REQUESTS, ((CircuitBreakingException) recovered).status()); + } + + public void testFromWireErrorPassesThroughOtherCodes() { + StreamException wire = new StreamException(StreamErrorCode.INTERNAL, "boom"); + assertSame(wire, AnalyticsTransportErrors.fromWireError(wire)); + } + + public void testFromWireErrorRebuildsTaskCancelledFromCancelled() { + StreamException wire = new StreamException(StreamErrorCode.CANCELLED, "task cancelled by search backpressure"); + + Exception recovered = AnalyticsTransportErrors.fromWireError(wire); + + assertTrue( + "CANCELLED must surface as TaskCancelledException, not ISE (got " + recovered.getClass().getName() + ")", + recovered instanceof TaskCancelledException + ); + assertEquals("task cancelled by search backpressure", recovered.getMessage()); + } + + public void testFromWireErrorPassesThroughNonStreamException() { + Exception other = new IllegalArgumentException("bad query"); + assertSame(other, AnalyticsTransportErrors.fromWireError(other)); + } + + // ── round-trip ──────────────────────────────────────────────────────── + + public void testBreakerSurvivesRoundTripAs429() { + CircuitBreakingException breaker = new CircuitBreakingException("limit hit", 100, 50, CircuitBreaker.Durability.TRANSIENT); + + // shard tags it → (Flight would carry the code) → coordinator rebuilds it. + Exception onWire = AnalyticsTransportErrors.toWireError(breaker); + Exception recovered = AnalyticsTransportErrors.fromWireError(onWire); + + assertTrue(recovered instanceof CircuitBreakingException); + assertEquals(RestStatus.TOO_MANY_REQUESTS, ((CircuitBreakingException) recovered).status()); + assertEquals("limit hit", recovered.getMessage()); + } + + public void testTaskCancelledSurvivesRoundTripAsTaskCancelled() { + TaskCancelledException cancelled = new TaskCancelledException("sbp cancel"); + + Exception onWire = AnalyticsTransportErrors.toWireError(cancelled); + Exception recovered = AnalyticsTransportErrors.fromWireError(onWire); + + assertTrue( + "round-tripped CANCELLED must remain TaskCancelledException (got " + recovered.getClass().getName() + ")", + recovered instanceof TaskCancelledException + ); + assertEquals("sbp cancel", recovered.getMessage()); + } +} diff --git a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/ArrowValuesTests.java b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/ArrowValuesTests.java index 77f3b3088bfc7..6914a1fddca93 100644 --- a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/ArrowValuesTests.java +++ b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/ArrowValuesTests.java @@ -11,8 +11,12 @@ import org.apache.arrow.memory.BufferAllocator; import org.apache.arrow.memory.RootAllocator; import org.apache.arrow.vector.BigIntVector; +import org.apache.arrow.vector.BitVector; import org.apache.arrow.vector.DateDayVector; import org.apache.arrow.vector.DateMilliVector; +import org.apache.arrow.vector.FieldVector; +import org.apache.arrow.vector.Float8Vector; +import org.apache.arrow.vector.IntVector; import org.apache.arrow.vector.TimeMicroVector; import org.apache.arrow.vector.TimeMilliVector; import org.apache.arrow.vector.TimeNanoVector; @@ -21,7 +25,9 @@ import org.apache.arrow.vector.TimeStampMilliVector; import org.apache.arrow.vector.TimeStampNanoVector; import org.apache.arrow.vector.TimeStampSecVector; +import org.apache.arrow.vector.VarBinaryVector; import org.apache.arrow.vector.VarCharVector; +import org.apache.arrow.vector.VectorSchemaRoot; import org.apache.arrow.vector.complex.ListVector; import org.apache.arrow.vector.complex.impl.UnionListWriter; import org.apache.arrow.vector.types.TimeUnit; @@ -31,6 +37,10 @@ import org.opensearch.test.OpenSearchTestCase; import java.util.List; +import java.util.Map; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; /** Null, BigInt pass-through, VarChar, Date/Time/Timestamp formatting (scalar + list). */ public class ArrowValuesTests extends OpenSearchTestCase { @@ -212,4 +222,110 @@ public void testListOfTimeElementsHasNoEpochPrefix() { assertEquals(List.of("07:40:05"), cell); } } + + // ---- toSourceValue / toSourceMap (moved from GetService.NativeBridgeExecutor) ---- + + public void testToSourceValueNullReturnsNull() { + try (BigIntVector v = new BigIntVector("x", allocator)) { + v.allocateNew(1); + v.setNull(0); + v.setValueCount(1); + assertNull(ArrowValues.toSourceValue(v, 0)); + } + } + + public void testToSourceValueBinaryDropped() { + try (VarBinaryVector v = new VarBinaryVector("b", allocator)) { + v.allocateNew(); + v.setSafe(0, new byte[] { 1, 2, 3 }); + v.setValueCount(1); + assertNull(ArrowValues.toSourceValue(v, 0)); + } + } + + public void testToSourceValueUtf8AsString() { + try (VarCharVector v = new VarCharVector("s", allocator)) { + v.allocateNew(); + v.setSafe(0, "hello".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + v.setValueCount(1); + assertEquals("hello", ArrowValues.toSourceValue(v, 0)); + } + } + + public void testToSourceValueIntAsLong() { + try (IntVector v = new IntVector("n", allocator)) { + v.allocateNew(1); + v.set(0, 42); + v.setValueCount(1); + assertEquals(42L, ArrowValues.toSourceValue(v, 0)); + } + } + + public void testToSourceValueFloatingPointAsDouble() { + try (Float8Vector v = new Float8Vector("d", allocator)) { + v.allocateNew(1); + v.set(0, 3.5d); + v.setValueCount(1); + assertEquals(3.5d, (double) ArrowValues.toSourceValue(v, 0), 0.0d); + } + } + + public void testToSourceValueBoolAsBoolean() { + try (BitVector v = new BitVector("flag", allocator)) { + v.allocateNew(1); + v.set(0, 1); + v.setValueCount(1); + assertEquals(Boolean.TRUE, ArrowValues.toSourceValue(v, 0)); + } + } + + /** Standard timestamp vectors decode to LocalDateTime; toSourceValue renders that via toString() (ISO-8601, 'T' separator, no 'Z'). */ + public void testToSourceValueTimestampLocalDateTimeToString() { + try (TimeStampMilliVector v = new TimeStampMilliVector("ts", allocator)) { + v.allocateNew(1); + v.set(0, 1_780_731_605_000L); + v.setValueCount(1); + assertEquals("2026-06-06T07:40:05", ArrowValues.toSourceValue(v, 0)); + } + } + + /** When a Timestamp cell decodes to a raw Number, toSourceValue formats it as ISO-8601 UTC (with 'Z') across all units. */ + public void testToSourceValueTimestampNumberRendersIsoUtc() { + assertEquals("2026-06-06T07:40:05Z", numericTimestampSource(1_780_731_605L, TimeUnit.SECOND)); + assertEquals("2026-06-06T07:40:05.123Z", numericTimestampSource(1_780_731_605_123L, TimeUnit.MILLISECOND)); + assertEquals("2026-06-06T07:40:05.123456Z", numericTimestampSource(1_780_731_605_123_456L, TimeUnit.MICROSECOND)); + assertEquals("2026-06-06T07:40:05.123456789Z", numericTimestampSource(1_780_731_605_123_456_789L, TimeUnit.NANOSECOND)); + } + + /** Builds a Timestamp-typed vector whose cell decodes to a raw Long, exercising the numeric ISO branch + toInstant. */ + private static Object numericTimestampSource(long raw, TimeUnit unit) { + FieldVector vec = mock(FieldVector.class); + when(vec.isNull(0)).thenReturn(false); + when(vec.getField()).thenReturn(Field.nullable("ts", new ArrowType.Timestamp(unit, null))); + when(vec.getObject(0)).thenReturn(raw); + return ArrowValues.toSourceValue(vec, 0); + } + + public void testToSourceMapDropsNullsAndPreservesFieldOrder() { + VarCharVector name = new VarCharVector("name", allocator); + IntVector age = new IntVector("age", allocator); + VarCharVector missing = new VarCharVector("missing", allocator); + name.allocateNew(); + name.setSafe(0, "alice".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + name.setValueCount(1); + age.allocateNew(1); + age.set(0, 30); + age.setValueCount(1); + missing.allocateNew(); + missing.setNull(0); + missing.setValueCount(1); + // VectorSchemaRoot.of takes ownership; closing root closes the child vectors. + try (VectorSchemaRoot root = VectorSchemaRoot.of(name, age, missing)) { + Map out = ArrowValues.toSourceMap(root, 0); + assertEquals(2, out.size()); + assertEquals("alice", out.get("name")); + assertEquals(30L, out.get("age")); + assertFalse(out.containsKey("missing")); + } + } } diff --git a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/QueryExecutionTests.java b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/QueryExecutionTests.java index 28b6c9bd020d9..99b18e90bede0 100644 --- a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/QueryExecutionTests.java +++ b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/QueryExecutionTests.java @@ -8,7 +8,9 @@ package org.opensearch.analytics.exec; +import org.apache.arrow.memory.OutOfMemoryException; import org.apache.arrow.vector.VectorSchemaRoot; +import org.opensearch.OpenSearchException; import org.opensearch.analytics.backend.ExchangeSource; import org.opensearch.analytics.exec.stage.DataProducer; import org.opensearch.analytics.exec.stage.StageExecution; @@ -21,12 +23,19 @@ import org.opensearch.analytics.planner.dag.StageExecutionType; import org.opensearch.analytics.spi.ExchangeSink; import org.opensearch.core.action.ActionListener; +import org.opensearch.core.common.breaker.CircuitBreaker; +import org.opensearch.core.common.breaker.CircuitBreakingException; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.core.tasks.TaskCancelledException; import org.opensearch.core.tasks.TaskId; import org.opensearch.test.OpenSearchTestCase; +import org.opensearch.transport.stream.StreamErrorCode; +import org.opensearch.transport.stream.StreamException; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.concurrent.CompletionException; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; @@ -184,6 +193,264 @@ public void testFailedTransitionStillFiresOriginalFailureWhenTerminalSinkCloseTh assertSame("original stage failure must reach the listener even when terminal sink close throws", rootCause, onFailure.get()); } + public void testBareBreakerMasqueradingAsCancelSurfacesAs429() { + // The exact shape reproduced live on 91-d: the breaker is the root stage's captured failure + // (DatafusionReduceSink.reduce → onTaskTerminal → captureFailure), and the same trip then + // cancels the parent task via the child cancel sweep so isCancelled() is already true. + // terminalCause must surface the breaker (HTTP 429), not mask it as TaskCancelledException (500). + Stage rootStage = stageWithId(0); + TestRootExecution root = new TestRootExecution(rootStage, new CountingCloseSink()); + builder.registerFactory(StageExecutionType.LOCAL_PASSTHROUGH, (stage, s, cfg) -> root); + + AnalyticsQueryTask task = newTask(); + task.cancel("circuit breaker sweep cancelled the parent task"); + + AtomicReference onFailure = new AtomicReference<>(); + newQueryExecution(rootStage, ActionListener.wrap(r -> fail("unexpected success"), onFailure::set), task); + + CircuitBreakingException breaker = new CircuitBreakingException( + "[analytics_backend_datafusion] Failed to allocate 64 bytes (limit: 7101482993)", + 64, + 7101482993L, + CircuitBreaker.Durability.TRANSIENT + ); + root.failWith(breaker); + + Exception surfaced = onFailure.get(); + assertSame("breaker must be surfaced, not masked by the self-cancel", breaker, surfaced); + assertEquals("breaker maps to HTTP 429", RestStatus.TOO_MANY_REQUESTS, ((OpenSearchException) surfaced).status()); + } + + public void testWrappedBreakerUnderCancelStillUnwrapsTo429() { + // Defends the cause-walk: even if the breaker arrives nested (e.g. a CompletionException from + // an async→sync future.join() bridge) under a cancelled parent task, it must still be unwrapped. + Stage rootStage = stageWithId(0); + TestRootExecution root = new TestRootExecution(rootStage, new CountingCloseSink()); + builder.registerFactory(StageExecutionType.LOCAL_PASSTHROUGH, (stage, s, cfg) -> root); + + AnalyticsQueryTask task = newTask(); + task.cancel("circuit breaker sweep cancelled the parent task"); + + AtomicReference onFailure = new AtomicReference<>(); + newQueryExecution(rootStage, ActionListener.wrap(r -> fail("unexpected success"), onFailure::set), task); + + CircuitBreakingException breaker = new CircuitBreakingException( + "[analytics_backend_datafusion] Failed to allocate 327680 bytes (limit: 4194304)", + 327680, + 4194304, + CircuitBreaker.Durability.TRANSIENT + ); + root.failWith(new CompletionException(breaker)); + + Exception surfaced = onFailure.get(); + assertSame("nested breaker must be unwrapped, not masked by cancellation", breaker, surfaced); + assertEquals("breaker maps to HTTP 429", RestStatus.TOO_MANY_REQUESTS, ((OpenSearchException) surfaced).status()); + } + + public void testBareBreakerFailureSurfacesAs429() { + // A breaker captured directly as the root failure (parent task NOT cancelled) is surfaced as-is + // via the unchanged non-cancel path. + Stage rootStage = stageWithId(0); + TestRootExecution root = new TestRootExecution(rootStage, new CountingCloseSink()); + builder.registerFactory(StageExecutionType.LOCAL_PASSTHROUGH, (stage, s, cfg) -> root); + + AtomicReference onFailure = new AtomicReference<>(); + newQueryExecution(rootStage, ActionListener.wrap(r -> fail("unexpected success"), onFailure::set)); + + CircuitBreakingException breaker = new CircuitBreakingException("Failed to allocate", CircuitBreaker.Durability.TRANSIENT); + root.failWith(breaker); + + Exception surfaced = onFailure.get(); + assertSame(breaker, surfaced); + assertEquals(RestStatus.TOO_MANY_REQUESTS, ((OpenSearchException) surfaced).status()); + } + + public void testGenuineCancelWithNoStageFailureStaysTaskCancelled() { + // No stage recorded a failure (getFailure()==null) and the parent task is cancelled: this is a + // real top-down cancel and must remain a TaskCancelledException (HTTP 500) — proving the fix + // does not turn genuine cancellations into 429. + Stage rootStage = stageWithId(0); + TestRootExecution root = new TestRootExecution(rootStage, new CountingCloseSink()); + builder.registerFactory(StageExecutionType.LOCAL_PASSTHROUGH, (stage, s, cfg) -> root); + + AnalyticsQueryTask task = newTask(); + task.cancel("user aborted the request"); + + AtomicReference onFailure = new AtomicReference<>(); + QueryExecution qe = newQueryExecution(rootStage, ActionListener.wrap(r -> fail("unexpected success"), onFailure::set), task); + qe.cancelAll("user aborted the request"); + + Exception surfaced = onFailure.get(); + assertTrue("genuine cancel must stay TaskCancelledException", surfaced instanceof TaskCancelledException); + assertEquals("cancellation maps to HTTP 500", RestStatus.INTERNAL_SERVER_ERROR, ((OpenSearchException) surfaced).status()); + } + + public void testNonBreakerFailureWithCancelledParentStaysTaskCancelled() { + // A non-breaker stage failure that also cancelled the parent task keeps the original cancel-first + // behavior: the peek finds no breaker, so we report TaskCancelledException (HTTP 500). Option B + // only diverts the breaker case; every other cancelled path is unchanged. + Stage rootStage = stageWithId(0); + TestRootExecution root = new TestRootExecution(rootStage, new CountingCloseSink()); + builder.registerFactory(StageExecutionType.LOCAL_PASSTHROUGH, (stage, s, cfg) -> root); + + AnalyticsQueryTask task = newTask(); + task.cancel("sibling sweep cancelled the parent task"); + + AtomicReference onFailure = new AtomicReference<>(); + newQueryExecution(rootStage, ActionListener.wrap(r -> fail("unexpected success"), onFailure::set), task); + + root.failWith(new RuntimeException("non-memory stage failure")); + + Exception surfaced = onFailure.get(); + assertTrue("non-breaker failure under a cancel stays TaskCancelledException", surfaced instanceof TaskCancelledException); + } + + // ── Arrow OutOfMemoryException translation ────────────────────────── + // Same shape as Garov's CircuitBreakingException tests, but for Arrow Java's allocator-budget + // refusal (org.apache.arrow.memory.OutOfMemoryException — NOT a JVM OOM). Arrow OOM is the + // signal that an allocation was rejected against native.allocator.pool.query.max; it belongs + // in the same back-pressure / 429 class as CircuitBreakingException so FailAwareWeightedRouting + // skips replica retry. terminalCause translates it to CircuitBreakingException at the coordinator. + + public void testBareArrowOomMasqueradingAsCancelSurfacesAs429() { + // Mirrors the live failure on dev (332-big5): an Arrow allocator trip fails the reduce stage + // and the same sweep cancels the parent task, so isCancelled() is already true by the time we + // report. terminalCause must translate the Arrow OOM to CircuitBreakingException (HTTP 429), + // not mask it as TaskCancelledException (HTTP 500). + Stage rootStage = stageWithId(0); + TestRootExecution root = new TestRootExecution(rootStage, new CountingCloseSink()); + builder.registerFactory(StageExecutionType.LOCAL_PASSTHROUGH, (stage, s, cfg) -> root); + + AnalyticsQueryTask task = newTask(); + task.cancel("arrow allocator sweep cancelled the parent task"); + + AtomicReference onFailure = new AtomicReference<>(); + newQueryExecution(rootStage, ActionListener.wrap(r -> fail("unexpected success"), onFailure::set), task); + + OutOfMemoryException arrowOom = new OutOfMemoryException( + "Unable to allocate buffer of size 128 (rounded from 80) due to memory limit. Current allocation: 277821480" + ); + root.failWith(arrowOom); + + Exception surfaced = onFailure.get(); + assertTrue("Arrow OOM must be translated to CircuitBreakingException", surfaced instanceof CircuitBreakingException); + assertEquals("translated breaker maps to HTTP 429", RestStatus.TOO_MANY_REQUESTS, ((OpenSearchException) surfaced).status()); + assertSame("original Arrow OOM must be preserved as cause", arrowOom, surfaced.getCause()); + assertTrue("translated message must preserve the Arrow OOM detail", surfaced.getMessage().contains("Unable to allocate buffer")); + } + + public void testWrappedArrowOomUnderCancelStillUnwrapsTo429() { + // Mirrors the in-process Arrow OOM path: ArrayImporter wraps the OOM in IllegalArgumentException + // ("Could not load buffers for field cnt[hll_registers]") before it propagates up. Even nested + // under a cancelled parent task, the cause-walk must surface it as 429. + Stage rootStage = stageWithId(0); + TestRootExecution root = new TestRootExecution(rootStage, new CountingCloseSink()); + builder.registerFactory(StageExecutionType.LOCAL_PASSTHROUGH, (stage, s, cfg) -> root); + + AnalyticsQueryTask task = newTask(); + task.cancel("arrow allocator sweep cancelled the parent task"); + + AtomicReference onFailure = new AtomicReference<>(); + newQueryExecution(rootStage, ActionListener.wrap(r -> fail("unexpected success"), onFailure::set), task); + + OutOfMemoryException arrowOom = new OutOfMemoryException("Unable to allocate buffer of size 98304"); + IllegalArgumentException wrapped = new IllegalArgumentException("Could not load buffers for field cnt[hll_registers]", arrowOom); + root.failWith(wrapped); + + Exception surfaced = onFailure.get(); + assertTrue("wrapped Arrow OOM must still translate to CircuitBreakingException", surfaced instanceof CircuitBreakingException); + assertEquals(RestStatus.TOO_MANY_REQUESTS, ((OpenSearchException) surfaced).status()); + assertTrue(surfaced.getMessage().contains("Unable to allocate buffer")); + } + + public void testBareArrowOomFailureSurfacesAs429() { + // Coordinator-local Arrow OOM (no cancel sweep): a direct in-process materialization + // OOM with parent task NOT cancelled must still translate to 429 via the non-cancel + // path so client/router treat it as back-pressure. + Stage rootStage = stageWithId(0); + TestRootExecution root = new TestRootExecution(rootStage, new CountingCloseSink()); + builder.registerFactory(StageExecutionType.LOCAL_PASSTHROUGH, (stage, s, cfg) -> root); + + AtomicReference onFailure = new AtomicReference<>(); + newQueryExecution(rootStage, ActionListener.wrap(r -> fail("unexpected success"), onFailure::set)); + + OutOfMemoryException arrowOom = new OutOfMemoryException("Unable to allocate buffer of size 4194304"); + root.failWith(arrowOom); + + Exception surfaced = onFailure.get(); + assertTrue(surfaced instanceof CircuitBreakingException); + assertEquals(RestStatus.TOO_MANY_REQUESTS, ((OpenSearchException) surfaced).status()); + assertSame(arrowOom, surfaced.getCause()); + } + + public void testStreamExceptionCarryingArrowOomMessageTranslatesTo429() { + // The cross-Flight-RPC path: shard-side Arrow OOM travels over Flight, the wire envelope strips + // the original OutOfMemoryException class, coordinator receives StreamException[INTERNAL] whose + // message contains Arrow's "Unable to allocate buffer ..." marker. terminalCause must still + // surface this as CircuitBreakingException (HTTP 429), not as the raw 500 StreamException. + Stage rootStage = stageWithId(0); + TestRootExecution root = new TestRootExecution(rootStage, new CountingCloseSink()); + builder.registerFactory(StageExecutionType.LOCAL_PASSTHROUGH, (stage, s, cfg) -> root); + + AtomicReference onFailure = new AtomicReference<>(); + newQueryExecution(rootStage, ActionListener.wrap(r -> fail("unexpected success"), onFailure::set)); + + StreamException se = new StreamException( + StreamErrorCode.INTERNAL, + "Could not load buffers for field cnt[hll_registers]: Binary not null. error message: " + + "Unable to allocate buffer of size 98304 due to memory limit. Current allocation: 197104" + ); + root.failWith(se); + + Exception surfaced = onFailure.get(); + assertTrue("post-RPC Arrow OOM must translate to CircuitBreakingException", surfaced instanceof CircuitBreakingException); + assertEquals("translated breaker maps to HTTP 429", RestStatus.TOO_MANY_REQUESTS, ((OpenSearchException) surfaced).status()); + assertSame("original StreamException must be preserved as cause", se, surfaced.getCause()); + assertTrue("translated message must preserve the Arrow OOM detail", surfaced.getMessage().contains("Unable to allocate buffer")); + } + + public void testStreamExceptionWithoutArrowOomMessageSurfacesUnchanged() { + // INTERNAL-code StreamException whose message is NOT an Arrow allocator failure (e.g. a Rust + // panic surfaced via cross_rt_stream.rs) must NOT be re-classified as back-pressure — operators + // need 500 so it pages and FailAwareWeightedRouting can retry on a replica. + Stage rootStage = stageWithId(0); + TestRootExecution root = new TestRootExecution(rootStage, new CountingCloseSink()); + builder.registerFactory(StageExecutionType.LOCAL_PASSTHROUGH, (stage, s, cfg) -> root); + + AtomicReference onFailure = new AtomicReference<>(); + newQueryExecution(rootStage, ActionListener.wrap(r -> fail("unexpected success"), onFailure::set)); + + StreamException se = new StreamException( + StreamErrorCode.INTERNAL, + "java.lang.RuntimeException: Execution error: Panic: byte array offset overflow" + ); + root.failWith(se); + + Exception surfaced = onFailure.get(); + assertSame("non-Arrow-OOM StreamException must pass through unchanged", se, surfaced); + assertFalse(surfaced instanceof CircuitBreakingException); + } + + public void testNonArrowOomFailureSurfacesUnchanged() { + // An unrelated stage failure (e.g. engine-bug-class — a Rust panic surfaced as a + // RuntimeException) must NOT be re-classified as back-pressure. It needs HTTP 500 + // so operators see a real "fix this" signal and FailAwareWeightedRouting can retry + // it on a replica. + Stage rootStage = stageWithId(0); + TestRootExecution root = new TestRootExecution(rootStage, new CountingCloseSink()); + builder.registerFactory(StageExecutionType.LOCAL_PASSTHROUGH, (stage, s, cfg) -> root); + + AtomicReference onFailure = new AtomicReference<>(); + newQueryExecution(rootStage, ActionListener.wrap(r -> fail("unexpected success"), onFailure::set)); + + RuntimeException panic = new RuntimeException("Execution error: Panic: byte array offset overflow"); + root.failWith(panic); + + Exception surfaced = onFailure.get(); + assertSame("non-Arrow-OOM failure must pass through unchanged", panic, surfaced); + assertFalse(surfaced instanceof CircuitBreakingException); + } + // ── helpers ───────────────────────────────────────────────────────── private QueryExecution newQueryExecution(Stage rootStage, ActionListener> listener) { @@ -192,6 +459,21 @@ private QueryExecution newQueryExecution(Stage rootStage, ActionListener> listener, + AnalyticsQueryTask task + ) { + QueryDAG dag = new QueryDAG("q-test", rootStage); + QueryContext ctx = QueryContext.forTest(dag, task); + ExecutionGraph graph = ExecutionGraph.build(ctx, builder, StageExecution::start); + return new QueryExecution(ctx, graph, StageExecution::start, listener); + } + + private static AnalyticsQueryTask newTask() { + return new AnalyticsQueryTask(1L, "transport", "analytics_query", "q-test", TaskId.EMPTY_TASK_ID, java.util.Map.of(), null); + } + private static Stage stageWithId(int id) { Stage stage = mock(Stage.class); when(stage.getStageId()).thenReturn(id); diff --git a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/QuerySchedulerTests.java b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/QuerySchedulerTests.java index 68ac11fd456dc..52a2b3178178d 100644 --- a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/QuerySchedulerTests.java +++ b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/QuerySchedulerTests.java @@ -19,7 +19,9 @@ import org.opensearch.analytics.exec.task.TaskRunner; import org.opensearch.analytics.planner.dag.ExecutionTarget; import org.opensearch.core.action.ActionListener; +import org.opensearch.tasks.TaskManager; import org.opensearch.test.OpenSearchTestCase; +import org.opensearch.transport.TransportService; import java.util.ArrayList; import java.util.List; @@ -28,6 +30,7 @@ import java.util.concurrent.atomic.AtomicReference; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; /** * Unit tests for {@link QueryScheduler#scheduleStage} and {@link QueryScheduler#handleFor}. @@ -41,7 +44,9 @@ public class QuerySchedulerTests extends OpenSearchTestCase { @Override public void setUp() throws Exception { super.setUp(); - scheduler = new QueryScheduler(mock(StageExecutionBuilder.class)); + TransportService transportService = mock(TransportService.class); + when(transportService.getTaskManager()).thenReturn(mock(TaskManager.class)); + scheduler = new QueryScheduler(mock(StageExecutionBuilder.class), transportService); } /** Happy path: scheduler iterates tasks, transitions each to RUNNING, runs them. */ @@ -199,6 +204,101 @@ public void testTerminalStageSkipsRetryAndPropagatesOriginalCause() { } } + /** + * Simulates the "No ReaderContext" bug scenario: task cancelled on data node (stream error) → + * retry on replica succeeds → verify that currentAttempt (the retry) is different from the + * original task when onResponse fires. This is the condition that triggers updateResolvedTarget + * in the fix (QueryScheduler checks currentAttempt != task after retry success). + */ + public void testRetryAfterCancelledStreamUsesNewAttemptOnSuccess() { + StageTask originalTask = makeTask(0); + FakeStage stage = new FakeStage(originalTask); + StageTask retryTask = makeTask(0); + stage.retryQueue.add(Optional.of(retryTask)); + + scheduler.scheduleStage(stage); + ActionListener handle = stage.runner.dispatched.get(0).handle; + + // Data node cancels the stream — propagates as TaskCancelledException to coordinator + handle.onFailure(new org.opensearch.core.tasks.TaskCancelledException("query cancelled")); + + // Retry dispatched to replica + assertEquals("retry dispatched after cancellation", 2, stage.runner.dispatched.size()); + assertSame("retry task dispatched", retryTask, stage.runner.dispatched.get(1).task); + assertEquals("original marked FAILED", StageTaskState.FAILED, originalTask.state()); + assertEquals("retry is RUNNING", StageTaskState.RUNNING, retryTask.state()); + + // Retry succeeds on replica — this is where updateResolvedTarget should fire + // because currentAttempt (retryTask) != task (originalTask) + handle.onResponse(null); + assertEquals("retry FINISHED", StageTaskState.FINISHED, retryTask.state()); + assertEquals("terminal called once", 1, stage.terminalCalls.get()); + assertNull("success — null cause", stage.lastTerminalCause.get()); + + // The fix checks: if (currentAttempt != task && stage instanceof ShardFragmentStageExecution) + // Here currentAttempt == retryTask, task == originalTask → they're different → fix triggers. + assertNotSame("currentAttempt differs from original — updateResolvedTarget condition met", originalTask, retryTask); + } + + /** + * Verifies that {@link QueryContext#updateResolvedTarget} correctly replaces a stale + * primary target with the replica target after a successful retry. This is the data-layer + * fix for the "No ReaderContext" bug — the LM fetch uses getResolvedTargets() to decide + * where to send fetch requests, so the map must reflect the node that actually ran the query. + */ + public void testUpdateResolvedTargetReplacesStaleEntry() { + org.opensearch.cluster.node.DiscoveryNode primaryNode = new org.opensearch.cluster.node.DiscoveryNode( + "primary", + buildNewFakeTransportAddress(), + java.util.Collections.emptyMap(), + java.util.Collections.emptySet(), + org.opensearch.Version.CURRENT + ); + org.opensearch.cluster.node.DiscoveryNode replicaNode = new org.opensearch.cluster.node.DiscoveryNode( + "replica", + buildNewFakeTransportAddress(), + java.util.Collections.emptyMap(), + java.util.Collections.emptySet(), + org.opensearch.Version.CURRENT + ); + org.opensearch.core.index.shard.ShardId shardId = new org.opensearch.core.index.shard.ShardId("test_idx", "uuid", 0); + + org.opensearch.analytics.planner.dag.ShardExecutionTarget primaryTarget = + new org.opensearch.analytics.planner.dag.ShardExecutionTarget(primaryNode, shardId, 0); + org.opensearch.analytics.planner.dag.ShardExecutionTarget replicaTarget = + new org.opensearch.analytics.planner.dag.ShardExecutionTarget(replicaNode, shardId, 0); + + // Simulate recordResolvedTargets at plan time — records primary + QueryContext config = mock(QueryContext.class); + java.util.Map targetMap = new java.util.HashMap<>(); + targetMap.put(0, primaryTarget); + org.mockito.Mockito.when(config.getResolvedTargets(0)).thenReturn(targetMap); + org.mockito.Mockito.doAnswer(invocation -> { + int stageId = invocation.getArgument(0); + int ordinal = invocation.getArgument(1); + org.opensearch.analytics.planner.dag.ShardExecutionTarget target = invocation.getArgument(2); + targetMap.put(ordinal, target); + return null; + }) + .when(config) + .updateResolvedTarget( + org.mockito.ArgumentMatchers.anyInt(), + org.mockito.ArgumentMatchers.anyInt(), + org.mockito.ArgumentMatchers.any() + ); + + // Before fix: target points to primary + assertSame("before retry, target points to primary", primaryTarget, targetMap.get(0)); + + // Simulate updateResolvedTarget after retry succeeds on replica + config.updateResolvedTarget(0, 0, replicaTarget); + + // After fix: target now points to replica + assertSame("after updateResolvedTarget, target points to replica", replicaTarget, targetMap.get(0)); + assertNotSame("target no longer points to primary", primaryTarget, targetMap.get(0)); + assertEquals("replica node is the fetch target", "replica", targetMap.get(0).node().getId()); + } + // ─── helpers ────────────────────────────────────────────────────────── private static StageTask makeTask(int partitionId) { diff --git a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/RowProducingSinkTests.java b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/RowProducingSinkTests.java index 90b5183f8eafd..85a0ff6157d1e 100644 --- a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/RowProducingSinkTests.java +++ b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/RowProducingSinkTests.java @@ -203,6 +203,26 @@ public void testUnlimitedSinkRetainsAllBatches() { sink.close(); } + // ─── Feed racing close (leak fix) ──────────────────────────────────── + + /** + * A {@code feed} after {@code close} must free the batch immediately rather than buffer it + * (close already cleared the list and will not run again, so a buffered batch would leak). + */ + public void testFeedAfterCloseClosesBatchAndDoesNotBuffer() { + RowProducingSink sink = new RowProducingSink(); + sink.close(); + + VectorSchemaRoot late = makeVsr(List.of("id"), new Object[][] { { "1" } }); + assertTrue("batch holds buffers before the late feed", allocator.getAllocatedMemory() > 0); + + sink.feed(late); + + assertEquals("late batch must be closed, not buffered", 0, allocator.getAllocatedMemory()); + assertEquals("late batch must not count toward rows", 0, sink.getRowCount()); + assertFalse("nothing retained from a post-close feed", sink.readResult().iterator().hasNext()); + } + // ─── Helpers ──────────────────────────────────────────────────────── private VectorSchemaRoot makeVsr(List fieldNames, Object[][] rows) { diff --git a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/stage/AttachChildrenTests.java b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/stage/AttachChildrenTests.java index 8713f4d128213..818e3ffa6f9d2 100644 --- a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/stage/AttachChildrenTests.java +++ b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/stage/AttachChildrenTests.java @@ -92,12 +92,14 @@ public void testFailedChildPropagatesDirectlyToParent() { } /** - * Early-termination contract: when a parent cancels its own child (e.g. coordinator-side - * LIMIT satisfied, stop the shard stream), the child transitions to CANCELLED. The - * cascade must NOT propagate that cancel back to the parent — the parent is the one - * who issued the cancel and must stay RUNNING. + * Cancelled-child contract: the cascade closes the parent's per-child input (so a parent reduce + * drain blocked on {@code streamNext} sees EOF and unwinds) AND propagates cancel to the parent + * so it reaches a terminal state. Without the propagation the parent strands in RUNNING, its + * pending count never drains, the root never mirrors to the query, and the coordinator task is + * never unregistered (the phantom-task leak). The parent must NOT be scheduled (nothing to run) + * and must NOT be failed (cancel is not a failure). */ - public void testCancelledChildIsNotPropagatedToParent() { + public void testCancelledChildClosesInputAndPropagatesCancelToParent() { StageExecution parent = mock(StageExecution.class, CALLS_REAL_METHODS); FakeChild cancelled = new FakeChild(5); // no failure recorded @@ -106,7 +108,10 @@ public void testCancelledChildIsNotPropagatedToParent() { parent.attachChildren(List.of(cancelled), scheduler); cancelled.fire(StageExecution.State.CANCELLED); - verify(parent, never()).cancel(any()); + // EOF released to the reduce input (the leak fix). + verify(parent).closeChildInput(eq(5)); + // Cancel IS propagated so the parent reaches terminal (phantom-task fix); not failed, not metadata-consumed. + verify(parent).cancel(any()); verify(parent, never()).failWithCause(any()); verify(parent, never()).consumeChildMetadata(any()); } @@ -134,6 +139,67 @@ public void testSiblingsAreCancelledWhenParentReachesFailedTerminal() { assertNull("already-terminal sibling must not be re-cancelled", alreadyDone.cancelReason); } + // ---- Phantom-task probes: does a CANCELLED child strand the parent (never terminal)? ---- + // A parent that never reaches terminal never fires mirrorRootStateToQuery → the coordinator + // query task never unregisters → phantom task (observed as a query/ppl task stuck for hours + // with zero CPU). These tests pin which child-state combinations strand the parent. + + /** PROBE 1: one child CANCELLED, sibling still RUNNING. Does the parent ever reach terminal? */ + public void testCancelledChildWithRunningSiblingDoesNotStrandParent() { + FakeChild cancelled = new FakeChild(1); + FakeChild stillRunning = new FakeChild(2); // stays RUNNING, never fires terminal + FakeParent parent = new FakeParent(99); + + parent.attachChildren(List.of(cancelled, stillRunning), stage -> {}); + cancelled.fire(StageExecution.State.CANCELLED); + + assertTrue( + "parent must reach a terminal state after a child is cancelled; otherwise the coordinator " + + "query task never unregisters (phantom). parent state=" + + parent.fakeState, + parent.fakeState.isTerminal() + ); + } + + /** PROBE 2: ALL children CANCELLED (full bottom-up cancel of leaves). Parent terminal? */ + public void testAllChildrenCancelledDrivesParentTerminal() { + FakeChild a = new FakeChild(1); + FakeChild b = new FakeChild(2); + FakeParent parent = new FakeParent(99); + + parent.attachChildren(List.of(a, b), stage -> {}); + a.fire(StageExecution.State.CANCELLED); + b.fire(StageExecution.State.CANCELLED); + + assertTrue( + "parent must reach a terminal state when all children are cancelled. parent state=" + parent.fakeState, + parent.fakeState.isTerminal() + ); + } + + /** + * PROBE 3: one CANCELLED + one SUCCEEDED. The cancelled child must still account for its pending + * slot so the count drains to 0; with a cancelled child present the parent reaches CANCELLED + * (terminal) rather than being scheduled — there is nothing left to run. + */ + public void testCancelledPlusSucceededChildDrivesParentTerminalNotScheduled() { + FakeChild cancelled = new FakeChild(1); + FakeChild succeeded = new FakeChild(2); + FakeParent parent = new FakeParent(99); + AtomicReference scheduled = new AtomicReference<>(); + + parent.attachChildren(List.of(cancelled, succeeded), scheduled::set); + cancelled.fire(StageExecution.State.CANCELLED); + succeeded.fire(StageExecution.State.SUCCEEDED); + + assertTrue( + "parent must reach a terminal state (not strand) once all children are accounted for; " + "parent state=" + parent.fakeState, + parent.fakeState.isTerminal() + ); + assertEquals("parent with a cancelled child must terminate as CANCELLED", StageExecution.State.CANCELLED, parent.fakeState); + assertNull("parent must NOT be scheduled when a child was cancelled", scheduled.get()); + } + /** * Eager (streaming) parents must be scheduled as soon as the first child transitions * to RUNNING — they need to run concurrently with their children's feeds (e.g. a diff --git a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/stage/OperationListenerCoverageTests.java b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/stage/OperationListenerCoverageTests.java index d770ea2b77242..6429585b6de50 100644 --- a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/stage/OperationListenerCoverageTests.java +++ b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/stage/OperationListenerCoverageTests.java @@ -21,7 +21,9 @@ import org.opensearch.cluster.service.ClusterService; import org.opensearch.core.action.ActionListener; import org.opensearch.core.tasks.TaskId; +import org.opensearch.tasks.TaskManager; import org.opensearch.test.OpenSearchTestCase; +import org.opensearch.transport.TransportService; import java.util.ArrayList; import java.util.Collections; @@ -161,7 +163,9 @@ private void executeViaScheduler( ); QueryDAG dag = new QueryDAG("q-test", rootStage); QueryContext ctx = QueryContext.forTest(dag, task, listeners); - new QueryScheduler(builder).execute(ctx, userListener); + TransportService transportService = mock(TransportService.class); + when(transportService.getTaskManager()).thenReturn(mock(TaskManager.class)); + new QueryScheduler(builder, transportService).execute(ctx, userListener); } private static Stage stageWithId(int id) { diff --git a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/task/AnalyticsQueryTaskCancellationCallbackTests.java b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/task/AnalyticsQueryTaskCancellationCallbackTests.java new file mode 100644 index 0000000000000..9061f84326474 --- /dev/null +++ b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/exec/task/AnalyticsQueryTaskCancellationCallbackTests.java @@ -0,0 +1,64 @@ +/* + * 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.analytics.exec.task; + +import org.opensearch.core.tasks.TaskId; +import org.opensearch.test.OpenSearchTestCase; + +import java.util.Collections; +import java.util.concurrent.atomic.AtomicInteger; + +public class AnalyticsQueryTaskCancellationCallbackTests extends OpenSearchTestCase { + + private AnalyticsQueryTask createTask() { + return new AnalyticsQueryTask(1L, "type", "action", "queryId", TaskId.EMPTY_TASK_ID, Collections.emptyMap()); + } + + public void testCallbackFiresOnCancellation() { + AnalyticsQueryTask task = createTask(); + AtomicInteger callCount = new AtomicInteger(); + task.setOnCancelCallback(callCount::incrementAndGet); + + task.cancel("test reason"); + + assertEquals(1, callCount.get()); + } + + public void testCallbackFiresImmediatelyIfAlreadyCancelled() { + AnalyticsQueryTask task = createTask(); + task.cancel("cancel before install"); + + AtomicInteger callCount = new AtomicInteger(); + task.setOnCancelCallback(callCount::incrementAndGet); + + assertEquals("callback must fire when installed after cancel", 1, callCount.get()); + } + + public void testCallbackFiresExactlyOnce() { + AnalyticsQueryTask task = createTask(); + AtomicInteger callCount = new AtomicInteger(); + task.setOnCancelCallback(callCount::incrementAndGet); + + task.cancel("first"); + task.cancel("second"); + + assertEquals(1, callCount.get()); + } + + public void testNullCallbackDoesNotThrowOnCancellation() { + AnalyticsQueryTask task = createTask(); + task.cancel("no callback installed"); + } + + public void testSecondSetThrows() { + AnalyticsQueryTask task = createTask(); + task.setOnCancelCallback(() -> {}); + expectThrows(IllegalStateException.class, () -> task.setOnCancelCallback(() -> {})); + } +} diff --git a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/AggregateRuleTests.java b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/AggregateRuleTests.java index 173abbf91f99b..c3c02da62e943 100644 --- a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/AggregateRuleTests.java +++ b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/AggregateRuleTests.java @@ -103,8 +103,11 @@ protected Set aggregateCapabilities() { } }; PlannerContext context = buildContext("parquet", 1, intFields(), List.of(noAggFunctions)); - IllegalStateException exception = expectThrows(IllegalStateException.class, () -> runPlanner(makeAggregate(sumCall()), context)); - assertTrue(exception.getMessage().contains("No backend supports aggregate function")); + UnsupportedFunctionException exception = expectThrows( + UnsupportedFunctionException.class, + () -> runPlanner(makeAggregate(sumCall()), context) + ); + assertTrue(exception.getMessage().contains("is not currently supported")); } public void testAggregateViableBackendsIntersection() { @@ -270,11 +273,11 @@ protected Set aggregateCapabilities() { // No acceptedDelegations() override → delegation is refused. }; PlannerContext context = buildContext("parquet", 1, intFields(), List.of(dfNoSum, luceneWithSum)); - IllegalStateException exception = expectThrows( - IllegalStateException.class, + UnsupportedFunctionException exception = expectThrows( + UnsupportedFunctionException.class, () -> runPlanner(makeMultiCallAggregate(sumCall(), stddevCall()), context) ); - assertTrue(exception.getMessage().contains("No backend supports aggregate function")); + assertTrue(exception.getMessage().contains("is not currently supported")); } // ---- Helpers ---- @@ -391,6 +394,29 @@ public void testPplDistinctCountApproxUdfRewrittenWithCastProject() { assertFalse("isDistinct must be cleared on the rewritten call", rebuilt.isDistinct()); } + /** + * COUNT(DISTINCT smallint_col) inserts a widening CAST(col AS INTEGER) below the aggregate + * so DataFusion uses HLL (Binary state) instead of bitmap (List state). + */ + public void testCountDistinctOnSmallintWidensToInteger() { + RelNode scan = stubScan( + mockTable("test_index", new String[] { "status", "size" }, new SqlTypeName[] { SqlTypeName.INTEGER, SqlTypeName.SMALLINT }) + ); + AggregateCall countDistinct = AggregateCall.create( + SqlStdOperatorTable.COUNT, + true, + List.of(1), + -1, + scan, + typeFactory.createSqlType(SqlTypeName.BIGINT), + "dc" + ); + RelNode result = runPlanner(makeAggregate(scan, countDistinct), defaultContext(1)); + String plan = RelOptUtil.toString(result); + logger.info("Full plan with SMALLINT dc:\n{}", plan); + assertTrue("Plan must contain CAST to widen SMALLINT to INTEGER", plan.contains("CAST") && plan.contains("INTEGER")); + } + /** * Stdop {@code APPROX_COUNT_DISTINCT} (already canonical) must not be rewritten — the rule's * predicate excludes the stdop, so no Project wrap is added and the result is a plain diff --git a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/FilterRuleTests.java b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/FilterRuleTests.java index 8879057cace67..145a095a428f1 100644 --- a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/FilterRuleTests.java +++ b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/FilterRuleTests.java @@ -31,10 +31,15 @@ import org.opensearch.analytics.settings.PlannerSettings; import org.opensearch.analytics.spi.AnalyticsSearchBackendPlugin; import org.opensearch.analytics.spi.BackendCapabilityProvider; +import org.opensearch.analytics.spi.DelegatedPredicateSerializer; import org.opensearch.analytics.spi.DelegationType; import org.opensearch.analytics.spi.EngineCapability; +import org.opensearch.analytics.spi.FieldReferences; +import org.opensearch.analytics.spi.FieldStorageInfo; import org.opensearch.analytics.spi.ScalarFunction; +import java.math.BigDecimal; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Set; @@ -312,6 +317,300 @@ public void testErrorForUnsupportedFieldTypeOperatorCombo() { assertTrue(exception.getMessage().contains("has no storage")); } + // ---- Text-relevance function on non-text field type-check ---- + + /** + * {@code query_string(['severityNumber'], '...')} on a {@code long} field is rejected + * at planning with a precise, actionable error message — text-relevance functions + * cannot be applied to numeric fields. + */ + public void testQueryStringOnNumericFieldIsRejected() { + RelOptTable table = mockTable("test_index", new String[] { "severityNumber" }, new SqlTypeName[] { SqlTypeName.BIGINT }); + RexNode condition = makeMultiFieldFullTextCall( + fullTextSqlFunction("QUERY_STRING"), + List.of("severityNumber"), + "severityNumber:>15" + ); + LogicalFilter filter = LogicalFilter.create(stubScan(table), condition); + PlannerContext context = buildContext("parquet", Map.of("severityNumber", Map.of("type", "long"))); + + IllegalArgumentException exception = expectThrows(IllegalArgumentException.class, () -> runPlanner(filter, context)); + assertTrue("Error must name the function: " + exception.getMessage(), exception.getMessage().contains("QUERY_STRING")); + assertTrue("Error must name the offending field: " + exception.getMessage(), exception.getMessage().contains("severityNumber")); + assertTrue("Error must name the field type: " + exception.getMessage(), exception.getMessage().contains("long")); + assertTrue("Error must hint at typed comparison: " + exception.getMessage(), exception.getMessage().contains("typed comparison")); + } + + /** + * {@code query_string(['eventTime'], '...')} on a {@code date} field is rejected — text-relevance + * functions only apply to text/keyword. + */ + public void testQueryStringOnDateFieldIsRejected() { + RelOptTable table = mockTable("test_index", new String[] { "eventTime" }, new SqlTypeName[] { SqlTypeName.DATE }); + RexNode condition = makeMultiFieldFullTextCall(fullTextSqlFunction("QUERY_STRING"), List.of("eventTime"), "2026-01-01"); + LogicalFilter filter = LogicalFilter.create(stubScan(table), condition); + PlannerContext context = buildContext("parquet", Map.of("eventTime", Map.of("type", "date"))); + + IllegalArgumentException exception = expectThrows(IllegalArgumentException.class, () -> runPlanner(filter, context)); + assertTrue(exception.getMessage().contains("QUERY_STRING")); + assertTrue(exception.getMessage().contains("eventTime")); + assertTrue(exception.getMessage().contains("date")); + } + + /** + * {@code query_string(['status'], '...')} on a {@code keyword} field is accepted — + * keyword fields are valid for text-relevance functions. + * + *

    Note: this fixture's mock DataFusion backend declares scan support only for + * numeric/keyword/date/boolean types (not {@code text}), and the mock Lucene backend + * has no value-producing scan capability, so a pure {@code text} field can't be scanned + * here at all — the table-scan rule throws before the filter rule runs. This test therefore + * exercises the {@code keyword} acceptance path. + */ + public void testQueryStringOnKeywordFieldIsAccepted() { + OpenSearchFilter result = runFilterWithDelegation( + "parquet", + Map.of("status", Map.of("type", "keyword", "index", true)), + new String[] { "status" }, + new SqlTypeName[] { SqlTypeName.VARCHAR }, + makeMultiFieldFullTextCall(fullTextSqlFunction("QUERY_STRING"), List.of("status"), "active") + ); + + AnnotatedPredicate predicate = (AnnotatedPredicate) result.getCondition(); + assertPredicateAnnotation(predicate, MockLuceneBackend.NAME); + } + + /** + * {@code query_string(['status', 'severityNumber'], '...')} with a mixed field list + * (keyword + long) is rejected — every named field must be text/keyword. The keyword + * field keeps the scan viable (see note on {@link #testQueryStringOnKeywordFieldIsAccepted}), + * so planning reaches the filter rule and the {@code long} field triggers the rejection. + */ + public void testQueryStringOnMixedFieldsRejectsBecauseOfNonTextField() { + RelOptTable table = mockTable( + "test_index", + new String[] { "status", "severityNumber" }, + new SqlTypeName[] { SqlTypeName.VARCHAR, SqlTypeName.BIGINT } + ); + RexNode condition = makeMultiFieldFullTextCall(fullTextSqlFunction("QUERY_STRING"), List.of("status", "severityNumber"), "error"); + LogicalFilter filter = LogicalFilter.create(stubScan(table), condition); + PlannerContext context = buildContext( + "parquet", + Map.of("status", Map.of("type", "keyword", "index", true), "severityNumber", Map.of("type", "long")) + ); + + IllegalArgumentException exception = expectThrows(IllegalArgumentException.class, () -> runPlanner(filter, context)); + assertTrue(exception.getMessage().contains("severityNumber")); + assertTrue(exception.getMessage().contains("long")); + } + + /** + * Field-less {@code query_string('category:A')} — the form the PPL {@code search} command lowers + * {@code search source=idx category=A} into. There is no {@code fields} MAP, so no literal field + * names are extracted; the field reference lives inside the Lucene query-string syntax. This must + * NOT be rejected — it falls back to the TEXT-type assumption and routes to the full-text backend. + * Regression test for the {@code search}-command 500 (no {@code fields} MAP present). + */ + public void testFieldlessQueryStringIsAccepted() { + OpenSearchFilter result = runFilterWithDelegation( + "parquet", + Map.of("category", Map.of("type", "keyword", "index", true)), + new String[] { "category" }, + new SqlTypeName[] { SqlTypeName.VARCHAR }, + makeFieldlessFullTextCall(fullTextSqlFunction("QUERY_STRING"), "category:A") + ); + + AnnotatedPredicate predicate = (AnnotatedPredicate) result.getCondition(); + assertPredicateAnnotation(predicate, MockLuceneBackend.NAME); + } + + // ---- referencedFields-driven validation (Wave 4) ---- + + /** + * With a backend serializer that reports field references, a field named only inside the + * query string (no {@code fields} MAP) is now validated. The stub reports a literal {@code long} + * field with {@code lenient=false}, so the planner rejects it — coverage the old MAP-literal + * path missed (field-less queries previously fell back to the TEXT assumption and were accepted). + */ + public void testInStringLiteralNonTextRejectedViaExtractor() { + FieldReferences refs = new FieldReferences(List.of("severityNumber"), List.of(), false); + RelOptTable table = mockTable("test_index", new String[] { "severityNumber" }, new SqlTypeName[] { SqlTypeName.BIGINT }); + RexNode condition = makeFieldlessFullTextCall(fullTextSqlFunction("QUERY_STRING"), "severityNumber:>15"); + LogicalFilter filter = LogicalFilter.create(stubScan(table), condition); + PlannerContext context = buildContext("parquet", Map.of("severityNumber", Map.of("type", "long")), backendsWithExtractor(refs)); + + IllegalArgumentException exception = expectThrows(IllegalArgumentException.class, () -> runPlanner(filter, context)); + assertTrue(exception.getMessage().contains("severityNumber")); + assertTrue(exception.getMessage().contains("text and keyword")); + } + + /** + * An explicit {@code lenient=true} (reported by the extractor) suppresses the eager type + * rejection. The query still fails — no backend supports {@code query_string} on a {@code long} + * field — but via the capability-viability path, not the friendly type-rejection. The distinct + * error proves the lenient gate skipped {@code rejectNonTextFieldsForTextFunction}. + * + *

    The viability failure is a query-level (client) error, so it surfaces as an + * {@link IllegalArgumentException} (HTTP 400 with the actionable message preserved) rather + * than an {@link IllegalStateException} (which would be redacted as a 500). + */ + public void testLenientTrueSuppressesEagerRejectionViaExtractor() { + FieldReferences refs = new FieldReferences(List.of("severityNumber"), List.of(), true); + RelOptTable table = mockTable("test_index", new String[] { "severityNumber" }, new SqlTypeName[] { SqlTypeName.BIGINT }); + RexNode condition = makeFieldlessFullTextCall(fullTextSqlFunction("QUERY_STRING"), "severityNumber:>15"); + LogicalFilter filter = LogicalFilter.create(stubScan(table), condition); + PlannerContext context = buildContext("parquet", Map.of("severityNumber", Map.of("type", "long")), backendsWithExtractor(refs)); + + IllegalArgumentException exception = expectThrows(IllegalArgumentException.class, () -> runPlanner(filter, context)); + assertTrue( + "Should fail via viability, not eager rejection: " + exception.getMessage(), + exception.getMessage().contains("No backend can evaluate") + ); + assertFalse( + "Eager type-rejection must have been skipped: " + exception.getMessage(), + exception.getMessage().contains("text and keyword") + ); + } + + /** + * A field named only inside the query string that resolves to a {@code keyword} field is + * accepted via the extractor path and routes to the full-text backend — the extractor surfaces + * {@code category} as a literal, which the planner type-checks as keyword (valid). + */ + public void testInStringLiteralKeywordAcceptedViaExtractor() { + FieldReferences refs = new FieldReferences(List.of("category"), List.of(), false); + OpenSearchFilter result = runFilter( + "parquet", + Map.of("category", Map.of("type", "keyword", "index", true)), + new String[] { "category" }, + new SqlTypeName[] { SqlTypeName.VARCHAR }, + makeFieldlessFullTextCall(fullTextSqlFunction("QUERY_STRING"), "category:A"), + backendsWithExtractor(refs), + Set.of(MockDataFusionBackend.NAME) + ); + + AnnotatedPredicate predicate = (AnnotatedPredicate) result.getCondition(); + assertPredicateAnnotation(predicate, MockLuceneBackend.NAME); + } + + /** + * A literal field named in {@code query_string} that is absent from the scan's schema is an + * unknown field — rejected with a "field not found" error, matching how a normal predicate on an + * unknown field fails, rather than silently assuming TEXT and matching nothing. (Wildcard/regex + * tokens are classified as patterns and are unaffected — see + * {@link #testQueryStringWildcardFieldPatternIsAccepted}.) + */ + public void testQueryStringOnUnknownLiteralFieldIsRejected() { + RelOptTable table = mockTable("test_index", new String[] { "status" }, new SqlTypeName[] { SqlTypeName.VARCHAR }); + RexNode condition = makeMultiFieldFullTextCall(fullTextSqlFunction("QUERY_STRING"), List.of("nosuchfield"), "active"); + LogicalFilter filter = LogicalFilter.create(stubScan(table), condition); + PlannerContext context = buildContext("parquet", Map.of("status", Map.of("type", "keyword", "index", true))); + + IllegalArgumentException exception = expectThrows(IllegalArgumentException.class, () -> runPlanner(filter, context)); + assertTrue("Error must name the unknown field: " + exception.getMessage(), exception.getMessage().contains("nosuchfield")); + assertTrue("Error must say not found: " + exception.getMessage(), exception.getMessage().contains("not found")); + } + + /** + * A wildcard field pattern (e.g. {@code ser*}) is classified as a pattern, not a literal, so it + * is never type-checked or treated as an unknown field — it routes to the full-text backend and + * is expanded at execution. Guards the empty-literals fallback against the unknown-field rejection + * added in {@link #testQueryStringOnUnknownLiteralFieldIsRejected}. + */ + public void testQueryStringWildcardFieldPatternIsAccepted() { + OpenSearchFilter result = runFilterWithDelegation( + "parquet", + Map.of("status", Map.of("type", "keyword", "index", true)), + new String[] { "status" }, + new SqlTypeName[] { SqlTypeName.VARCHAR }, + makeMultiFieldFullTextCall(fullTextSqlFunction("QUERY_STRING"), List.of("ser*"), "active") + ); + + AnnotatedPredicate predicate = (AnnotatedPredicate) result.getCondition(); + assertPredicateAnnotation(predicate, MockLuceneBackend.NAME); + } + + /** + * DataFusion (FILTER delegation) + Lucene (accepts FILTER delegation, registers a stub + * {@link DelegatedPredicateSerializer} for {@code QUERY_STRING} whose {@code referencedFields} + * returns {@code refs}). + */ + private List backendsWithExtractor(FieldReferences refs) { + MockDataFusionBackend df = new MockDataFusionBackend() { + @Override + protected Set supportedDelegations() { + return Set.of(DelegationType.FILTER); + } + }; + MockLuceneBackend lucene = new MockLuceneBackend() { + @Override + protected Set acceptedDelegations() { + return Set.of(DelegationType.FILTER); + } + + @Override + public Map delegatedPredicateSerializers() { + DelegatedPredicateSerializer stub = new DelegatedPredicateSerializer() { + @Override + public byte[] serialize(RexCall call, List fieldStorage) { + throw new UnsupportedOperationException("stub"); + } + + @Override + public FieldReferences referencedFields(RexCall call, List fieldStorage) { + return refs; + } + }; + return Map.of(ScalarFunction.QUERY_STRING, stub); + } + }; + return List.of(df, lucene); + } + + /** + * Builds a multi-field full-text {@code RexCall} matching the shape that the SQL plugin + * lowers {@code query_string(['fieldName'], 'queryText')} into: + *

    +     *   QUERY_STRING(
    +     *     MAP('fields', MAP('fieldName':VARCHAR, 1.0:DOUBLE)),
    +     *     MAP('query', 'queryText':VARCHAR)
    +     *   )
    +     * 
    + */ + private RexNode makeMultiFieldFullTextCall(SqlFunction function, List fieldNames, String query) { + List innerMapOperands = new ArrayList<>(); + for (String fieldName : fieldNames) { + innerMapOperands.add(rexBuilder.makeLiteral(fieldName)); + innerMapOperands.add(rexBuilder.makeApproxLiteral(BigDecimal.valueOf(1.0))); + } + RexNode innerMap = rexBuilder.makeCall(SqlStdOperatorTable.MAP_VALUE_CONSTRUCTOR, innerMapOperands); + RexNode fieldsMap = rexBuilder.makeCall(SqlStdOperatorTable.MAP_VALUE_CONSTRUCTOR, rexBuilder.makeLiteral("fields"), innerMap); + RexNode queryMap = rexBuilder.makeCall( + SqlStdOperatorTable.MAP_VALUE_CONSTRUCTOR, + rexBuilder.makeLiteral("query"), + rexBuilder.makeLiteral(query) + ); + return rexBuilder.makeCall(function, fieldsMap, queryMap); + } + + /** + * Builds a field-less full-text {@code RexCall} matching the shape the SQL plugin lowers + * {@code query_string('category:A')} (and the PPL {@code search} command's {@code category=A}) + * into: + *
    +     *   QUERY_STRING(MAP('query', 'category:A':VARCHAR))
    +     * 
    + * There is no {@code fields} MAP — the field names live inside the query-string value itself. + */ + private RexNode makeFieldlessFullTextCall(SqlFunction function, String query) { + RexNode queryMap = rexBuilder.makeCall( + SqlStdOperatorTable.MAP_VALUE_CONSTRUCTOR, + rexBuilder.makeLiteral("query"), + rexBuilder.makeLiteral(query) + ); + return rexBuilder.makeCall(function, queryMap); + } + // ---- Derived columns ---- /** diff --git a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/IndexResolutionTests.java b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/IndexResolutionTests.java index ae8b17984d0d2..a0915c6f75d68 100644 --- a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/IndexResolutionTests.java +++ b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/IndexResolutionTests.java @@ -57,7 +57,7 @@ public void testAliasRejectsWhenFieldTypesDiffer() { IndexMetadata.Builder b = indexBuilder("bank_b", keywordField("age")).putAlias(AliasMetadata.builder("bank_all").build()); ClusterState state = clusterStateOf(a, b); - IllegalStateException ex = expectThrows(IllegalStateException.class, () -> IndexResolution.resolve("bank_all", state)); + IllegalArgumentException ex = expectThrows(IllegalArgumentException.class, () -> IndexResolution.resolve("bank_all", state)); assertTrue("error must mention the conflicting field: " + ex.getMessage(), ex.getMessage().contains("age")); assertTrue( "error must mention both indices: " + ex.getMessage(), @@ -103,7 +103,7 @@ public void testAliasRejectsFilterAlias() { IndexMetadata.Builder a = indexBuilder("bank_a", longField("age")).putAlias(filterAlias); ClusterState state = clusterStateOf(a); - IllegalStateException ex = expectThrows(IllegalStateException.class, () -> IndexResolution.resolve("active_only", state)); + IllegalArgumentException ex = expectThrows(IllegalArgumentException.class, () -> IndexResolution.resolve("active_only", state)); assertTrue("error must mention the alias name: " + ex.getMessage(), ex.getMessage().contains("active_only")); assertTrue("error must mention 'filter': " + ex.getMessage(), ex.getMessage().toLowerCase(Locale.ROOT).contains("filter")); } @@ -142,7 +142,7 @@ public void testCommaSeparatedExpressionResolvesToUnion() { public void testWildcardRejectsIncompatibleSchemasAcrossMatches() { ClusterState state = clusterStateOf(indexBuilder("test", longField("age")), indexBuilder("test1", keywordField("age"))); - IllegalStateException ex = expectThrows(IllegalStateException.class, () -> IndexResolution.resolve("test*", state, RESOLVER)); + IllegalArgumentException ex = expectThrows(IllegalArgumentException.class, () -> IndexResolution.resolve("test*", state, RESOLVER)); assertTrue("error must mention the conflicting field: " + ex.getMessage(), ex.getMessage().contains("age")); } @@ -268,7 +268,7 @@ public void testDataStreamRejectsConflictingBackingMappings() { ) .build(); - IllegalStateException ex = expectThrows(IllegalStateException.class, () -> IndexResolution.resolve("logs", state, RESOLVER)); + IllegalArgumentException ex = expectThrows(IllegalArgumentException.class, () -> IndexResolution.resolve("logs", state, RESOLVER)); assertTrue("error must mention the conflicting field: " + ex.getMessage(), ex.getMessage().contains("age")); } diff --git a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/MockLuceneBackend.java b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/MockLuceneBackend.java index 4529d206eae29..e7601d060fe6b 100644 --- a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/MockLuceneBackend.java +++ b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/MockLuceneBackend.java @@ -8,16 +8,25 @@ package org.opensearch.analytics.planner; +import org.apache.calcite.rex.RexCall; +import org.apache.calcite.rex.RexLiteral; +import org.apache.calcite.rex.RexNode; +import org.opensearch.analytics.spi.DelegatedPredicateSerializer; import org.opensearch.analytics.spi.DelegatedSubtreeConvertor; +import org.opensearch.analytics.spi.FieldReferences; +import org.opensearch.analytics.spi.FieldStorageInfo; import org.opensearch.analytics.spi.FieldType; import org.opensearch.analytics.spi.FilterCapability; import org.opensearch.analytics.spi.ScalarFunction; +import org.opensearch.common.regex.Regex; import org.opensearch.index.engine.dataformat.ReaderManagerConfig; import org.opensearch.index.engine.exec.EngineReaderManager; import org.opensearch.plugins.SearchBackEndPlugin; +import java.util.ArrayList; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; /** @@ -52,7 +61,8 @@ public class MockLuceneBackend extends MockBackend implements SearchBackEndPlugi ScalarFunction.MATCH_PHRASE, ScalarFunction.FUZZY, ScalarFunction.WILDCARD, - ScalarFunction.REGEXP + ScalarFunction.REGEXP, + ScalarFunction.QUERY_STRING ); private static final Set STANDARD_TYPES = new HashSet<>(); @@ -94,6 +104,80 @@ protected Set filterCapabilities() { return FILTER_CAPS; } + /** + * Default delegated-predicate serializers for the multi-field full-text functions. Their + * {@code referencedFields} mirrors the planner's pre-extractor behavior: literals come from the + * {@code fields} MAP, fan-out when empty, non-lenient. {@code serialize} is unused in planner + * tests. Tests needing in-string fields or explicit lenient override this. + */ + @Override + public Map delegatedPredicateSerializers() { + DelegatedPredicateSerializer mapLiteralSerializer = new DelegatedPredicateSerializer() { + @Override + public byte[] serialize(RexCall call, List fieldStorage) { + throw new UnsupportedOperationException("Mock backend does not serialize predicates"); + } + + @Override + public FieldReferences referencedFields(RexCall call, List fieldStorage) { + List literals = new ArrayList<>(); + List patterns = new ArrayList<>(); + for (String token : extractLiteralFieldNames(call)) { + if (Regex.isSimpleMatchPattern(token)) { + patterns.add(token); + } else { + literals.add(token); + } + } + return new FieldReferences(literals, patterns, false); + } + }; + return Map.of( + ScalarFunction.QUERY_STRING, + mapLiteralSerializer, + ScalarFunction.SIMPLE_QUERY_STRING, + mapLiteralSerializer, + ScalarFunction.MULTI_MATCH, + mapLiteralSerializer + ); + } + + /** + * Extracts literal field names from the {@code fields}/{@code field} MAP operand of a + * multi-field full-text {@code RexCall} — the field-name encoding the SQL plugin emits. Mirrors + * the planner's pre-extractor behavior so existing full-text tests keep their semantics. + */ + private static List extractLiteralFieldNames(RexCall predicate) { + List names = new ArrayList<>(); + for (RexNode operand : predicate.getOperands()) { + if (operand instanceof RexCall outerMap && outerMap.getOperands().size() >= 2) { + RexNode keyNode = outerMap.getOperands().get(0); + if (!(keyNode instanceof RexLiteral keyLit)) continue; + String key = keyLit.getValueAs(String.class); + if (!"fields".equals(key) && !"field".equals(key)) continue; + + RexNode valueNode = outerMap.getOperands().get(1); + if (valueNode instanceof RexCall nestedMap) { + List nestedOperands = nestedMap.getOperands(); + for (int i = 0; i + 1 < nestedOperands.size(); i += 2) { + if (nestedOperands.get(i) instanceof RexLiteral fieldLit) { + String fieldName = fieldLit.getValueAs(String.class); + if (fieldName != null && !fieldName.isEmpty()) { + names.add(fieldName); + } + } + } + } else if (valueNode instanceof RexLiteral valueLit) { + String fieldName = valueLit.getValueAs(String.class); + if (fieldName != null && !fieldName.isEmpty()) { + names.add(fieldName); + } + } + } + } + return names; + } + // ---- SearchBackEndPlugin (storage) ---- @Override diff --git a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/ProjectRuleTests.java b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/ProjectRuleTests.java index f64666b7f36ff..494f1d211da07 100644 --- a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/ProjectRuleTests.java +++ b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/ProjectRuleTests.java @@ -118,8 +118,8 @@ public void testExpressionProjectionStillRequiresCapabilityWithoutDeclaration() LogicalProject project = LogicalProject.create(stubScan(table), List.of(), List.of(ceilExpr), List.of("ceil_v")); PlannerContext context = buildContext("parquet", nameValueFields(), List.of(new MockDataFusionBackend(), LUCENE)); - IllegalStateException exception = expectThrows(IllegalStateException.class, () -> runPlanner(project, context)); - assertTrue(exception.getMessage().contains("No backend supports scalar function")); + UnsupportedFunctionException exception = expectThrows(UnsupportedFunctionException.class, () -> runPlanner(project, context)); + assertTrue(exception.getMessage().contains("is not currently supported")); } // ---- Scalar functions ---- @@ -154,8 +154,8 @@ public void testUnsupportedScalarFunctionErrors() { LogicalProject project = LogicalProject.create(stubScan(table), List.of(), List.of(ceilExpr), List.of("casted")); PlannerContext context = buildContext("parquet", nameValueFields()); - IllegalStateException exception = expectThrows(IllegalStateException.class, () -> runPlanner(project, context)); - assertTrue(exception.getMessage().contains("No backend supports scalar function")); + UnsupportedFunctionException exception = expectThrows(UnsupportedFunctionException.class, () -> runPlanner(project, context)); + assertTrue(exception.getMessage().contains("is not currently supported")); } /** @@ -218,8 +218,8 @@ protected Set projectCapabilities() { List.of(DATAFUSION, luceneWithPainless) ); - IllegalStateException exception = expectThrows(IllegalStateException.class, () -> runPlanner(project, context)); - assertTrue(exception.getMessage().contains("no delegation path exists")); + UnsupportedFunctionException exception = expectThrows(UnsupportedFunctionException.class, () -> runPlanner(project, context)); + assertTrue(exception.getMessage().contains("is not currently supported")); } public void testMixedFieldAndPainlessWithDelegation() { @@ -384,10 +384,10 @@ protected Set windowCapabilities( List.of("name", "rn") ); PlannerContext context = buildContext("parquet", nameValueFields(), List.of(dfNoWindow, LUCENE)); - IllegalStateException exception = expectThrows(IllegalStateException.class, () -> runPlanner(project, context)); + UnsupportedFunctionException exception = expectThrows(UnsupportedFunctionException.class, () -> runPlanner(project, context)); assertTrue( "Expected planner to surface window-function capability gap, got: " + exception.getMessage(), - exception.getMessage().contains("No backend supports window functions") && exception.getMessage().contains("ROW_NUMBER") + exception.getMessage().contains("ROW_NUMBER") && exception.getMessage().contains("is not currently supported") ); } @@ -599,8 +599,8 @@ protected Set projectCapabilities() { List.of(dfWithDelegation, luceneAccepting, thirdBackend) ); - IllegalStateException exception = expectThrows(IllegalStateException.class, () -> runPlanner(project, context)); - assertTrue(exception.getMessage().contains("no delegation path exists")); + UnsupportedFunctionException exception = expectThrows(UnsupportedFunctionException.class, () -> runPlanner(project, context)); + assertTrue(exception.getMessage().contains("is not currently supported")); } public void testDelegationFailsWhenAcceptorRejectsDelegationType() { @@ -627,8 +627,8 @@ protected Set projectCapabilities() { List.of(dfWithDelegation, luceneWithPainlessNoAccept) ); - IllegalStateException exception = expectThrows(IllegalStateException.class, () -> runPlanner(project, context)); - assertTrue(exception.getMessage().contains("no delegation path exists")); + UnsupportedFunctionException exception = expectThrows(UnsupportedFunctionException.class, () -> runPlanner(project, context)); + assertTrue(exception.getMessage().contains("is not currently supported")); } // ---- Composed pipeline shapes ---- diff --git a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/RelNodeUtilsTests.java b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/RelNodeUtilsTests.java index 4f62526c358e8..f399ed781a30a 100644 --- a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/RelNodeUtilsTests.java +++ b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/RelNodeUtilsTests.java @@ -151,6 +151,74 @@ public void testDepthGuardThrowsOnExcessiveDepth() { assertTrue(e.getMessage().contains("maximum depth")); } + // --- Multi-index comma-separated table name tests (FGAC bypass fix) --- + + public void testCommaDelimitedIndicesSplit() { + RelBuilder b = builderWithTable("logs-2024-01,secrets-2024-01"); + RelNode plan = b.scan("logs-2024-01,secrets-2024-01").build(); + assertArrayEquals(new String[] { "logs-2024-01", "secrets-2024-01" }, RelNodeUtils.extractIndices(plan)); + } + + public void testCommaDelimitedThreeIndices() { + RelBuilder b = builderWithTable("a,b,c"); + RelNode plan = b.scan("a,b,c").build(); + assertArrayEquals(new String[] { "a", "b", "c" }, RelNodeUtils.extractIndices(plan)); + } + + public void testDoubleCommaProducesEmptyStringFiltered() { + RelBuilder b = builderWithTable("index1,,index2"); + RelNode plan = b.scan("index1,,index2").build(); + // Strings.splitStringByCommaToArray trims and skips empty tokens + String[] result = RelNodeUtils.extractIndices(plan); + for (String idx : result) { + assertFalse("Should not contain empty string", idx.isEmpty()); + } + assertTrue("Should contain index1", java.util.Arrays.asList(result).contains("index1")); + assertTrue("Should contain index2", java.util.Arrays.asList(result).contains("index2")); + } + + public void testLeadingComma() { + RelBuilder b = builderWithTable(",index1"); + RelNode plan = b.scan(",index1").build(); + String[] result = RelNodeUtils.extractIndices(plan); + for (String idx : result) { + assertFalse("Should not contain empty string", idx.isEmpty()); + } + assertTrue("Should contain index1", java.util.Arrays.asList(result).contains("index1")); + } + + public void testDoubleLeadingComma() { + RelBuilder b = builderWithTable(",,index1"); + RelNode plan = b.scan(",,index1").build(); + String[] result = RelNodeUtils.extractIndices(plan); + for (String idx : result) { + assertFalse("Should not contain empty string", idx.isEmpty()); + } + assertTrue("Should contain index1", java.util.Arrays.asList(result).contains("index1")); + } + + public void testTrailingComma() { + RelBuilder b = builderWithTable("index1,"); + RelNode plan = b.scan("index1,").build(); + String[] result = RelNodeUtils.extractIndices(plan); + for (String idx : result) { + assertFalse("Should not contain empty string", idx.isEmpty()); + } + assertTrue("Should contain index1", java.util.Arrays.asList(result).contains("index1")); + } + + public void testSingleIndexNoComma() { + RelBuilder b = builderWithTable("plain_index"); + RelNode plan = b.scan("plain_index").build(); + assertArrayEquals(new String[] { "plain_index" }, RelNodeUtils.extractIndices(plan)); + } + + private RelBuilder builderWithTable(String tableName) { + SchemaPlus schema = CalciteSchema.createRootSchema(true).plus(); + schema.add(tableName, new MockTable()); + return RelBuilder.create(Frameworks.newConfigBuilder().defaultSchema(schema).build()); + } + /** Minimal table implementation for RelBuilder schema registration. */ private static class MockTable extends AbstractTable { @Override diff --git a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/TopKRewriterPlanShapeTests.java b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/TopKRewriterPlanShapeTests.java index 184ddbd5b1456..73796cbb24a39 100644 --- a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/TopKRewriterPlanShapeTests.java +++ b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/TopKRewriterPlanShapeTests.java @@ -485,6 +485,57 @@ public void testRewrite_pplShape_sortByGroupKey_remapsCorrectly() { ); } + // ── Detection: chained stats (nested aggregation) must NOT get TopK ───────── + + /** + * PPL: {@code stats count() as c by X, Y | stats sum(c) as total by X | sort - total | head 5} + * The outer aggregate's PARTIAL input subtree contains another aggregate, so TopK must bail. + * TopK on the inner agg would truncate (X, Y) groups before the outer sum sees all of them, + * producing catastrophically wrong totals. + */ + public void testDetection_chainedStats_topKBails() { + RelOptTable table = mockTable("test_index", "status", "size"); + RelNode scan = stubScan(table); + + // Inner agg: count() by (status, size) + LogicalAggregate innerAgg = LogicalAggregate.create(scan, List.of(), ImmutableBitSet.of(0, 1), null, List.of(countStarCall())); + + // Outer agg: sum(count) by status — groups over the inner agg result + LogicalAggregate outerAgg = LogicalAggregate.create( + innerAgg, + List.of(), + ImmutableBitSet.of(0), + null, + List.of( + AggregateCall.create( + SqlStdOperatorTable.SUM, + false, + false, + false, + List.of(), + List.of(2), + -1, + null, + RelCollations.EMPTY, + typeFactory.createSqlType(SqlTypeName.BIGINT), + "total" + ) + ) + ); + + // Sort on total DESC, head 5 + RelNode sort = LogicalSort.create( + outerAgg, + RelCollations.of(new RelFieldCollation(1, RelFieldCollation.Direction.DESCENDING)), + null, + rexBuilder.makeLiteral(5, typeFactory.createSqlType(SqlTypeName.INTEGER), true) + ); + + RelNode result = runPlanner(sort, contextWithOversampling(2.0)); + String plan = RelOptUtil.toString(result); + assertEquals("chained stats — TopK must not insert a shard Sort", 0, countShardSortsBelowER(plan)); + } + // ── Detection: AVG does NOT get TopK (reduce decomposition inserts computed Project) ── /** AVG is decomposed into SUM/COUNT with a divide Project — rewriter bails. */ @@ -504,10 +555,11 @@ public void testDetection_avgByGroup_noTopK() { } /** - * Multiple adjacent Projects between Sort and Aggregate: if PROJECT_MERGE is ever removed, - * the rewriter should still work (captures only the first Project, skips remapping for the - * second). This test verifies TopK still fires — sort key passes through un-remapped since - * the second Project is not captured. + * Multiple adjacent Projects between Sort and Aggregate: PROJECT_MERGE collapses them during + * RBO so TopK normally fires. If for any reason two projects survive (PROJECT_MERGE removed or + * blocked), the rewriter now safely bails — accepting the second project is unsafe since it + * could carry window functions or other expressions that make TopK incorrect. + * This test verifies the safe-bail behavior when two projects reach the rewriter. */ public void testDetection_multipleProjects_topKStillFires() { RelOptTable table = mockTable("test_index", "status", "size"); @@ -538,7 +590,10 @@ public void testDetection_multipleProjects_topKStillFires() { RelNode result = runPlanner(sort, contextWithOversampling(2.0)); String plan = RelOptUtil.toString(result); long sortCount = plan.lines().filter(l -> l.contains("OpenSearchSort")).count(); - assertTrue("TopK should still fire with multiple projects (PROJECT_MERGE collapses them)", sortCount >= 2); + // PROJECT_MERGE collapses the two adjacent identity projects, so TopK fires. + // Even without PROJECT_MERGE, the rewriter passes through multiple plain-column projects + // and validates the sort key at the first seenProject — TopK still fires correctly. + assertTrue("TopK should fire with multiple plain-column projects", sortCount >= 2); } /** Computed expression (literal) in Project between Sort and Aggregate — rewriter bails. */ diff --git a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/dag/FragmentConversionDriverTests.java b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/dag/FragmentConversionDriverTests.java index 736d9173c8288..345b9c101a751 100644 --- a/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/dag/FragmentConversionDriverTests.java +++ b/sandbox/plugins/analytics-engine/src/test/java/org/opensearch/analytics/planner/dag/FragmentConversionDriverTests.java @@ -1279,6 +1279,82 @@ private FilterTreeShape treeShapeOf(StagePlan plan) { .orElseThrow()).getTreeShape(); } + // ---- HAVING regression: delegated WHERE under an Aggregate with a HAVING filter above ---- + + /** + * HAVING produces two stacked filters that don't merge across the Aggregate: a native HAVING + * (count=1) above and the delegated WHERE (match_phrase) below. The derived FilterTreeShape + * (which the data node reads as its classification) must reflect the WHERE's delegation + * (CONJUNCTIVE), not the topmost HAVING (NO_DELEGATION). Regression: picking the topmost filter + * yielded NO_DELEGATION → data node skipped the Lucene collector → full scan / over-count. + */ + public void testHavingFilterAboveDelegatedWhere_derivesConjunctive() { + StagePlan plan = runHaving(matchPhrase("hello")); + assertEquals("delegated WHERE under HAVING must still ship one expression", 1, plan.delegatedExpressions().size()); + assertEquals( + "treeShape must come from the WHERE (CONJUNCTIVE), not the HAVING (NO_DELEGATION)", + FilterTreeShape.CONJUNCTIVE, + treeShapeOf(plan) + ); + } + + /** Builds Filter(count=1)[HAVING] over Aggregate(group=message, count(*)) over Filter(where)[scan]. */ + private StagePlan runHaving(RexNode whereCondition) { + RecordingConvertor dfConvertor = new RecordingConvertor(); + RecordingSerializer serializer = new RecordingSerializer(); + MockDataFusionBackend df = new MockDataFusionBackend() { + @Override + protected Set supportedDelegations() { + return Set.of(DelegationType.FILTER); + } + + @Override + public FragmentConvertor getFragmentConvertor() { + return dfConvertor; + } + }; + MockLuceneBackend lucene = new MockLuceneBackend() { + @Override + protected Set acceptedDelegations() { + return Set.of(DelegationType.FILTER); + } + + @Override + public Map delegatedPredicateSerializers() { + Map map = new HashMap<>(super.delegatedPredicateSerializers()); + map.put(ScalarFunction.MATCH_PHRASE, serializer); + return map; + } + }; + List backends = List.of(df, lucene); + Map> fields = Map.of( + "message", + Map.of("type", "keyword", "index", true), + "amount", + Map.of("type", "integer", "index", false), + "count", + Map.of("type", "integer", "index", false) + ); + // Single shard: no exchange split, so HAVING + Aggregate + WHERE stay in ONE fragment — the + // shape the bug needs (multi-shard forks HAVING into a separate reduce stage, hiding it). + PlannerContext context = buildContext("parquet", 1, fields, backends); + RelNode scan = stubScan( + mockTable( + "test_index", + new String[] { "message", "amount", "count" }, + new SqlTypeName[] { SqlTypeName.VARCHAR, SqlTypeName.INTEGER, SqlTypeName.INTEGER } + ) + ); + LogicalFilter where = LogicalFilter.create(scan, whereCondition); + LogicalAggregate aggregate = makeAggregate(where, ImmutableBitSet.of(0), countStarCall(where)); + LogicalFilter having = LogicalFilter.create(aggregate, makeEquals(1, SqlTypeName.BIGINT, 1)); + RelNode cboOutput = runPlanner(having, context); + QueryDAG dag = DAGBuilder.build(cboOutput, context.getCapabilityRegistry(), mockClusterService(), TEST_RESOLVER); + PlanForker.forkAll(dag, context.getCapabilityRegistry()); + FragmentConversionDriver.convertAll(dag, context.getCapabilityRegistry()); + return leafStage(dag).getPlanAlternatives().getFirst(); + } + // ---- Combining tests (OR/NOT/mixed) ---- /** match_phrase AND fuzzy OR amount=200 → OR(AND(lucene,lucene), native) — combined, INTERLEAVED. */ diff --git a/sandbox/plugins/block-cache-foyer/src/internalClusterTest/java/org/opensearch/blockcache/foyer/BlockCacheKeyIndexRecoveryIT.java b/sandbox/plugins/block-cache-foyer/src/internalClusterTest/java/org/opensearch/blockcache/foyer/BlockCacheKeyIndexRecoveryIT.java index 9614adb3f85bf..28cf1bcb47264 100644 --- a/sandbox/plugins/block-cache-foyer/src/internalClusterTest/java/org/opensearch/blockcache/foyer/BlockCacheKeyIndexRecoveryIT.java +++ b/sandbox/plugins/block-cache-foyer/src/internalClusterTest/java/org/opensearch/blockcache/foyer/BlockCacheKeyIndexRecoveryIT.java @@ -423,6 +423,122 @@ public void testWrongVersionKeyIndexFileResultsInCleanStartup() throws Exception assertDocCount(indexName + "-copy", 50L); } + // ── Tiered-mode coverage ───────────────────────────────────────────────── + + /** + * Tiered mode (default): both the data tier and the metadata tier each persist + * their own {@code key_index.json} on graceful warm-node restart. + * + *

    With {@code block_cache.foyer.metadata_cache_ratio > 0} (5% by default) the + * Foyer block cache splits into two independent instances under + * {@code foyer-block-cache/data} and {@code foyer-block-cache/metadata}. Each + * tier has its own Drop impl writing its own snapshot. This test guards against + * a regression where one tier's persistence wiring is broken while the other + * appears healthy — the previous helper would only see the data tier and miss + * a metadata-tier failure entirely. + */ + public void testTieredBothTiersWriteKeyIndexOnShutdown() throws Exception { + final Client client = client(); + final String indexName = "test-tiered-shutdown"; + + setupWarmNodeWithIndex(client, indexName); + assertDocCount(indexName + "-copy", 50L); + + logger.info("[tiered-shutdown-01] restarting warm node '{}'", getWarmNodeName()); + internalCluster().restartNode(getWarmNodeName()); + ensureGreen(); + + Path dataDir = findFoyerCacheDir(); + Path metaDir = findFoyerMetadataCacheDir(); + assertNotNull("data-tier cache directory must exist on warm node after restart", dataDir); + assertNotNull( + "metadata-tier cache directory must exist on warm node after restart " + + "(check block_cache.foyer.metadata_cache_ratio is > 0)", + metaDir + ); + + // Both tiers wrote a key_index.json. + Path dataKeyIndex = dataDir.resolve(KEY_INDEX_FILENAME); + Path metaKeyIndex = metaDir.resolve(KEY_INDEX_FILENAME); + assertTrue("data-tier key_index.json must exist after restart", Files.exists(dataKeyIndex)); + assertTrue("metadata-tier key_index.json must exist after restart", Files.exists(metaKeyIndex)); + + // No leftover .tmp files on either tier. + assertFalse( + "data-tier .key_index.json.tmp must not exist after successful rename", + Files.exists(dataDir.resolve(KEY_INDEX_TMP_FILENAME)) + ); + assertFalse( + "metadata-tier .key_index.json.tmp must not exist after successful rename", + Files.exists(metaDir.resolve(KEY_INDEX_TMP_FILENAME)) + ); + + // Both snapshots are valid JSON with version=1. + String dataContent = Files.readString(dataKeyIndex); + String metaContent = Files.readString(metaKeyIndex); + assertFalse("data-tier key_index.json must not be empty", dataContent.isBlank()); + assertFalse("metadata-tier key_index.json must not be empty", metaContent.isBlank()); + assertTrue("data-tier key_index.json must contain version:1", dataContent.contains("\"version\":1")); + assertTrue("metadata-tier key_index.json must contain version:1", metaContent.contains("\"version\":1")); + + // Cluster healthy after restart. + assertDocCount(indexName + "-copy", 50L); + } + + /** + * Tiered mode: the periodic persist task fires independently on both tiers + * and writes {@code key_index.json} within the configured interval. + * + *

    Each tier owns its own persist task, so this test catches the case where + * one tier's task is starved or never spawned — symptoms that would otherwise + * only surface on shutdown. + */ + public void testTieredBothTiersPeriodicPersistFires() throws Exception { + final Client client = client(); + final String indexName = "test-tiered-periodic"; + + setupWarmNodeWithIndex(client, indexName); + assertDocCount(indexName + "-copy", 50L); + + Path dataDir = findFoyerCacheDir(); + Path metaDir = findFoyerMetadataCacheDir(); + assertNotNull("data-tier cache directory must exist on warm node", dataDir); + assertNotNull( + "metadata-tier cache directory must exist on warm node " + "(check block_cache.foyer.metadata_cache_ratio is > 0)", + metaDir + ); + + logger.info("[tiered-periodic-01] waiting up to 30s for periodic persist (interval=5s) on data and metadata tiers"); + + // Persist interval is 5s (set in nodeSettings); allow up to 30s for both tiers. + assertBusy(() -> { + assertTrue( + "data-tier key_index.json must be written by periodic persist task within interval", + Files.exists(dataDir.resolve(KEY_INDEX_FILENAME)) + ); + assertTrue( + "metadata-tier key_index.json must be written by periodic persist task within interval", + Files.exists(metaDir.resolve(KEY_INDEX_FILENAME)) + ); + }, 30, TimeUnit.SECONDS); + + // No leftover .tmp files after successful rename on either tier. + assertFalse( + "data-tier .key_index.json.tmp must not exist after successful periodic persist", + Files.exists(dataDir.resolve(KEY_INDEX_TMP_FILENAME)) + ); + assertFalse( + "metadata-tier .key_index.json.tmp must not exist after successful periodic persist", + Files.exists(metaDir.resolve(KEY_INDEX_TMP_FILENAME)) + ); + + // Both tiers' content is valid JSON with version=1. + String dataContent = Files.readString(dataDir.resolve(KEY_INDEX_FILENAME)); + String metaContent = Files.readString(metaDir.resolve(KEY_INDEX_FILENAME)); + assertTrue("data-tier key_index.json must contain version:1", dataContent.contains("\"version\":1")); + assertTrue("metadata-tier key_index.json must contain version:1", metaContent.contains("\"version\":1")); + } + // ── Helper methods ──────────────────────────────────────────────────────── /** @@ -520,7 +636,44 @@ private long getWarmNodeUsedBytes(Client client) { * different node ordinals. Fails the test with a descriptive message if the directory is * not found — this ensures file-level assertions are never silently skipped. */ + /** + * Locates the Foyer block cache directory that holds {@code key_index.json}. + * + *

    In tiered mode (default — {@code block_cache.foyer.metadata_cache_ratio > 0}) + * the layout is {@code foyer-block-cache/data/} and {@code foyer-block-cache/metadata/}; + * each subdir has its own {@code key_index.json}. This helper returns the data + * subdir in that case. In single-cache mode the file lives directly under + * {@code foyer-block-cache/}, which is what gets returned then. + * + *

    Use {@link #findFoyerMetadataCacheDir()} for the metadata-tier subdir. + */ private Path findFoyerCacheDir() { + Path root = findFoyerCacheRoot(); + if (root == null) { + return null; + } + Path dataSubdir = root.resolve("data"); + if (Files.isDirectory(dataSubdir)) { + return dataSubdir; // tiered mode + } + return root; // single-cache mode + } + + /** + * Returns the metadata-tier subdir ({@code foyer-block-cache/metadata}) when + * the cache is in tiered mode, or {@code null} in single-cache mode. + */ + private Path findFoyerMetadataCacheDir() { + Path root = findFoyerCacheRoot(); + if (root == null) { + return null; + } + Path metaSubdir = root.resolve("metadata"); + return Files.isDirectory(metaSubdir) ? metaSubdir : null; + } + + /** Locates the {@code foyer-block-cache} parent directory on the warm node. */ + private Path findFoyerCacheRoot() { String warmName = getWarmNodeName(); assertNotNull("A warm node must be present in the cluster", warmName); diff --git a/sandbox/plugins/block-cache-foyer/src/internalClusterTest/java/org/opensearch/blockcache/foyer/BlockCacheKeyIndexScaleIT.java b/sandbox/plugins/block-cache-foyer/src/internalClusterTest/java/org/opensearch/blockcache/foyer/BlockCacheKeyIndexScaleIT.java index bb8a2c6da2324..e6419115a6a67 100644 --- a/sandbox/plugins/block-cache-foyer/src/internalClusterTest/java/org/opensearch/blockcache/foyer/BlockCacheKeyIndexScaleIT.java +++ b/sandbox/plugins/block-cache-foyer/src/internalClusterTest/java/org/opensearch/blockcache/foyer/BlockCacheKeyIndexScaleIT.java @@ -224,6 +224,48 @@ public void testEvictPrefixAfterRecovery() throws Exception { assertTrue("Warm node must still be present and healthy after bulk evict_prefix", foundWarm); } + /** + * Tiered mode: verify that BOTH the data tier and the metadata tier write + * their own {@code key_index.json} on graceful warm-node shutdown. + * + *

    With the default {@code block_cache.foyer.metadata_cache_ratio} (5%) the + * Foyer block cache runs in tiered mode and creates two independent Foyer + * instances rooted at {@code foyer-block-cache/data} and + * {@code foyer-block-cache/metadata}. Each tier owns its own key_index.json, + * its own Drop impl, and its own periodic-persist task. A regression in either + * tier's persistence wiring would leak state across restarts. + */ + public void testKeyIndexJsonForBothTiersWrittenOnShutdown() throws Exception { + setupScaleShards(); + + logger.info("[scale-tiered-01] restarting warm node '{}'", getWarmNodeName()); + internalCluster().restartNode(getWarmNodeName()); + ensureGreen(TimeValue.timeValueSeconds(120)); + + Path dataDir = findFoyerCacheDir(); + Path metaDir = findFoyerMetadataCacheDir(); + assertNotNull("data-tier cache directory must exist on warm node after restart", dataDir); + assertNotNull( + "metadata-tier cache directory must exist on warm node after restart " + + "(check block_cache.foyer.metadata_cache_ratio is > 0)", + metaDir + ); + + // Data tier + Path dataKeyIndex = dataDir.resolve(KEY_INDEX_FILENAME); + assertTrue("data-tier key_index.json must exist after restart", Files.exists(dataKeyIndex)); + String dataContent = Files.readString(dataKeyIndex); + assertFalse("data-tier key_index.json must not be empty", dataContent.isBlank()); + assertTrue("data-tier key_index.json must contain version:1", dataContent.contains("\"version\":1")); + + // Metadata tier + Path metaKeyIndex = metaDir.resolve(KEY_INDEX_FILENAME); + assertTrue("metadata-tier key_index.json must exist after restart", Files.exists(metaKeyIndex)); + String metaContent = Files.readString(metaKeyIndex); + assertFalse("metadata-tier key_index.json must not be empty", metaContent.isBlank()); + assertTrue("metadata-tier key_index.json must contain version:1", metaContent.contains("\"version\":1")); + } + /** * Verifies that the warm node remains healthy after restoring all indices, * cluster is GREEN, and block_cache stats are accessible. @@ -287,7 +329,7 @@ private void setupScaleShards() throws Exception { // Index 1 doc into each index to make the snapshot non-trivial. for (String name : indexNames) { - client.prepareIndex(name).setId("1").setSource("field", "value").get(); + client.prepareIndex(name).setSource("field", "value").get(); } ensureGreen(); @@ -359,7 +401,44 @@ private long getWarmNodeUsedBytes() { return 0L; } + /** + * Locates the Foyer block cache directory that holds {@code key_index.json}. + * + *

    In tiered mode (default — {@code block_cache.foyer.metadata_cache_ratio > 0}) + * the layout is {@code foyer-block-cache/data/} and {@code foyer-block-cache/metadata/}; + * each subdir has its own {@code key_index.json}. This helper returns the data + * subdir in that case. In single-cache mode the file lives directly under + * {@code foyer-block-cache/}, which is what gets returned then. + * + *

    Use {@link #findFoyerMetadataCacheDir()} for the metadata-tier subdir. + */ private Path findFoyerCacheDir() { + Path root = findFoyerCacheRoot(); + if (root == null) { + return null; + } + Path dataSubdir = root.resolve("data"); + if (Files.isDirectory(dataSubdir)) { + return dataSubdir; // tiered mode + } + return root; // single-cache mode + } + + /** + * Returns the metadata-tier subdir ({@code foyer-block-cache/metadata}) when + * the cache is in tiered mode, or {@code null} in single-cache mode. + */ + private Path findFoyerMetadataCacheDir() { + Path root = findFoyerCacheRoot(); + if (root == null) { + return null; + } + Path metaSubdir = root.resolve("metadata"); + return Files.isDirectory(metaSubdir) ? metaSubdir : null; + } + + /** Locates the {@code foyer-block-cache} parent directory on the warm node. */ + private Path findFoyerCacheRoot() { String warmName = getWarmNodeName(); assertNotNull("A warm node must be present in the cluster", warmName); Environment environment = internalCluster().getInstance(Environment.class, warmName); diff --git a/sandbox/plugins/block-cache-foyer/src/main/java/org/opensearch/blockcache/foyer/BlockCacheFoyerPlugin.java b/sandbox/plugins/block-cache-foyer/src/main/java/org/opensearch/blockcache/foyer/BlockCacheFoyerPlugin.java index 0321861ec3af0..0bf5117aed1d5 100644 --- a/sandbox/plugins/block-cache-foyer/src/main/java/org/opensearch/blockcache/foyer/BlockCacheFoyerPlugin.java +++ b/sandbox/plugins/block-cache-foyer/src/main/java/org/opensearch/blockcache/foyer/BlockCacheFoyerPlugin.java @@ -102,7 +102,9 @@ public List> getSettings() { FoyerBlockCacheSettings.IO_ENGINE_SETTING, FoyerBlockCacheSettings.KEY_INDEX_SWEEP_INTERVAL_SETTING, FoyerBlockCacheSettings.KEY_INDEX_SWEEP_THRESHOLD_SETTING, - FoyerBlockCacheSettings.KEY_INDEX_PERSIST_INTERVAL_SETTING + FoyerBlockCacheSettings.KEY_INDEX_PERSIST_INTERVAL_SETTING, + FoyerBlockCacheSettings.METADATA_CACHE_RATIO_SETTING, + FoyerBlockCacheSettings.METADATA_BLOCK_SIZE_SETTING ); } @@ -173,6 +175,9 @@ public Collection createComponents( final long sweepIntervalSecs = FoyerBlockCacheSettings.KEY_INDEX_SWEEP_INTERVAL_SETTING.get(settings); final double sweepThresholdRatio = FoyerBlockCacheSettings.KEY_INDEX_SWEEP_THRESHOLD_SETTING.get(settings); final long persistIntervalSecs = FoyerBlockCacheSettings.KEY_INDEX_PERSIST_INTERVAL_SETTING.get(settings); + final long metaBlockSizeBytes = FoyerBlockCacheSettings.METADATA_BLOCK_SIZE_SETTING.get(settings).getBytes(); + final String metadataCacheRatioRaw = FoyerBlockCacheSettings.METADATA_CACHE_RATIO_SETTING.get(settings); + final double metadataCacheRatio = RatioValue.parseRatioValue(metadataCacheRatioRaw).getAsRatio(); // Use the exact capacity reserved by NodeCacheService during budget phase. final long diskCapacityBytes = reservedCapacityBytes; @@ -189,7 +194,6 @@ public Collection createComponents( } // block_size must be strictly less than the total disk capacity. - final long effectiveBlockSizeBytes; if (blockSizeBytes >= diskCapacityBytes) { throw new SettingsException( "block_cache.foyer.block_size (" @@ -198,22 +202,64 @@ public Collection createComponents( + diskCapacityBytes + " bytes). Reduce block_cache.foyer.block_size or increase node.search.cache.size." ); - } else { - effectiveBlockSizeBytes = blockSizeBytes; } try { - cache = new FoyerBlockCache( - diskCapacityBytes, - diskDir, - blockSizeBytes, - bufferPoolSizeBytes, - submitQueueSizeThresholdBytes, - ioEngine, - sweepIntervalSecs, - sweepThresholdRatio, - persistIntervalSecs - ); + if (metadataCacheRatio > 0.0) { + final long metaDiskBytes = Math.round(diskCapacityBytes * metadataCacheRatio); + final long dataDiskBytes = diskCapacityBytes - metaDiskBytes; + final String dataDiskDir = diskDir + "/data"; + final String metaDiskDir = diskDir + "/metadata"; + cache = new FoyerBlockCache( + dataDiskBytes, + dataDiskDir, + blockSizeBytes, + bufferPoolSizeBytes, + submitQueueSizeThresholdBytes, + metaDiskBytes, + metaDiskDir, + metaBlockSizeBytes, + bufferPoolSizeBytes, + submitQueueSizeThresholdBytes, + ioEngine, + sweepIntervalSecs, + sweepThresholdRatio, + persistIntervalSecs + ); + logger.info( + "BlockCacheFoyerPlugin created tiered FoyerBlockCache (dataDir={}, metaDir={}, " + + "dataDisk={}, metaDisk={}, dataBlockSize={}, metaBlockSize={}, ioEngine={})", + dataDiskDir, + metaDiskDir, + dataDiskBytes, + metaDiskBytes, + blockSizeBytes, + metaBlockSizeBytes, + ioEngine + ); + } else { + cache = new FoyerBlockCache( + diskCapacityBytes, + diskDir, + blockSizeBytes, + bufferPoolSizeBytes, + submitQueueSizeThresholdBytes, + ioEngine, + sweepIntervalSecs, + sweepThresholdRatio, + persistIntervalSecs + ); + logger.info( + "BlockCacheFoyerPlugin created FoyerBlockCache (diskDir={}, blockSize={}, ioEngine={}, " + + "sweepIntervalSecs={}, sweepThresholdRatio={}, persistIntervalSecs={})", + diskDir, + blockSizeBytes, + ioEngine, + sweepIntervalSecs == 0 ? "disabled" : sweepIntervalSecs + "s", + sweepThresholdRatio == 0.0 ? "always-sweep (no threshold)" : sweepThresholdRatio, + persistIntervalSecs == 0 ? "disabled" : persistIntervalSecs + "s" + ); + } } catch (final Throwable t) { throw new IllegalStateException("Failed to initialise Foyer block cache (diskDir=" + diskDir + ")", t); } @@ -232,16 +278,6 @@ public Collection createComponents( FoyerBlockCacheSettings.KEY_INDEX_PERSIST_INTERVAL_SETTING, newInterval -> finalCache.updatePersistInterval(newInterval) ); - logger.info( - "BlockCacheFoyerPlugin created FoyerBlockCache (diskDir={}, blockSize={}, ioEngine={}, " - + "sweepIntervalSecs={}, sweepThresholdRatio={}, persistIntervalSecs={})", - diskDir, - blockSizeBytes, - ioEngine, - sweepIntervalSecs == 0 ? "disabled" : sweepIntervalSecs + "s", - sweepThresholdRatio == 0.0 ? "always-sweep (no threshold)" : sweepThresholdRatio, - persistIntervalSecs == 0 ? "disabled" : persistIntervalSecs + "s" - ); return List.of(cache); } diff --git a/sandbox/plugins/block-cache-foyer/src/main/java/org/opensearch/blockcache/foyer/FoyerAggregatedStats.java b/sandbox/plugins/block-cache-foyer/src/main/java/org/opensearch/blockcache/foyer/FoyerAggregatedStats.java index 32e83bf285854..66507b8028e6d 100644 --- a/sandbox/plugins/block-cache-foyer/src/main/java/org/opensearch/blockcache/foyer/FoyerAggregatedStats.java +++ b/sandbox/plugins/block-cache-foyer/src/main/java/org/opensearch/blockcache/foyer/FoyerAggregatedStats.java @@ -13,14 +13,21 @@ /** * Point-in-time stats snapshot for the Foyer block cache. * - *

    Combines two sections parsed from the native FFM stats buffer: - * section 0 (overall cross-tier rollup) and section 1 (disk-tier only). - * Each section is a {@link BlockCacheStats} record carrying the full - * counter set. + *

    Two construction modes: + *

      + *
    • Single-cache — built via {@link #snapshot(long[], long)}. The FFM + * buffer's section 0 (overall) and section 1 (block-level / disk-tier) are + * written by Rust as identical copies; we keep both for back-compat with + * existing tests.
    • + *
    • Tiered — built via {@link #snapshotTiered(long[], long, long)}. + * Section 0 is the data tier and section 1 is the metadata tier. Both + * sections are stored directly; {@link #overallStats()} returns the + * eagerly-computed merge of the two so callers see a single rolled-up view.
    • + *
    * - *

    Core only ever sees the {@link BlockCacheStats} from {@link #overallStats()}, - * via {@link FoyerBlockCache#stats()}. The per-tier breakdown is available to - * Foyer-aware code via {@link FoyerBlockCache#foyerStats()}. + *

    Core only ever sees {@link #overallStats()} via {@link FoyerBlockCache#stats()}. + * Foyer-aware code can reach for {@link #dataCacheStats()} / {@link #metadataCacheStats()} + * which return {@code null} in single-cache mode and the per-tier sections in tiered mode. * * @opensearch.internal */ @@ -52,26 +59,83 @@ private enum Field { */ static final int STATS_BUFFER_SIZE = Field.COUNT * 2; - /** Cross-tier rollup — section 0 of the FFM buffer. */ + /** Cross-tier rollup. In tiered mode = {@code merge(data, meta)}; in single mode = section 0. */ private final BlockCacheStats overallStats; - /** Disk-tier only — section 1 of the FFM buffer. */ + /** + * Disk-tier breakdown. In tiered mode == {@link #dataCacheStats}; in single mode = section 1 + * of the FFM buffer (which Rust mirrors from section 0 in single-cache mode). + * Kept as a separate accessor for back-compat with existing tests. + */ private final BlockCacheStats blockLevelStats; - private FoyerAggregatedStats(BlockCacheStats overallStats, BlockCacheStats blockLevelStats) { + /** Tiered mode only. {@code null} in single-cache mode. */ + private final BlockCacheStats dataCacheStats; + + /** Tiered mode only. {@code null} in single-cache mode. */ + private final BlockCacheStats metadataCacheStats; + + /** Data cache configured capacity. {@code 0} in single-cache mode. */ + private final long dataCapacityBytes; + + /** Metadata cache configured capacity. {@code 0} in single-cache mode. */ + private final long metadataCapacityBytes; + + private FoyerAggregatedStats( + BlockCacheStats overallStats, + BlockCacheStats blockLevelStats, + BlockCacheStats dataCacheStats, + BlockCacheStats metadataCacheStats, + long dataCapacityBytes, + long metadataCapacityBytes + ) { this.overallStats = overallStats; this.blockLevelStats = blockLevelStats; + this.dataCacheStats = dataCacheStats; + this.metadataCacheStats = metadataCacheStats; + this.dataCapacityBytes = dataCapacityBytes; + this.metadataCapacityBytes = metadataCapacityBytes; } /** - * Parses both sections from the FFM stats buffer returned by - * {@code FoyerBridge.snapshotStats(cachePtr)}. + * Parses both sections from the FFM stats buffer for a single-cache (non-tiered) Foyer. * * @param raw stats buffer; length must be {@code >= 2 * Field.COUNT} * @param capacityBytes configured disk capacity for this cache instance */ public static FoyerAggregatedStats snapshot(long[] raw, long capacityBytes) { - return new FoyerAggregatedStats(readSection(raw, 0, capacityBytes), readSection(raw, Field.COUNT, capacityBytes)); + BlockCacheStats overall = readSection(raw, 0, capacityBytes); + BlockCacheStats blockLevel = readSection(raw, Field.COUNT, capacityBytes); + // dataCacheStats / metadataCacheStats are null in single-cache mode — callers asking + // for per-tier breakdown are expected to check isTiered() first. + return new FoyerAggregatedStats(overall, blockLevel, null, null, 0L, 0L); + } + + /** + * Parses the FFM stats buffer for a tiered cache (data + metadata on separate SSDs). + * + *

    The Rust FFM writes: + *

      + *
    • Indices 0–9: data cache stats
    • + *
    • Indices 10–19: metadata cache stats
    • + *
    + * + *

    {@link #dataCacheStats()} and {@link #metadataCacheStats()} return their respective + * sections directly — no subtraction, no derivation — so per-tier counters reflect + * exactly what the native side emitted. {@link #overallStats()} is the eagerly-computed + * merge of both tiers for callers that want a single rolled-up view. + * + * @param raw stats buffer; length must be {@code >= 2 * Field.COUNT} + * @param dataCapacityBytes data cache disk capacity + * @param metaCapacityBytes metadata cache disk capacity + */ + public static FoyerAggregatedStats snapshotTiered(long[] raw, long dataCapacityBytes, long metaCapacityBytes) { + BlockCacheStats data = readSection(raw, 0, dataCapacityBytes); + BlockCacheStats meta = readSection(raw, Field.COUNT, metaCapacityBytes); + BlockCacheStats overall = merge(data, meta); + // blockLevelStats == data is intentional: the historical "block-level" view in tiered + // mode pointed at the data tier, and existing tests assert on that. Keep it stable. + return new FoyerAggregatedStats(overall, data, data, meta, dataCapacityBytes, metaCapacityBytes); } private static BlockCacheStats readSection(long[] raw, int offset, long capacityBytes) { @@ -99,8 +163,60 @@ public BlockCacheStats overallStats() { return overallStats; } - /** Disk-tier-only stats — SSD I/O and eviction pressure breakdown. */ + /** + * Disk-tier-only stats — SSD I/O and eviction pressure breakdown. + * In tiered mode this returns the data tier (back-compat with the historical contract). + * Use {@link #dataCacheStats()} / {@link #metadataCacheStats()} for explicit per-tier access. + */ public BlockCacheStats blockLevelStats() { return blockLevelStats; } + + /** + * Data cache stats. Returns the data-tier section directly in tiered mode, + * or {@code null} in single-cache mode. + */ + public BlockCacheStats dataCacheStats() { + return dataCacheStats; + } + + /** + * Metadata cache stats. Returns the metadata-tier section directly in tiered mode, + * or {@code null} in single-cache mode. + */ + public BlockCacheStats metadataCacheStats() { + return metadataCacheStats; + } + + /** Data cache configured capacity. {@code 0} in single-cache mode. */ + public long dataCapacityBytes() { + return dataCapacityBytes; + } + + /** Metadata cache configured capacity. {@code 0} in single-cache mode. */ + public long metadataCapacityBytes() { + return metadataCapacityBytes; + } + + /** Whether this snapshot represents a tiered cache (data + metadata). */ + public boolean isTiered() { + return metadataCacheStats != null; + } + + private static BlockCacheStats merge(BlockCacheStats data, BlockCacheStats meta) { + return new BlockCacheStats( + data.hits() + meta.hits(), + data.misses() + meta.misses(), + data.hitBytes() + meta.hitBytes(), + data.missBytes() + meta.missBytes(), + data.evictions() + meta.evictions(), + data.evictionBytes() + meta.evictionBytes(), + data.removed() + meta.removed(), + data.removedBytes() + meta.removedBytes(), + 0L, + data.diskBytesUsed() + meta.diskBytesUsed(), + data.totalBytes() + meta.totalBytes(), + data.activeInBytes() + meta.activeInBytes() + ); + } } diff --git a/sandbox/plugins/block-cache-foyer/src/main/java/org/opensearch/blockcache/foyer/FoyerBlockCache.java b/sandbox/plugins/block-cache-foyer/src/main/java/org/opensearch/blockcache/foyer/FoyerBlockCache.java index 1ccbc8307aed3..1b5ef38d2b809 100644 --- a/sandbox/plugins/block-cache-foyer/src/main/java/org/opensearch/blockcache/foyer/FoyerBlockCache.java +++ b/sandbox/plugins/block-cache-foyer/src/main/java/org/opensearch/blockcache/foyer/FoyerBlockCache.java @@ -13,6 +13,7 @@ import org.opensearch.plugins.BlockCache; import org.opensearch.plugins.BlockCacheConstants; import org.opensearch.plugins.BlockCacheStats; +import org.opensearch.plugins.BlockCacheTieredStats; import org.opensearch.plugins.NativeStoreHandle; import java.util.Objects; @@ -33,6 +34,15 @@ public final class FoyerBlockCache implements BlockCache { /** The configured disk capacity in bytes */ private final long diskBytes; + /** Data cache capacity in tiered mode. 0 in single-cache mode. */ + private final long dataDiskBytes; + + /** Metadata cache capacity in tiered mode. 0 in single-cache mode. */ + private final long metadataDiskBytes; + + /** Whether this instance is a tiered cache (data + metadata on separate SSDs). */ + private final boolean tiered; + /** Guards against double-close per the {@link AutoCloseable} contract. */ private final AtomicBoolean closed = new AtomicBoolean(false); @@ -97,6 +107,9 @@ public FoyerBlockCache( throw new IllegalArgumentException("persistIntervalSecs must be >= 0, got: " + persistIntervalSecs); } this.diskBytes = diskBytes; + this.dataDiskBytes = 0L; + this.metadataDiskBytes = 0L; + this.tiered = false; this.cachePtr = FoyerBridge.createCache( diskBytes, diskDir, @@ -110,6 +123,71 @@ public FoyerBlockCache( ); } + /** + * Create a tiered Foyer cache with separate data and metadata SSDs. + * + * @param dataDiskBytes data cache disk capacity + * @param dataDiskDir directory for data cache + * @param dataBlockSizeBytes data cache block size + * @param dataBufferPoolSizeBytes data cache buffer pool + * @param dataSubmitQueueSizeBytes data cache submit queue threshold + * @param metaDiskBytes metadata cache disk capacity + * @param metaDiskDir directory for metadata cache + * @param metaBlockSizeBytes metadata cache block size + * @param metaBufferPoolSizeBytes metadata cache buffer pool + * @param metaSubmitQueueSizeBytes metadata cache submit queue threshold + * @param ioEngine I/O engine for both caches + * @param sweepIntervalSecs sweep interval; 0 = disabled + * @param sweepThresholdRatio sweep threshold; 0.0 = always + * @param persistIntervalSecs persist interval; 0 = disabled + */ + public FoyerBlockCache( + long dataDiskBytes, + String dataDiskDir, + long dataBlockSizeBytes, + long dataBufferPoolSizeBytes, + long dataSubmitQueueSizeBytes, + long metaDiskBytes, + String metaDiskDir, + long metaBlockSizeBytes, + long metaBufferPoolSizeBytes, + long metaSubmitQueueSizeBytes, + String ioEngine, + long sweepIntervalSecs, + double sweepThresholdRatio, + long persistIntervalSecs + ) { + if (dataDiskBytes <= 0) { + throw new IllegalArgumentException("dataDiskBytes must be > 0, got: " + dataDiskBytes); + } + if (metaDiskBytes <= 0) { + throw new IllegalArgumentException("metaDiskBytes must be > 0, got: " + metaDiskBytes); + } + Objects.requireNonNull(dataDiskDir, "dataDiskDir must not be null"); + Objects.requireNonNull(metaDiskDir, "metaDiskDir must not be null"); + Objects.requireNonNull(ioEngine, "ioEngine must not be null"); + this.diskBytes = dataDiskBytes + metaDiskBytes; + this.dataDiskBytes = dataDiskBytes; + this.metadataDiskBytes = metaDiskBytes; + this.tiered = true; + this.cachePtr = FoyerBridge.createTieredCache( + dataDiskBytes, + dataDiskDir, + dataBlockSizeBytes, + dataBufferPoolSizeBytes, + dataSubmitQueueSizeBytes, + metaDiskBytes, + metaDiskDir, + metaBlockSizeBytes, + metaBufferPoolSizeBytes, + metaSubmitQueueSizeBytes, + ioEngine, + sweepIntervalSecs, + sweepThresholdRatio, + persistIntervalSecs + ); + } + @Override public String cacheName() { return BlockCacheConstants.FOYER; @@ -117,13 +195,8 @@ public String cacheName() { /** * Snapshots cache statistics via FFM call to the native Foyer runtime. - * Returns the cross-tier rollup {@link BlockCacheStats} (section 0 of the - * native stats buffer) for core consumption. - * - *

    Delegates to {@link #foyerStats()} and returns the overall section - * directly — no projection step needed. Core code uses this method; - * Foyer-aware code that needs the disk-tier breakdown (section 1) should - * call {@link #foyerStats()} directly. + * Returns the cross-tier rollup {@link BlockCacheStats} for core consumption. + * In tiered mode, this is the merged (data + metadata) view. */ @Override public BlockCacheStats stats() { @@ -131,25 +204,61 @@ public BlockCacheStats stats() { } /** - * Snapshots the full two-section Foyer stats from the native runtime: - *

      - *
    • section 0 — cross-tier overall rollup
    • - *
    • section 1 — disk-tier (block-level) only
    • - *
    + * Snapshots the full Foyer stats from the native runtime. * - *

    Returns richer counters than {@link #stats()}: per-tier hit/miss/eviction - * byte counts, configured capacity, and the disk-tier breakdown. Intended for - * Foyer-internal logging, node-stats contributions, and any caller that - * explicitly narrows the {@link org.opensearch.plugins.BlockCache} reference - * to {@code FoyerBlockCache}. + *

    In single-cache mode: section 0 = overall, section 1 = block-level (identical). + * In tiered mode: section 0 = data cache, section 1 = metadata cache, + * {@code overallStats()} returns merged counters. * - * @return two-section snapshot; never {@code null} + * @return stats snapshot; never {@code null} */ public FoyerAggregatedStats foyerStats() { long[] raw = FoyerBridge.snapshotStats(cachePtr); + if (tiered) { + return FoyerAggregatedStats.snapshotTiered(raw, dataDiskBytes, metadataDiskBytes); + } return FoyerAggregatedStats.snapshot(raw, diskBytes); } + /** Whether this is a tiered cache (data + metadata on separate SSDs). */ + public boolean isTiered() { + return tiered; + } + + @Override + public BlockCacheTieredStats tieredStats() { + if (tiered == false) { + return null; + } + FoyerAggregatedStats s = foyerStats(); + BlockCacheStats data = s.dataCacheStats(); + BlockCacheStats meta = s.metadataCacheStats(); + return new BlockCacheTieredStats( + data.hits(), + data.misses(), + data.hitBytes(), + data.missBytes(), + data.evictions(), + data.evictionBytes(), + data.removed(), + data.removedBytes(), + data.diskBytesUsed(), + data.totalBytes(), + data.activeInBytes(), + meta.hits(), + meta.misses(), + meta.hitBytes(), + meta.missBytes(), + meta.evictions(), + meta.evictionBytes(), + meta.removed(), + meta.removedBytes(), + meta.diskBytesUsed(), + meta.totalBytes(), + meta.activeInBytes() + ); + } + /** * Returns a borrowed, non-owning {@link NativeStoreHandle} for this Foyer cache. * diff --git a/sandbox/plugins/block-cache-foyer/src/main/java/org/opensearch/blockcache/foyer/FoyerBlockCacheSettings.java b/sandbox/plugins/block-cache-foyer/src/main/java/org/opensearch/blockcache/foyer/FoyerBlockCacheSettings.java index 3c556a8e115c2..6dae9be90ed70 100644 --- a/sandbox/plugins/block-cache-foyer/src/main/java/org/opensearch/blockcache/foyer/FoyerBlockCacheSettings.java +++ b/sandbox/plugins/block-cache-foyer/src/main/java/org/opensearch/blockcache/foyer/FoyerBlockCacheSettings.java @@ -124,21 +124,22 @@ public final class FoyerBlockCacheSettings { * I/O engine for the Foyer disk tier. * *

      - *
    • {@code auto} (default) — selects io_uring on Linux ≥ 5.1, - * falls back to psync otherwise.
    • + *
    • {@code psync} (default) — synchronous pread/pwrite. Portable across + * all kernels and container sandboxes, and gives predictable + * syscall-level behaviour.
    • + *
    • {@code auto} — selects io_uring on Linux ≥ 5.1, falls back to + * psync otherwise.
    • *
    • {@code io_uring} — force io_uring regardless of kernel detection. * Fails at startup if io_uring is unavailable (e.g. blocked by seccomp * or AppArmor in locked-down container environments).
    • - *
    • {@code psync} — force synchronous pread/pwrite. Use when io_uring is - * restricted or when predictable syscall-level profiling is needed.
    • *
    * *

    Configure in {@code opensearch.yml}: *

    {@code
    -     * block_cache.foyer.io_engine: auto
    +     * block_cache.foyer.io_engine: psync
          * }
    */ - public static final Setting IO_ENGINE_SETTING = new Setting<>("block_cache.foyer.io_engine", "auto", value -> { + public static final Setting IO_ENGINE_SETTING = new Setting<>("block_cache.foyer.io_engine", "psync", value -> { if (!Set.of("auto", "io_uring", "psync").contains(value)) { throw new IllegalArgumentException("[block_cache.foyer.io_engine] must be one of: auto, io_uring, psync; got: " + value); } @@ -218,5 +219,57 @@ public final class FoyerBlockCacheSettings { Setting.Property.Dynamic ); + /** + * Fraction of the total Foyer disk budget allocated to the metadata cache. + * + *

    The remaining {@code (1 - ratio)} goes to the data cache. Applied against the + * total Foyer disk bytes computed from {@code block_cache.foyer.size}. + * + *

    Example: 400 GB Foyer budget, {@code metadata_cache_ratio=5%} → + * metadata cache gets 20 GB, data cache gets 380 GB. + * + *

    Default: {@code 5%}. Set to {@code 0%} to disable tiered caching (single + * data-only cache). Accepts a percentage (e.g. {@code 5%}) or a ratio (e.g. {@code 0.05}). + * + *

    Range: [0%, 50%). Values ≥ 50% are rejected — metadata should never consume + * more than half the SSD budget. + */ + public static final Setting METADATA_CACHE_RATIO_SETTING = new Setting<>( + "block_cache.foyer.metadata_cache_ratio", + "5%", + value -> { + try { + RatioValue ratio = RatioValue.parseRatioValue(value); + if (ratio.getAsRatio() < 0 || ratio.getAsRatio() >= 0.5) { + throw new IllegalArgumentException("[block_cache.foyer.metadata_cache_ratio] must be in [0%, 50%); got: " + value); + } + return value; + } catch (Exception e) { + throw new IllegalArgumentException( + "[block_cache.foyer.metadata_cache_ratio] must be a percentage (e.g. 5%) or ratio (e.g. 0.05); got: " + value, + e + ); + } + }, + Setting.Property.NodeScope + ); + + /** + * Block size for the metadata cache's Foyer disk tier. + * + *

    Metadata entries (page indexes, offset indexes) are typically small (KB–MB range). + * A smaller block size than the data cache avoids wasting SSD space on internal + * fragmentation for these small entries. + * + *

    Default: 8 MB. Range: [1 MB, 128 MB]. + */ + public static final Setting METADATA_BLOCK_SIZE_SETTING = Setting.byteSizeSetting( + "block_cache.foyer.metadata_block_size", + new ByteSizeValue(8, ByteSizeUnit.MB), + new ByteSizeValue(1, ByteSizeUnit.MB), + new ByteSizeValue(128, ByteSizeUnit.MB), + Setting.Property.NodeScope + ); + private FoyerBlockCacheSettings() {} } diff --git a/sandbox/plugins/block-cache-foyer/src/main/java/org/opensearch/blockcache/foyer/FoyerBridge.java b/sandbox/plugins/block-cache-foyer/src/main/java/org/opensearch/blockcache/foyer/FoyerBridge.java index a361084cd6f91..b93148027fd8f 100644 --- a/sandbox/plugins/block-cache-foyer/src/main/java/org/opensearch/blockcache/foyer/FoyerBridge.java +++ b/sandbox/plugins/block-cache-foyer/src/main/java/org/opensearch/blockcache/foyer/FoyerBridge.java @@ -38,6 +38,7 @@ public final class FoyerBridge { private static final Logger logger = LogManager.getLogger(FoyerBridge.class); private static final MethodHandle FOYER_CREATE_CACHE; + private static final MethodHandle FOYER_CREATE_TIERED_CACHE; private static final MethodHandle FOYER_DESTROY_CACHE; private static final MethodHandle FOYER_SNAPSHOT_STATS; private static final MethodHandle FOYER_EVICT_PREFIX; @@ -68,6 +69,34 @@ public final class FoyerBridge { ) ); + // foyer_create_tiered_cache(data_disk, data_dir_ptr, data_dir_len, data_block_size, + // data_buffer_pool, data_submit_queue, meta_disk, meta_dir_ptr, meta_dir_len, + // meta_block_size, meta_buffer_pool, meta_submit_queue, io_engine_ptr, io_engine_len, + // sweep_interval, sweep_threshold, persist_interval) -> i64 + FOYER_CREATE_TIERED_CACHE = linker.downcallHandle( + lib.find("foyer_create_tiered_cache").orElseThrow(), + FunctionDescriptor.of( + ValueLayout.JAVA_LONG, // return: opaque i64 fat pointer + ValueLayout.JAVA_LONG, // data_disk_bytes + ValueLayout.ADDRESS, // data_dir_ptr + ValueLayout.JAVA_LONG, // data_dir_len + ValueLayout.JAVA_LONG, // data_block_size_bytes + ValueLayout.JAVA_LONG, // data_buffer_pool_size_bytes + ValueLayout.JAVA_LONG, // data_submit_queue_size_threshold_bytes + ValueLayout.JAVA_LONG, // meta_disk_bytes + ValueLayout.ADDRESS, // meta_dir_ptr + ValueLayout.JAVA_LONG, // meta_dir_len + ValueLayout.JAVA_LONG, // meta_block_size_bytes + ValueLayout.JAVA_LONG, // meta_buffer_pool_size_bytes + ValueLayout.JAVA_LONG, // meta_submit_queue_size_threshold_bytes + ValueLayout.ADDRESS, // io_engine_ptr + ValueLayout.JAVA_LONG, // io_engine_len + ValueLayout.JAVA_LONG, // sweep_interval_secs + ValueLayout.JAVA_DOUBLE, // sweep_threshold_ratio + ValueLayout.JAVA_LONG // persist_interval_secs + ) + ); + // i64 foyer_destroy_cache(i64 ptr) — 0=success, <0=error pointer FOYER_DESTROY_CACHE = linker.downcallHandle( lib.find("foyer_destroy_cache").orElseThrow(), @@ -138,8 +167,8 @@ public final class FoyerBridge { ); logger.info( - "FFM downcall handles resolved: foyer_create_cache, foyer_destroy_cache, foyer_snapshot_stats, " - + "foyer_evict_prefix, foyer_clear_cache, foyer_update_sweep_threshold, " + "FFM downcall handles resolved: foyer_create_cache, foyer_create_tiered_cache, foyer_destroy_cache, " + + "foyer_snapshot_stats, foyer_evict_prefix, foyer_clear_cache, foyer_update_sweep_threshold, " + "foyer_update_sweep_interval, foyer_update_persist_interval" ); } @@ -211,6 +240,86 @@ public static long createCache( } } + /** + * Create a tiered Foyer block cache with separate data and metadata SSDs. + * + *

    Returns a {@code Box>} fat pointer that can be passed + * directly as {@code cache_box_ptr} to {@code ts_create_tiered_object_store}. + * + * @param dataDiskBytes data cache disk capacity in bytes + * @param dataDiskDir directory for data cache files + * @param dataBlockSizeBytes data cache block size in bytes + * @param dataBufferPoolSizeBytes data cache buffer pool size in bytes + * @param dataSubmitQueueSizeBytes data cache submit queue threshold in bytes + * @param metaDiskBytes metadata cache disk capacity in bytes + * @param metaDiskDir directory for metadata cache files + * @param metaBlockSizeBytes metadata cache block size in bytes + * @param metaBufferPoolSizeBytes metadata cache buffer pool size in bytes + * @param metaSubmitQueueSizeBytes metadata cache submit queue threshold in bytes + * @param ioEngine I/O engine for both caches + * @param sweepIntervalSecs sweep interval for both caches; 0 = disabled + * @param sweepThresholdRatio sweep threshold for both caches; 0.0 = always + * @param persistIntervalSecs persist interval for both caches; 0 = disabled + * @return opaque handle; always positive on success + */ + public static long createTieredCache( + long dataDiskBytes, + String dataDiskDir, + long dataBlockSizeBytes, + long dataBufferPoolSizeBytes, + long dataSubmitQueueSizeBytes, + long metaDiskBytes, + String metaDiskDir, + long metaBlockSizeBytes, + long metaBufferPoolSizeBytes, + long metaSubmitQueueSizeBytes, + String ioEngine, + long sweepIntervalSecs, + double sweepThresholdRatio, + long persistIntervalSecs + ) { + try (var call = new NativeCall()) { + var dataDir = call.str(dataDiskDir); + var metaDir = call.str(metaDiskDir); + var engine = call.str(ioEngine); + long ptr = call.invoke( + FOYER_CREATE_TIERED_CACHE, + dataDiskBytes, + dataDir.segment(), + dataDir.len(), + dataBlockSizeBytes, + dataBufferPoolSizeBytes, + dataSubmitQueueSizeBytes, + metaDiskBytes, + metaDir.segment(), + metaDir.len(), + metaBlockSizeBytes, + metaBufferPoolSizeBytes, + metaSubmitQueueSizeBytes, + engine.segment(), + engine.len(), + sweepIntervalSecs, + sweepThresholdRatio, + persistIntervalSecs + ); + if (ptr <= 0) { + throw new IllegalStateException("foyer_create_tiered_cache returned an invalid handle"); + } + logger.info( + "Foyer tiered block cache created: dataDisk={}, metaDisk={}, dataBlockSize={}, metaBlockSize={}, " + + "ioEngine={}, dataDir={}, metaDir={}", + dataDiskBytes, + metaDiskBytes, + dataBlockSizeBytes, + metaBlockSizeBytes, + ioEngine, + dataDiskDir, + metaDiskDir + ); + return ptr; + } + } + /** * Destroy a cache previously created by {@link #createCache}. * diff --git a/sandbox/plugins/block-cache-foyer/src/main/rust/src/foyer/ffm.rs b/sandbox/plugins/block-cache-foyer/src/main/rust/src/foyer/ffm.rs index 6b0fae0a9fabe..02fdbccb4f8f2 100644 --- a/sandbox/plugins/block-cache-foyer/src/main/rust/src/foyer/ffm.rs +++ b/sandbox/plugins/block-cache-foyer/src/main/rust/src/foyer/ffm.rs @@ -8,9 +8,10 @@ //! FFM lifecycle entry points exported to Java. -use std::sync::Arc; -use native_bridge_common::ffm_safe; use crate::foyer::foyer_cache::FoyerCache; +use crate::tiered_block_cache::TieredBlockCache; +use native_bridge_common::ffm_safe; +use std::sync::Arc; /// Create a [`FoyerCache`] and return an opaque `Box>` fat pointer as `i64`. /// @@ -57,8 +58,11 @@ pub unsafe extern "C" fn foyer_create_cache( let io_engine = if io_engine_ptr.is_null() { "auto" } else { - std::str::from_utf8(std::slice::from_raw_parts(io_engine_ptr, io_engine_len as usize)) - .unwrap_or("auto") + std::str::from_utf8(std::slice::from_raw_parts( + io_engine_ptr, + io_engine_len as usize, + )) + .unwrap_or("auto") }; let cache: Arc = Arc::new(FoyerCache::new( disk_bytes as usize, @@ -70,6 +74,7 @@ pub unsafe extern "C" fn foyer_create_cache( sweep_interval_secs, sweep_threshold_ratio, persist_interval_secs, + false, // reinsertion_admit_all: default RejectAll for standard data cache )); Ok(Box::into_raw(Box::new(cache)) as i64) } @@ -86,7 +91,9 @@ pub unsafe extern "C" fn foyer_destroy_cache(ptr: i64) -> i64 { if ptr <= 0 { return Err(format!("foyer_destroy_cache: invalid ptr {}", ptr)); } - drop(Box::from_raw(ptr as *mut Arc)); + drop(Box::from_raw( + ptr as *mut Arc, + )); Ok(0) } @@ -126,18 +133,24 @@ pub unsafe extern "C" fn foyer_snapshot_stats(ptr: i64, out: *mut i64) -> i64 { } // Borrow the Box> without consuming it. let boxed = &*(ptr as *const Arc); - // Downcast to FoyerCache to access Foyer-specific stats. - let foyer = match boxed.as_any().downcast_ref::() { - Some(f) => f, - None => return -1, - }; - let single = foyer.stats.snapshot(); - // Foyer is currently single-tier (disk only): overall and block_level are identical. - // 10 fields × 2 sections = 20 longs total. let mut flat = [0i64; 20]; - flat[..10].copy_from_slice(&single); - flat[10..].copy_from_slice(&single); + + if let Some(foyer) = boxed.as_any().downcast_ref::() { + // Single-tier: overall and block_level are identical. + let single = foyer.stats.snapshot(); + flat[..10].copy_from_slice(&single); + flat[10..].copy_from_slice(&single); + } else if let Some(tiered) = boxed.as_any().downcast_ref::() { + // Tiered: indices 0-9 = data cache stats, indices 10-19 = metadata cache stats. + let data_stats = tiered.data_cache().stats.snapshot(); + let meta_stats = tiered.metadata_cache().stats.snapshot(); + flat[..10].copy_from_slice(&data_stats); + flat[10..].copy_from_slice(&meta_stats); + } else { + return -1; + } + for (i, &v) in flat.iter().enumerate() { *out.add(i) = v; } @@ -161,11 +174,15 @@ pub unsafe extern "C" fn foyer_clear_cache(ptr: i64) -> i64 { return Err(format!("foyer_clear_cache: invalid ptr {}", ptr)); } let boxed = &*(ptr as *const Arc); - let foyer = match boxed.as_any().downcast_ref::() { - Some(f) => f, - None => return Err("foyer_clear_cache: downcast to FoyerCache failed".to_string()), - }; - foyer.clear_sync(); + if let Some(foyer) = boxed.as_any().downcast_ref::() { + foyer.clear_sync(); + } else if let Some(tiered) = boxed.as_any().downcast_ref::() { + tiered.clear_sync(); + } else { + return Err( + "foyer_clear_cache: downcast to FoyerCache or TieredBlockCache failed".to_string(), + ); + } native_bridge_common::log_info!("ffm: foyer_clear_cache completed"); Ok(0) } @@ -192,11 +209,14 @@ pub unsafe extern "C" fn foyer_update_sweep_threshold(ptr: i64, new_ratio: f64) return Err(format!("foyer_update_sweep_threshold: invalid ptr {}", ptr)); } let boxed = &*(ptr as *const Arc); - let foyer = match boxed.as_any().downcast_ref::() { - Some(f) => f, - None => return Err("foyer_update_sweep_threshold: downcast to FoyerCache failed".to_string()), - }; - foyer.update_sweep_threshold(new_ratio); + if let Some(foyer) = boxed.as_any().downcast_ref::() { + foyer.update_sweep_threshold(new_ratio); + } else if let Some(tiered) = boxed.as_any().downcast_ref::() { + tiered.data_cache().update_sweep_threshold(new_ratio); + tiered.metadata_cache().update_sweep_threshold(new_ratio); + } else { + return Err("foyer_update_sweep_threshold: downcast failed".to_string()); + } Ok(0) } @@ -208,11 +228,14 @@ pub unsafe extern "C" fn foyer_update_sweep_interval(ptr: i64, new_secs: u64) -> return Err(format!("foyer_update_sweep_interval: invalid ptr {}", ptr)); } let boxed = &*(ptr as *const Arc); - let foyer = match boxed.as_any().downcast_ref::() { - Some(f) => f, - None => return Err("foyer_update_sweep_interval: downcast failed".to_string()), - }; - foyer.update_sweep_interval(new_secs); + if let Some(foyer) = boxed.as_any().downcast_ref::() { + foyer.update_sweep_interval(new_secs); + } else if let Some(tiered) = boxed.as_any().downcast_ref::() { + tiered.data_cache().update_sweep_interval(new_secs); + tiered.metadata_cache().update_sweep_interval(new_secs); + } else { + return Err("foyer_update_sweep_interval: downcast failed".to_string()); + } Ok(0) } @@ -221,14 +244,20 @@ pub unsafe extern "C" fn foyer_update_sweep_interval(ptr: i64, new_secs: u64) -> #[no_mangle] pub unsafe extern "C" fn foyer_update_persist_interval(ptr: i64, new_secs: u64) -> i64 { if ptr <= 0 { - return Err(format!("foyer_update_persist_interval: invalid ptr {}", ptr)); + return Err(format!( + "foyer_update_persist_interval: invalid ptr {}", + ptr + )); } let boxed = &*(ptr as *const Arc); - let foyer = match boxed.as_any().downcast_ref::() { - Some(f) => f, - None => return Err("foyer_update_persist_interval: downcast failed".to_string()), - }; - foyer.update_persist_interval(new_secs); + if let Some(foyer) = boxed.as_any().downcast_ref::() { + foyer.update_persist_interval(new_secs); + } else if let Some(tiered) = boxed.as_any().downcast_ref::() { + tiered.data_cache().update_persist_interval(new_secs); + tiered.metadata_cache().update_persist_interval(new_secs); + } else { + return Err("foyer_update_persist_interval: downcast failed".to_string()); + } Ok(0) } @@ -250,10 +279,122 @@ pub extern "C" fn foyer_evict_prefix(ptr: i64, prefix_ptr: *const u8, prefix_len } let prefix = unsafe { let bytes = std::slice::from_raw_parts(prefix_ptr, prefix_len as usize); - std::str::from_utf8(bytes).map_err(|e| format!("foyer_evict_prefix: invalid utf-8: {}", e))? + std::str::from_utf8(bytes) + .map_err(|e| format!("foyer_evict_prefix: invalid utf-8: {}", e))? }; let boxed = unsafe { &*(ptr as *const Arc) }; boxed.evict_prefix(prefix); native_bridge_common::log_debug!("ffm: foyer_evict_prefix prefix='{}'", prefix); Ok(0) } + +/// Create a [`TieredBlockCache`] with separate data and metadata caches. +/// +/// Returns an opaque `Box>` fat pointer as `i64`. +/// The data cache uses default reinsertion (RejectAll) and the metadata cache +/// uses AdmitAll reinsertion so that metadata entries are never evicted. +/// +/// # Parameters +/// - `data_*` — parameters for the data cache (large SSD, normal eviction). +/// - `meta_*` — parameters for the metadata cache (small SSD, never-evict). +/// - Shared parameters (`io_engine_*`, `sweep_*`, `persist_*`) apply to both caches. +/// +/// # Safety +/// All `*_ptr` parameters must point to `*_len` consecutive valid UTF-8 bytes. +#[ffm_safe] +#[no_mangle] +pub unsafe extern "C" fn foyer_create_tiered_cache( + // Data cache params + data_disk_bytes: u64, + data_dir_ptr: *const u8, + data_dir_len: u64, + data_block_size_bytes: u64, + data_buffer_pool_size_bytes: u64, + data_submit_queue_size_threshold_bytes: u64, + // Metadata cache params + meta_disk_bytes: u64, + meta_dir_ptr: *const u8, + meta_dir_len: u64, + meta_block_size_bytes: u64, + meta_buffer_pool_size_bytes: u64, + meta_submit_queue_size_threshold_bytes: u64, + // Shared params + io_engine_ptr: *const u8, + io_engine_len: u64, + sweep_interval_secs: u64, + sweep_threshold_ratio: f64, + persist_interval_secs: u64, +) -> i64 { + // Parse data dir + if data_dir_ptr.is_null() { + return Err("data_dir_ptr is null".to_string()); + } + let data_dir = std::str::from_utf8(std::slice::from_raw_parts( + data_dir_ptr, + data_dir_len as usize, + )) + .map_err(|e| format!("invalid UTF-8 in data_dir path: {}", e))?; + + // Parse metadata dir + if meta_dir_ptr.is_null() { + return Err("meta_dir_ptr is null".to_string()); + } + let meta_dir = std::str::from_utf8(std::slice::from_raw_parts( + meta_dir_ptr, + meta_dir_len as usize, + )) + .map_err(|e| format!("invalid UTF-8 in meta_dir path: {}", e))?; + + // Parse io_engine + let io_engine = if io_engine_ptr.is_null() { + "auto" + } else { + std::str::from_utf8(std::slice::from_raw_parts( + io_engine_ptr, + io_engine_len as usize, + )) + .unwrap_or("auto") + }; + + // Build data cache (default reinsertion = RejectAll) + let data_cache = Arc::new(FoyerCache::new( + data_disk_bytes as usize, + data_dir, + data_block_size_bytes as usize, + data_buffer_pool_size_bytes as usize, + data_submit_queue_size_threshold_bytes as usize, + io_engine, + sweep_interval_secs, + sweep_threshold_ratio, + persist_interval_secs, + false, // reinsertion_admit_all = false (RejectAll) + )); + + // Build metadata cache (same LRU as data cache — separate SSD prevents + // data pressure from evicting metadata) + let metadata_cache = Arc::new(FoyerCache::new( + meta_disk_bytes as usize, + meta_dir, + meta_block_size_bytes as usize, + meta_buffer_pool_size_bytes as usize, + meta_submit_queue_size_threshold_bytes as usize, + io_engine, + sweep_interval_secs, + sweep_threshold_ratio, + persist_interval_secs, + false, // reinsertion_admit_all = false (normal LRU, same as data) + )); + + native_bridge_common::log_info!( + "[tiered-block-cache] ffm: created tiered cache: data_dir={}, meta_dir={}, \ + data_disk={}B, meta_disk={}B", + data_dir, + meta_dir, + data_disk_bytes, + meta_disk_bytes + ); + + let cache: Arc = + Arc::new(TieredBlockCache::new(data_cache, metadata_cache)); + Ok(Box::into_raw(Box::new(cache)) as i64) +} diff --git a/sandbox/plugins/block-cache-foyer/src/main/rust/src/foyer/foyer_cache.rs b/sandbox/plugins/block-cache-foyer/src/main/rust/src/foyer/foyer_cache.rs index c9f9f60f25f7c..25fdd8295c1d9 100644 --- a/sandbox/plugins/block-cache-foyer/src/main/rust/src/foyer/foyer_cache.rs +++ b/sandbox/plugins/block-cache-foyer/src/main/rust/src/foyer/foyer_cache.rs @@ -8,21 +8,23 @@ //! [`FoyerCache`] — a [`BlockCache`] implementation backed by Foyer. +use bytes::Bytes; +use dashmap::DashMap; +#[cfg(target_os = "linux")] +use foyer::UringIoEngineConfig; +use foyer::{ + AdmitAll, BlockEngineConfig, DeviceBuilder, FsDeviceBuilder, HybridCache, HybridCacheBuilder, + IoEngineConfig, PsyncIoEngineConfig, RecoverMode, StorageFilter, +}; use std::collections::HashSet; use std::path::PathBuf; -use std::sync::Arc; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::Arc; use std::time::{Duration, Instant}; -use bytes::Bytes; -use dashmap::DashMap; -use foyer::{BlockEngineConfig, DeviceBuilder, FsDeviceBuilder, - HybridCache, HybridCacheBuilder, IoEngineConfig, PsyncIoEngineConfig, RecoverMode}; use tokio_util::sync::CancellationToken; -#[cfg(target_os = "linux")] -use foyer::UringIoEngineConfig; use crate::key_index_store; -use crate::range_cache::{CacheKey, SEPARATOR, key_byte_size}; +use crate::range_cache::{key_byte_size, CacheKey, SEPARATOR}; use crate::stats::FoyerStatsCounter; use crate::traits::BlockCache; @@ -69,7 +71,10 @@ fn build_io_engine_config(choice: &str) -> Box { } other => { if other != "auto" { - native_bridge_common::log_info!("[block-cache] unknown io_engine='{}'; falling back to auto-detect", other); + native_bridge_common::log_info!( + "[block-cache] unknown io_engine='{}'; falling back to auto-detect", + other + ); } // "auto" — detect by kernel version (existing logic) #[cfg(target_os = "linux")] @@ -226,12 +231,29 @@ pub struct FoyerCache { impl Drop for FoyerCache { fn drop(&mut self) { + // Flush partial blocks to SSD before shutdown with a timeout. + // Without close(), entries smaller than block_size are lost on restart. + // Timeout prevents indefinite blocking if the flusher is stuck. + let close_result = self._runtime.block_on(async { + tokio::time::timeout(std::time::Duration::from_secs(30), self.inner.close()).await + }); + match close_result { + Ok(Ok(())) => {} + Ok(Err(e)) => { + native_bridge_common::log_info!( + "[block-cache] HybridCache close FAILED on shutdown: {}", + e + ); + } + Err(_) => { + native_bridge_common::log_info!( + "[block-cache] HybridCache close TIMED OUT (30s) on shutdown — partial blocks may be lost" + ); + } + } + // Unconditional final persist — graceful shutdown path. // Writes the complete current key_index as the authoritative final snapshot. - // This supersedes any earlier periodic persist and ensures the most - // up-to-date state is available on the next restart. - // On crash (OOM, SIGKILL) Drop is not called; the cache restarts from the - // last periodic snapshot, or with an empty key_index if no snapshot exists. if let Err(e) = key_index_store::save(&self.cache_dir, &self.key_index) { native_bridge_common::log_info!( "[block-cache] key_index final persist FAILED on shutdown: {}", @@ -284,27 +306,43 @@ impl FoyerCache { sweep_interval_secs: u64, sweep_threshold_ratio: f64, persist_interval_secs: u64, + reinsertion_admit_all: bool, ) -> Self { let disk_dir: PathBuf = disk_dir.into(); let key_index: Arc>> = Arc::new(DashMap::new()); let stats = FoyerStatsCounter::new(); - let rt = tokio::runtime::Runtime::new() - .expect("[block-cache] failed to create Tokio runtime"); + let rt = + tokio::runtime::Runtime::new().expect("[block-cache] failed to create Tokio runtime"); // Clone disk_dir only for the async closure; the original is moved into cache_dir // after block_on returns. let dir_clone = disk_dir.clone(); let io_engine = io_engine.to_string(); - let io_engine_for_log = io_engine.clone(); // clone for use in log after the closure + let io_engine_for_log = io_engine.clone(); // clone for use in log after the closure let inner = rt.block_on(async move { + let mut engine_config = BlockEngineConfig::new( + FsDeviceBuilder::new(dir_clone) + .with_capacity(disk_bytes) + .build() + .expect("[block-cache] FsDevice build failed"), + ) + .with_block_size(block_size_bytes) + .with_buffer_pool_size(buffer_pool_size_bytes) + .with_submit_queue_size_threshold(submit_queue_size_threshold_bytes); + + if reinsertion_admit_all { + engine_config = engine_config + .with_reinsertion_filter(StorageFilter::new().with_condition(AdmitAll)); + } + HybridCacheBuilder::>::new() .with_name("block-cache") .memory(1) - // Disable the in-memory tier — this cache is disk-only. - // Foyer is a hybrid (DRAM + disk) cache; setting the memory capacity - // to 1 byte opts out of DRAM caching. All entries go directly to the - // disk tier (FsDevice) below. + // Disable the in-memory tier — this cache is disk-only. + // Foyer is a hybrid (DRAM + disk) cache; setting the memory capacity + // to 1 byte opts out of DRAM caching. All entries go directly to the + // disk tier (FsDevice) below. .storage() // RecoverMode::Quiet recovers existing disk entries into Foyer's in-RAM // index without raising an error on corrupted pages. Together with @@ -313,27 +351,28 @@ impl FoyerCache { // On a fresh directory (clean startup) Quiet behaves identically to None. .with_recover_mode(RecoverMode::Quiet) .with_io_engine_config(build_io_engine_config(&io_engine)) - .with_engine_config( - BlockEngineConfig::new( - FsDeviceBuilder::new(dir_clone) - .with_capacity(disk_bytes) - .build() - .expect("[block-cache] FsDevice build failed") - ) - .with_block_size(block_size_bytes) - .with_buffer_pool_size(buffer_pool_size_bytes) - .with_submit_queue_size_threshold(submit_queue_size_threshold_bytes) - ) + .with_engine_config(engine_config) .build() .await .expect("[block-cache] HybridCache build failed") }); native_bridge_common::log_info!( "[block-cache] ready: disk={}B, block_size={}B, io_engine={}, sweep_threshold={:.0}%, \ - persist_interval={}s, dir={}", - disk_bytes, block_size_bytes, io_engine_for_log, + persist_interval={}s, reinsertion={}, dir={}", + disk_bytes, + block_size_bytes, + io_engine_for_log, sweep_threshold_ratio * 100.0, - if persist_interval_secs == 0 { "disabled".to_string() } else { persist_interval_secs.to_string() }, + if persist_interval_secs == 0 { + "disabled".to_string() + } else { + persist_interval_secs.to_string() + }, + if reinsertion_admit_all { + "AdmitAll" + } else { + "RejectAll" + }, disk_dir.display() ); // CancellationToken is Clone and Send — cheap to share with background tasks. @@ -345,7 +384,7 @@ impl FoyerCache { // ── Construct instance ──────────────────────────────────────────────── let sweep_threshold_atomic = Arc::new(AtomicU64::new(sweep_threshold_ratio.to_bits())); - let sweep_interval_atomic = Arc::new(AtomicU64::new(sweep_interval_secs)); + let sweep_interval_atomic = Arc::new(AtomicU64::new(sweep_interval_secs)); let persist_interval_atomic = Arc::new(AtomicU64::new(persist_interval_secs)); let mut instance = Self { @@ -357,9 +396,9 @@ impl FoyerCache { sweep_cursor, disk_bytes, sweep_threshold_ratio: sweep_threshold_atomic, - sweep_interval_secs: sweep_interval_atomic, + sweep_interval_secs: sweep_interval_atomic, persist_interval_secs: persist_interval_atomic, - cache_dir: disk_dir, // move: dir_clone was consumed by block_on, disk_dir is still owned + cache_dir: disk_dir, // move: dir_clone was consumed by block_on, disk_dir is still owned }; // Bulk-load key_index from disk. @@ -444,11 +483,11 @@ impl FoyerCache { // scan) vs persisting (cheap file write). // persist_interval_secs > 0 required to spawn; 0 means disabled (Drop-only persist). if persist_interval_secs > 0 { - let persist_token = instance.shutdown.clone(); + let persist_token = instance.shutdown.clone(); let persist_key_index = Arc::clone(&instance.key_index); - let persist_stats = Arc::clone(&instance.stats); - let persist_dir = instance.cache_dir.clone(); - let persist_interval = Duration::from_secs(persist_interval_secs); + let persist_stats = Arc::clone(&instance.stats); + let persist_dir = instance.cache_dir.clone(); + let persist_interval = Duration::from_secs(persist_interval_secs); instance._runtime.spawn(async move { native_bridge_common::log_info!( @@ -547,7 +586,9 @@ impl FoyerCache { // Compute stats before moving snapshot.index into the DashMap. let total_loaded: usize = snapshot.index.values().map(|s| s.len()).sum(); - let recovered_bytes: i64 = snapshot.index.values() + let recovered_bytes: i64 = snapshot + .index + .values() .flat_map(|keys| keys.iter()) .map(|k| key_byte_size(k)) .sum(); @@ -559,13 +600,18 @@ impl FoyerCache { } // Initialise used_bytes. No put() calls have happened yet so it is 0. - self.stats.used_bytes.fetch_add(recovered_bytes, Ordering::Relaxed); + self.stats + .used_bytes + .fetch_add(recovered_bytes, Ordering::Relaxed); let elapsed_ms = t0.elapsed().as_millis() as u64; native_bridge_common::log_info!( "[block-cache] key_index recovered: total_keys={} recovered_bytes={} \ elapsed_ms={} prefix_buckets={}", - total_loaded, recovered_bytes, elapsed_ms, self.key_index.len() + total_loaded, + recovered_bytes, + elapsed_ms, + self.key_index.len() ); } @@ -588,7 +634,7 @@ impl FoyerCache { let shard_count = shards.len(); let shard_idx = cursor.fetch_add(1, Ordering::Relaxed) % shard_count; - let mut shard = shards[shard_idx].write(); // write lock on ONE shard + let mut shard = shards[shard_idx].write(); // write lock on ONE shard let mut stale_removed = 0usize; let mut freed_bytes = 0i64; @@ -616,8 +662,12 @@ impl FoyerCache { drop(shard); if stale_removed > 0 { - stats.eviction_count.fetch_add(stale_removed as i64, Ordering::Relaxed); - stats.eviction_bytes.fetch_add(freed_bytes, Ordering::Relaxed); + stats + .eviction_count + .fetch_add(stale_removed as i64, Ordering::Relaxed); + stats + .eviction_bytes + .fetch_add(freed_bytes, Ordering::Relaxed); stats.used_bytes.fetch_add(-freed_bytes, Ordering::Relaxed); native_bridge_common::log_info!( "[block-cache] key_index_sweep: shard={} stale_removed={} freed_bytes={} key_index_size={}", @@ -626,7 +676,8 @@ impl FoyerCache { } else { native_bridge_common::log_debug!( "[block-cache] key_index_sweep: shard={} no stale entries, key_index_size={}", - shard_idx, key_index.len() + shard_idx, + key_index.len() ); } stale_removed @@ -639,7 +690,12 @@ impl FoyerCache { /// the `sweep_threshold_ratio` guard (that lives in the async task loop). #[cfg(test)] pub(crate) fn sweep_once(&self) -> usize { - Self::reconcile_key_index(&self.key_index, &self.inner, &self.stats, &self.sweep_cursor) + Self::reconcile_key_index( + &self.key_index, + &self.inner, + &self.stats, + &self.sweep_cursor, + ) } /// Returns `true` if the current usage ratio is below the configured threshold, @@ -664,9 +720,11 @@ impl FoyerCache { /// Update the sweep threshold ratio atomically. Takes effect on next tick. pub(crate) fn update_sweep_threshold(&self, new_ratio: f64) { - self.sweep_threshold_ratio.store(new_ratio.to_bits(), Ordering::Relaxed); + self.sweep_threshold_ratio + .store(new_ratio.to_bits(), Ordering::Relaxed); native_bridge_common::log_info!( - "[block-cache] sweep threshold updated live: {:.0}%", new_ratio * 100.0 + "[block-cache] sweep threshold updated live: {:.0}%", + new_ratio * 100.0 ); } @@ -678,8 +736,12 @@ impl FoyerCache { /// Update the persist interval live. `0` = disable periodic persist. Takes effect on next sleep. pub(crate) fn update_persist_interval(&self, new_secs: u64) { - self.persist_interval_secs.store(new_secs, Ordering::Relaxed); - native_bridge_common::log_info!("[block-cache] persist interval updated live: {}s", new_secs); + self.persist_interval_secs + .store(new_secs, Ordering::Relaxed); + native_bridge_common::log_info!( + "[block-cache] persist interval updated live: {}s", + new_secs + ); } /// Clear all entries synchronously. Called from the FFM layer. @@ -687,6 +749,12 @@ impl FoyerCache { self._runtime.block_on(self.clear()); } + /// Wait for the storage flusher to drain its write queue. + /// After this returns, all previously put() entries are on SSD and findable via get(). + pub async fn wait_for_flush(&self) { + self.inner.storage().wait().await; + } + /// Derive the normalized index key from a cache key. /// /// Extracts everything before the first [`SEPARATOR`] (the path prefix), @@ -694,38 +762,47 @@ impl FoyerCache { /// `object_store::Path` (no leading slash) and keys from tests or /// direct path strings (with leading slash) both map to the same bucket. fn index_key(key: &str) -> &str { - let raw = if let Some(pos) = key.find(SEPARATOR) { &key[..pos] } else { key }; + let raw = if let Some(pos) = key.find(SEPARATOR) { + &key[..pos] + } else { + key + }; raw.trim_start_matches('/') } } impl BlockCache for FoyerCache { - fn as_any(&self) -> &dyn std::any::Any { self } - fn get<'a>(&'a self, key: &'a CacheKey) - -> std::pin::Pin> + Send + 'a>> - { + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn get<'a>( + &'a self, + key: &'a CacheKey, + ) -> std::pin::Pin> + Send + 'a>> { Box::pin(async { - let range_len = key.range_len() as i64; - // ActiveBytesGuard increments active_in_bytes on construction and decrements it in Drop. - // This ensures the counter is restored even if this future is dropped mid-execution - // (e.g. the caller uses tokio::select! with a timeout that fires before the disk read - // completes). Without the guard, a cancelled future would leave active_in_bytes elevated. - let _active_guard = ActiveBytesGuard::new(&self.stats.active_in_bytes, range_len); - - match self.inner.get(&key.as_str().to_string()).await { - Ok(Some(e)) => { - let size = e.value().len() as i64; - self.stats.hit_count.fetch_add(1, Ordering::Relaxed); - self.stats.hit_bytes.fetch_add(size, Ordering::Relaxed); - Some(Bytes::copy_from_slice(e.value())) - } - _ => { - self.stats.miss_count.fetch_add(1, Ordering::Relaxed); - self.stats.miss_bytes.fetch_add(range_len, Ordering::Relaxed); - None + let range_len = key.range_len() as i64; + // ActiveBytesGuard increments active_in_bytes on construction and decrements it in Drop. + // This ensures the counter is restored even if this future is dropped mid-execution + // (e.g. the caller uses tokio::select! with a timeout that fires before the disk read + // completes). Without the guard, a cancelled future would leave active_in_bytes elevated. + let _active_guard = ActiveBytesGuard::new(&self.stats.active_in_bytes, range_len); + + match self.inner.get(&key.as_str().to_string()).await { + Ok(Some(e)) => { + let size = e.value().len() as i64; + self.stats.hit_count.fetch_add(1, Ordering::Relaxed); + self.stats.hit_bytes.fetch_add(size, Ordering::Relaxed); + Some(Bytes::copy_from_slice(e.value())) + } + _ => { + self.stats.miss_count.fetch_add(1, Ordering::Relaxed); + self.stats + .miss_bytes + .fetch_add(range_len, Ordering::Relaxed); + None + } } - } - // _active_guard dropped here — fetch_sub runs regardless of hit/miss/cancellation + // _active_guard dropped here — fetch_sub runs regardless of hit/miss/cancellation }) } @@ -751,7 +828,8 @@ impl BlockCache for FoyerCache { fn evict_prefix(&self, prefix: &str) { // Normalize prefix: object_store::Path strips leading '/' when building keys, let normalized = prefix.trim_start_matches('/'); - let matching: Vec = self.key_index + let matching: Vec = self + .key_index .iter() .filter(|e| e.key().starts_with(normalized)) .map(|e| e.key().clone()) @@ -774,9 +852,15 @@ impl BlockCache for FoyerCache { // on disk when evict_prefix() is called, so memory.remove() returns None and Event::Remove // never fires. if total_evicted > 0 { - self.stats.removed_count.fetch_add(total_evicted as i64, Ordering::Relaxed); - self.stats.removed_bytes.fetch_add(removed_bytes, Ordering::Relaxed); - self.stats.used_bytes.fetch_add(-removed_bytes, Ordering::Relaxed); + self.stats + .removed_count + .fetch_add(total_evicted as i64, Ordering::Relaxed); + self.stats + .removed_bytes + .fetch_add(removed_bytes, Ordering::Relaxed); + self.stats + .used_bytes + .fetch_add(-removed_bytes, Ordering::Relaxed); } native_bridge_common::log_info!( @@ -787,33 +871,38 @@ impl BlockCache for FoyerCache { fn clear(&self) -> std::pin::Pin + Send + '_>> { Box::pin(async move { - // Accumulate removed stats from key_index before wiping. - // Slightly inaccurate: stale entries (disk-reclaimer-evicted but not yet swept) - // are counted as removed here rather than as evictions. Acceptable — mirrors how - // FileCache.clear() uses recordRemoval() per entry. - let mut total_removed = 0i64; - let mut total_removed_bytes = 0i64; - for entry in self.key_index.iter() { - for k in entry.value() { - total_removed += 1; - total_removed_bytes += key_byte_size(k); + // Accumulate removed stats from key_index before wiping. + // Slightly inaccurate: stale entries (disk-reclaimer-evicted but not yet swept) + // are counted as removed here rather than as evictions. Acceptable — mirrors how + // FileCache.clear() uses recordRemoval() per entry. + let mut total_removed = 0i64; + let mut total_removed_bytes = 0i64; + for entry in self.key_index.iter() { + for k in entry.value() { + total_removed += 1; + total_removed_bytes += key_byte_size(k); + } } - } - self.key_index.clear(); - self.stats.used_bytes.store(0, Ordering::Relaxed); - if total_removed > 0 { - self.stats.removed_count.fetch_add(total_removed, Ordering::Relaxed); - self.stats.removed_bytes.fetch_add(total_removed_bytes, Ordering::Relaxed); - } - let _ = self.inner.clear().await; + self.key_index.clear(); + self.stats.used_bytes.store(0, Ordering::Relaxed); + if total_removed > 0 { + self.stats + .removed_count + .fetch_add(total_removed, Ordering::Relaxed); + self.stats + .removed_bytes + .fetch_add(total_removed_bytes, Ordering::Relaxed); + } + let _ = self.inner.clear().await; - // Delete the persisted key_index files so the next startup does not bulk-load - // stale keys into a freshly-cleared cache. - if let Err(e) = key_index_store::delete(&self.cache_dir) { - native_bridge_common::log_info!( - "[block-cache] key_index clear: failed to delete snapshot files: {}", e - ); - } + // Delete the persisted key_index files so the next startup does not bulk-load + // stale keys into a freshly-cleared cache. + if let Err(e) = key_index_store::delete(&self.cache_dir) { + native_bridge_common::log_info!( + "[block-cache] key_index clear: failed to delete snapshot files: {}", + e + ); + } }) } } diff --git a/sandbox/plugins/block-cache-foyer/src/main/rust/src/foyer/mod.rs b/sandbox/plugins/block-cache-foyer/src/main/rust/src/foyer/mod.rs index c4b942b8291ec..777f3a36151e9 100644 --- a/sandbox/plugins/block-cache-foyer/src/main/rust/src/foyer/mod.rs +++ b/sandbox/plugins/block-cache-foyer/src/main/rust/src/foyer/mod.rs @@ -6,5 +6,5 @@ * compatible open source license. */ -pub mod foyer_cache; pub mod ffm; +pub mod foyer_cache; diff --git a/sandbox/plugins/block-cache-foyer/src/main/rust/src/key_index_store.rs b/sandbox/plugins/block-cache-foyer/src/main/rust/src/key_index_store.rs index 264165115396c..829d1a1389473 100644 --- a/sandbox/plugins/block-cache-foyer/src/main/rust/src/key_index_store.rs +++ b/sandbox/plugins/block-cache-foyer/src/main/rust/src/key_index_store.rs @@ -66,7 +66,10 @@ impl KeyIndexSnapshot { /// Return an empty snapshot with the current version. /// Used as a fallback when the snapshot file is missing or unparseable. pub fn empty() -> Self { - Self { version: SNAPSHOT_VERSION, index: HashMap::new() } + Self { + version: SNAPSHOT_VERSION, + index: HashMap::new(), + } } /// Return `true` when there are no prefix buckets in this snapshot. @@ -97,7 +100,7 @@ pub fn save(dir: &Path, key_index: &DashMap>) -> io::Res })) .map_err(|e| io::Error::new(ErrorKind::InvalidData, e))?; - let tmp_path = dir.join(KEY_INDEX_TMP_FILENAME); + let tmp_path = dir.join(KEY_INDEX_TMP_FILENAME); let final_path = dir.join(KEY_INDEX_FILENAME); // Atomic write: write to .tmp then rename. @@ -136,7 +139,7 @@ pub fn load(dir: &Path) -> io::Result { fn load_inner(dir: &Path) -> io::Result { let final_path = dir.join(KEY_INDEX_FILENAME); - let tmp_path = dir.join(KEY_INDEX_TMP_FILENAME); + let tmp_path = dir.join(KEY_INDEX_TMP_FILENAME); // 1. Try the committed file. match std::fs::read_to_string(&final_path) { @@ -154,16 +157,17 @@ fn load_inner(dir: &Path) -> io::Result { ); parse_and_validate(&contents) } - Err(e) if e.kind() == ErrorKind::NotFound => { - Err(io::Error::new(ErrorKind::NotFound, "key_index.json not found")) - } + Err(e) if e.kind() == ErrorKind::NotFound => Err(io::Error::new( + ErrorKind::NotFound, + "key_index.json not found", + )), Err(e) => Err(e), } } fn parse_and_validate(contents: &str) -> io::Result { - let snapshot: KeyIndexSnapshot = serde_json::from_str(contents) - .map_err(|e| io::Error::new(ErrorKind::InvalidData, e))?; + let snapshot: KeyIndexSnapshot = + serde_json::from_str(contents).map_err(|e| io::Error::new(ErrorKind::InvalidData, e))?; if snapshot.version != SNAPSHOT_VERSION { return Err(io::Error::new( @@ -237,9 +241,18 @@ mod tests { fn test_save_and_load_roundtrip() { let dir = TempDir::new().unwrap(); let map = make_map(vec![ - ("data/a.parquet", vec!["data/a.parquet\x1F0-100", "data/a.parquet\x1F100-200"]), - ("data/b.parquet", vec!["data/b.parquet\x1F0-512", "data/b.parquet\x1F512-1024"]), - ("data/c.parquet", vec!["data/c.parquet\x1F0-4096", "data/c.parquet\x1F4096-8192"]), + ( + "data/a.parquet", + vec!["data/a.parquet\x1F0-100", "data/a.parquet\x1F100-200"], + ), + ( + "data/b.parquet", + vec!["data/b.parquet\x1F0-512", "data/b.parquet\x1F512-1024"], + ), + ( + "data/c.parquet", + vec!["data/c.parquet\x1F0-4096", "data/c.parquet\x1F4096-8192"], + ), ]); save(dir.path(), &map).expect("save must succeed"); @@ -261,7 +274,10 @@ mod tests { save(dir.path(), &map).unwrap(); let snapshot = load(dir.path()).unwrap(); let keys = snapshot.index.get("data/nodes/0/file.parquet").unwrap(); - assert!(keys.contains(key), "\\x1F separator must survive serde roundtrip"); + assert!( + keys.contains(key), + "\\x1F separator must survive serde roundtrip" + ); } #[test] @@ -300,7 +316,8 @@ mod tests { std::fs::write( dir.path().join(KEY_INDEX_FILENAME), br#"{"version":999,"index":{}}"#, - ).unwrap(); + ) + .unwrap(); assert_eq!(load(dir.path()).unwrap_err().kind(), ErrorKind::InvalidData); } @@ -323,7 +340,10 @@ mod tests { version: SNAPSHOT_VERSION, index: { let mut m = HashMap::new(); - m.insert("data/x.parquet".to_string(), ["data/x.parquet\x1F0-100".to_string()].into()); + m.insert( + "data/x.parquet".to_string(), + ["data/x.parquet\x1F0-100".to_string()].into(), + ); m }, }; @@ -334,7 +354,10 @@ mod tests { let loaded = load(dir.path()).expect(".tmp fallback must succeed"); assert_eq!(loaded.index.len(), 1); assert!(loaded.index.contains_key("data/x.parquet")); - assert!(!dir.path().join(KEY_INDEX_TMP_FILENAME).exists(), ".tmp must be deleted"); + assert!( + !dir.path().join(KEY_INDEX_TMP_FILENAME).exists(), + ".tmp must be deleted" + ); } #[test] @@ -342,14 +365,30 @@ mod tests { let dir = TempDir::new().unwrap(); let main_snap = KeyIndexSnapshot { version: SNAPSHOT_VERSION, - index: { let mut m = HashMap::new(); m.insert("main".to_string(), HashSet::new()); m }, + index: { + let mut m = HashMap::new(); + m.insert("main".to_string(), HashSet::new()); + m + }, }; let tmp_snap = KeyIndexSnapshot { version: SNAPSHOT_VERSION, - index: { let mut m = HashMap::new(); m.insert("tmp".to_string(), HashSet::new()); m }, + index: { + let mut m = HashMap::new(); + m.insert("tmp".to_string(), HashSet::new()); + m + }, }; - std::fs::write(dir.path().join(KEY_INDEX_FILENAME), serde_json::to_string(&main_snap).unwrap().as_bytes()).unwrap(); - std::fs::write(dir.path().join(KEY_INDEX_TMP_FILENAME), serde_json::to_string(&tmp_snap).unwrap().as_bytes()).unwrap(); + std::fs::write( + dir.path().join(KEY_INDEX_FILENAME), + serde_json::to_string(&main_snap).unwrap().as_bytes(), + ) + .unwrap(); + std::fs::write( + dir.path().join(KEY_INDEX_TMP_FILENAME), + serde_json::to_string(&tmp_snap).unwrap().as_bytes(), + ) + .unwrap(); let loaded = load(dir.path()).unwrap(); assert!(loaded.index.contains_key("main")); @@ -366,8 +405,15 @@ mod tests { #[test] fn test_load_cleans_up_tmp_even_when_main_succeeds() { let dir = TempDir::new().unwrap(); - let snap = KeyIndexSnapshot { version: SNAPSHOT_VERSION, index: HashMap::new() }; - std::fs::write(dir.path().join(KEY_INDEX_FILENAME), serde_json::to_string(&snap).unwrap().as_bytes()).unwrap(); + let snap = KeyIndexSnapshot { + version: SNAPSHOT_VERSION, + index: HashMap::new(), + }; + std::fs::write( + dir.path().join(KEY_INDEX_FILENAME), + serde_json::to_string(&snap).unwrap().as_bytes(), + ) + .unwrap(); std::fs::write(dir.path().join(KEY_INDEX_TMP_FILENAME), b"stale").unwrap(); load(dir.path()).unwrap(); diff --git a/sandbox/plugins/block-cache-foyer/src/main/rust/src/lib.rs b/sandbox/plugins/block-cache-foyer/src/main/rust/src/lib.rs index 751d8c0d06d4a..70816202529fe 100644 --- a/sandbox/plugins/block-cache-foyer/src/main/rust/src/lib.rs +++ b/sandbox/plugins/block-cache-foyer/src/main/rust/src/lib.rs @@ -6,11 +6,12 @@ * compatible open source license. */ +pub mod foyer; +pub mod key_index_store; pub mod range_cache; pub mod stats; +pub mod tiered_block_cache; pub mod traits; -pub mod key_index_store; -pub mod foyer; #[cfg(test)] mod tests; diff --git a/sandbox/plugins/block-cache-foyer/src/main/rust/src/range_cache.rs b/sandbox/plugins/block-cache-foyer/src/main/rust/src/range_cache.rs index 2642b0696edf3..c62ad3f1e5d0c 100644 --- a/sandbox/plugins/block-cache-foyer/src/main/rust/src/range_cache.rs +++ b/sandbox/plugins/block-cache-foyer/src/main/rust/src/range_cache.rs @@ -80,7 +80,7 @@ impl CacheKey { let range_part = &self.0[sep_pos + SEPARATOR.len_utf8()..]; if let Some(dash_pos) = range_part.find('-') { let start_str = &range_part[..dash_pos]; - let end_str = &range_part[dash_pos + 1..]; + let end_str = &range_part[dash_pos + 1..]; if let (Ok(start), Ok(end)) = (start_str.parse::(), end_str.parse::()) { return end.saturating_sub(start); } @@ -118,7 +118,7 @@ pub(crate) fn key_byte_size(raw_key: &str) -> i64 { let range_part = &raw_key[sep_pos + SEPARATOR.len_utf8()..]; if let Some(dash_pos) = range_part.find('-') { let start_str = &range_part[..dash_pos]; - let end_str = &range_part[dash_pos + 1..]; + let end_str = &range_part[dash_pos + 1..]; if let (Ok(start), Ok(end)) = (start_str.parse::(), end_str.parse::()) { return end.saturating_sub(start) as i64; } @@ -209,7 +209,7 @@ mod tests { #[test] fn range_keys_for_same_path_share_index_prefix() { - let k0 = range_cache_key("/data/file.parquet", 0, 4096); + let k0 = range_cache_key("/data/file.parquet", 0, 4096); let k1 = range_cache_key("/data/file.parquet", 4096, 8192); assert!(k0.as_str().starts_with("/data/file.parquet")); assert!(k1.as_str().starts_with("/data/file.parquet")); @@ -278,5 +278,4 @@ mod tests { let key = range_cache_key("/data/file.parquet", 0, u64::MAX); assert_eq!(key.range_len(), u64::MAX); } - } diff --git a/sandbox/plugins/block-cache-foyer/src/main/rust/src/tests.rs b/sandbox/plugins/block-cache-foyer/src/main/rust/src/tests.rs index 435659c2533ce..ca82aaf7aeb7a 100644 --- a/sandbox/plugins/block-cache-foyer/src/main/rust/src/tests.rs +++ b/sandbox/plugins/block-cache-foyer/src/main/rust/src/tests.rs @@ -8,12 +8,12 @@ //! Unit tests for [`FoyerCache`] and the FFM lifecycle API. -use std::sync::Arc; use bytes::Bytes; +use std::sync::Arc; use tempfile::TempDir; -use crate::foyer::foyer_cache::FoyerCache; use crate::foyer::ffm::{foyer_create_cache, foyer_destroy_cache}; +use crate::foyer::foyer_cache::FoyerCache; use crate::range_cache::range_cache_key; use crate::traits::BlockCache; @@ -21,7 +21,7 @@ use crate::traits::BlockCache; /// Block size and disk capacity for FFM/integration tests that need a large Foyer instance. /// Must be kept in sync: block_size (BLOCK_SIZE) ≤ disk_bytes (any test using this constant). -const BLOCK_SIZE: usize = 64 * 1024 * 1024; // 64 MB — used by FFM tests (disk=64MB) and large_value test +const BLOCK_SIZE: usize = 64 * 1024 * 1024; // 64 MB — used by FFM tests (disk=64MB) and large_value test /// I/O engine for all test caches. const IO_ENGINE: &str = "auto"; @@ -37,23 +37,33 @@ const IO_ENGINE: &str = "auto"; /// Tests that explicitly exercise capacity pressure (`put_and_get_work_after_cache_nears_capacity`, /// `lru_eviction_retains_keys_in_key_index`, etc.) create their own caches with /// explicit sizes and are unaffected by these constants. -const TEST_CACHE_DISK_BYTES: usize = 4 * 1024 * 1024; // 4 MB disk capacity -const TEST_CACHE_BLOCK_SIZE: usize = 1 * 1024 * 1024; // 1 MB block size (must be ≤ disk) -const TEST_BUFFER_POOL_SIZE: usize = 1 * 1024 * 1024; // 1 MB buffer pool (≥ block_size) -const TEST_SUBMIT_QUEUE_SIZE: usize = 2 * 1024 * 1024; // 2 MB submit queue (≥ 2× buffer pool) +const TEST_CACHE_DISK_BYTES: usize = 4 * 1024 * 1024; // 4 MB disk capacity +const TEST_CACHE_BLOCK_SIZE: usize = 1 * 1024 * 1024; // 1 MB block size (must be ≤ disk) +const TEST_BUFFER_POOL_SIZE: usize = 1 * 1024 * 1024; // 1 MB buffer pool (≥ block_size) +const TEST_SUBMIT_QUEUE_SIZE: usize = 2 * 1024 * 1024; // 2 MB submit queue (≥ 2× buffer pool) fn test_cache() -> (FoyerCache, TempDir) { let dir = TempDir::new().expect("failed to create temp dir"); let cache = FoyerCache::new( - TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, - TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, - IO_ENGINE, 0, 0.0, 0, + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 0, + false, ); (cache, dir) } fn put_range(cache: &FoyerCache, path: &str, start: u64, end: u64, data: &[u8]) { - cache.put(&range_cache_key(path, start, end), Bytes::copy_from_slice(data)); + cache.put( + &range_cache_key(path, start, end), + Bytes::copy_from_slice(data), + ); } /// Shared Tokio runtime for all `block_on()` calls in tests. @@ -91,12 +101,21 @@ fn get_returns_exact_bytes_that_were_put() { #[test] fn multiple_ranges_for_same_file_are_independent() { let (cache, _dir) = test_cache(); - put_range(&cache, "/data/a.parquet", 0, 4096, b"range0"); + put_range(&cache, "/data/a.parquet", 0, 4096, b"range0"); put_range(&cache, "/data/a.parquet", 4096, 8192, b"range1"); put_range(&cache, "/data/a.parquet", 8192, 12288, b"range2"); - assert_eq!(block_on(cache.get(&range_cache_key("/data/a.parquet", 0, 4096))).as_deref(), Some(b"range0".as_slice())); - assert_eq!(block_on(cache.get(&range_cache_key("/data/a.parquet", 4096, 8192))).as_deref(), Some(b"range1".as_slice())); - assert_eq!(block_on(cache.get(&range_cache_key("/data/a.parquet", 8192, 12288))).as_deref(), Some(b"range2".as_slice())); + assert_eq!( + block_on(cache.get(&range_cache_key("/data/a.parquet", 0, 4096))).as_deref(), + Some(b"range0".as_slice()) + ); + assert_eq!( + block_on(cache.get(&range_cache_key("/data/a.parquet", 4096, 8192))).as_deref(), + Some(b"range1".as_slice()) + ); + assert_eq!( + block_on(cache.get(&range_cache_key("/data/a.parquet", 8192, 12288))).as_deref(), + Some(b"range2".as_slice()) + ); } #[test] @@ -105,9 +124,18 @@ fn multiple_files_are_independent() { put_range(&cache, "/data/a.parquet", 0, 100, b"file_a"); put_range(&cache, "/data/b.parquet", 0, 100, b"file_b"); put_range(&cache, "/data/c.parquet", 0, 100, b"file_c"); - assert_eq!(block_on(cache.get(&range_cache_key("/data/a.parquet", 0, 100))).as_deref(), Some(b"file_a".as_slice())); - assert_eq!(block_on(cache.get(&range_cache_key("/data/b.parquet", 0, 100))).as_deref(), Some(b"file_b".as_slice())); - assert_eq!(block_on(cache.get(&range_cache_key("/data/c.parquet", 0, 100))).as_deref(), Some(b"file_c".as_slice())); + assert_eq!( + block_on(cache.get(&range_cache_key("/data/a.parquet", 0, 100))).as_deref(), + Some(b"file_a".as_slice()) + ); + assert_eq!( + block_on(cache.get(&range_cache_key("/data/b.parquet", 0, 100))).as_deref(), + Some(b"file_b".as_slice()) + ); + assert_eq!( + block_on(cache.get(&range_cache_key("/data/c.parquet", 0, 100))).as_deref(), + Some(b"file_c".as_slice()) + ); } #[test] @@ -143,8 +171,8 @@ fn get_returns_none_for_unknown_key() { fn get_returns_none_for_wrong_range_on_known_path() { let (cache, _dir) = test_cache(); put_range(&cache, "/data/file.parquet", 0, 100, b"data"); - assert!(block_on(cache.get(&range_cache_key("/data/file.parquet", 1, 100))).is_none()); - assert!(block_on(cache.get(&range_cache_key("/data/file.parquet", 0, 99))).is_none()); + assert!(block_on(cache.get(&range_cache_key("/data/file.parquet", 1, 100))).is_none()); + assert!(block_on(cache.get(&range_cache_key("/data/file.parquet", 0, 99))).is_none()); assert!(block_on(cache.get(&range_cache_key("/data/file.parquet", 200, 300))).is_none()); } @@ -153,7 +181,7 @@ fn get_returns_none_for_wrong_range_on_known_path() { #[test] fn evict_prefix_removes_all_ranges_for_file() { let (cache, _dir) = test_cache(); - put_range(&cache, "/data/target.parquet", 0, 4096, b"range0"); + put_range(&cache, "/data/target.parquet", 0, 4096, b"range0"); put_range(&cache, "/data/target.parquet", 4096, 8192, b"range1"); put_range(&cache, "/data/target.parquet", 8192, 12288, b"range2"); cache.evict_prefix("/data/target.parquet"); @@ -170,7 +198,7 @@ fn evict_prefix_removes_all_ranges_for_file() { fn evict_prefix_does_not_affect_other_files() { let (cache, _dir) = test_cache(); put_range(&cache, "/data/target.parquet", 0, 100, b"target"); - put_range(&cache, "/data/other.parquet", 0, 100, b"other"); + put_range(&cache, "/data/other.parquet", 0, 100, b"other"); cache.evict_prefix("/data/target.parquet"); assert!(block_on(cache.get(&range_cache_key("/data/other.parquet", 0, 100))).is_some()); assert!(block_on(cache.get(&range_cache_key("/data/target.parquet", 0, 100))).is_none()); @@ -213,28 +241,63 @@ fn clear_updates_removed_count_and_removed_bytes() { put_range(&cache, "/data/a.parquet", 0, 100, &vec![0u8; 100]); put_range(&cache, "/data/b.parquet", 0, 200, &vec![0u8; 200]); - let removed_count_before = cache.stats.removed_count.load(std::sync::atomic::Ordering::Relaxed); - let removed_bytes_before = cache.stats.removed_bytes.load(std::sync::atomic::Ordering::Relaxed); + let removed_count_before = cache + .stats + .removed_count + .load(std::sync::atomic::Ordering::Relaxed); + let removed_bytes_before = cache + .stats + .removed_bytes + .load(std::sync::atomic::Ordering::Relaxed); block_on(cache.clear()); - let removed_count_after = cache.stats.removed_count.load(std::sync::atomic::Ordering::Relaxed); - let removed_bytes_after = cache.stats.removed_bytes.load(std::sync::atomic::Ordering::Relaxed); - let used_bytes_after = cache.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed); + let removed_count_after = cache + .stats + .removed_count + .load(std::sync::atomic::Ordering::Relaxed); + let removed_bytes_after = cache + .stats + .removed_bytes + .load(std::sync::atomic::Ordering::Relaxed); + let used_bytes_after = cache + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed); - assert_eq!(removed_count_after, removed_count_before + 2, "clear() must count 2 removed entries"); - assert_eq!(removed_bytes_after, removed_bytes_before + 300, "clear() must count 100 + 200 = 300 removed bytes"); + assert_eq!( + removed_count_after, + removed_count_before + 2, + "clear() must count 2 removed entries" + ); + assert_eq!( + removed_bytes_after, + removed_bytes_before + 300, + "clear() must count 100 + 200 = 300 removed bytes" + ); assert_eq!(used_bytes_after, 0, "clear() must reset used_bytes to 0"); - assert!(cache.key_index.is_empty(), "key_index must be empty after clear()"); + assert!( + cache.key_index.is_empty(), + "key_index must be empty after clear()" + ); } #[test] fn clear_on_empty_cache_does_not_change_removed_stats() { let (cache, _dir) = test_cache(); - let removed_before = cache.stats.removed_count.load(std::sync::atomic::Ordering::Relaxed); + let removed_before = cache + .stats + .removed_count + .load(std::sync::atomic::Ordering::Relaxed); block_on(cache.clear()); - assert_eq!(cache.stats.removed_count.load(std::sync::atomic::Ordering::Relaxed), removed_before, - "clear() on empty cache must not increment removed_count"); + assert_eq!( + cache + .stats + .removed_count + .load(std::sync::atomic::Ordering::Relaxed), + removed_before, + "clear() on empty cache must not increment removed_count" + ); } #[test] @@ -278,7 +341,7 @@ fn key_index_is_empty_after_clear() { fn key_index_has_no_entry_for_evicted_file() { let (cache, _dir) = test_cache(); put_range(&cache, "/data/target.parquet", 0, 100, b"data"); - put_range(&cache, "/data/other.parquet", 0, 100, b"other"); + put_range(&cache, "/data/other.parquet", 0, 100, b"other"); cache.evict_prefix("/data/target.parquet"); // key_index stores normalized keys (no leading '/'): assert!(!cache.key_index.contains_key("data/target.parquet")); @@ -300,21 +363,28 @@ fn put_same_key_twice_does_not_duplicate_key_index_entry() { // First put: key must be added to key_index. cache.put(&key, Bytes::from(vec![0u8; 1024])); - let count_after_first = cache.key_index + let count_after_first = cache + .key_index .get("data/file.parquet") .map(|v| v.len()) .unwrap_or(0); - assert_eq!(count_after_first, 1, "first put must add exactly 1 entry to key_index"); + assert_eq!( + count_after_first, 1, + "first put must add exactly 1 entry to key_index" + ); // Second put with same key (simulates cache stampede or re-put after eviction). cache.put(&key, Bytes::from(vec![0xFFu8; 1024])); - let count_after_second = cache.key_index + let count_after_second = cache + .key_index .get("data/file.parquet") .map(|v| v.len()) .unwrap_or(0); - assert_eq!(count_after_second, 1, + assert_eq!( + count_after_second, 1, "second put of same key must NOT add a duplicate entry (HashSet dedup); got {}", - count_after_second); + count_after_second + ); } /// used_bytes must not be double-counted when the same key is put twice. @@ -325,12 +395,22 @@ fn put_same_key_twice_does_not_duplicate_key_index_entry() { #[test] fn put_same_key_twice_does_not_double_count_used_bytes() { let (cache, _dir) = test_cache(); - assert_eq!(cache.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed), 0); + assert_eq!( + cache + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed), + 0 + ); let key = range_cache_key("/data/file.parquet", 0, 512); cache.put(&key, Bytes::from(vec![0u8; 512])); assert_eq!( - cache.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed), 512, + cache + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed), + 512, "used_bytes must be 512 after first put" ); @@ -338,7 +418,11 @@ fn put_same_key_twice_does_not_double_count_used_bytes() { // used_bytes must NOT be incremented again. cache.put(&key, Bytes::from(vec![0xAAu8; 512])); assert_eq!( - cache.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed), 512, + cache + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed), + 512, "used_bytes must still be 512 after second put of the same key (no double-count)" ); } @@ -354,30 +438,42 @@ fn concurrent_stampede_same_key_does_not_duplicate_key_index() { const RANGE_SIZE: u64 = 1024; // All threads race to put the same key — simulates a cache stampede after a miss. - let handles: Vec<_> = (0..THREAD_COUNT).map(|_| { - let cache = Arc::clone(&cache); - std::thread::spawn(move || { - let key = range_cache_key("/data/shared.parquet", 0, RANGE_SIZE); - cache.put(&key, Bytes::from(vec![0xABu8; RANGE_SIZE as usize])); + let handles: Vec<_> = (0..THREAD_COUNT) + .map(|_| { + let cache = Arc::clone(&cache); + std::thread::spawn(move || { + let key = range_cache_key("/data/shared.parquet", 0, RANGE_SIZE); + cache.put(&key, Bytes::from(vec![0xABu8; RANGE_SIZE as usize])); + }) }) - }).collect(); - for h in handles { h.join().expect("thread panicked"); } + .collect(); + for h in handles { + h.join().expect("thread panicked"); + } // key_index must have exactly 1 entry for this key, not THREAD_COUNT. - let count = cache.key_index + let count = cache + .key_index .get("data/shared.parquet") .map(|v| v.len()) .unwrap_or(0); - assert_eq!(count, 1, + assert_eq!( + count, 1, "key_index must contain exactly 1 entry after {} concurrent puts of the same key; got {}", - THREAD_COUNT, count); + THREAD_COUNT, count + ); // used_bytes: at most 1× the entry size. It may be less if the second-N puts // raced and all returned false from HashSet::insert, but never more than 1×. - let used_bytes = cache.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed); - assert_eq!(used_bytes, RANGE_SIZE as i64, + let used_bytes = cache + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed); + assert_eq!( + used_bytes, RANGE_SIZE as i64, "used_bytes must be exactly {} after {} concurrent puts of the same key; got {}", - RANGE_SIZE, THREAD_COUNT, used_bytes); + RANGE_SIZE, THREAD_COUNT, used_bytes + ); } // ── concurrent access ───────────────────────────────────────────────────────── @@ -386,14 +482,18 @@ fn concurrent_stampede_same_key_does_not_duplicate_key_index() { fn concurrent_puts_to_different_files_do_not_corrupt() { let (cache, _dir) = test_cache(); let cache = Arc::new(cache); - let handles: Vec<_> = (0..16).map(|i| { - let cache = Arc::clone(&cache); - std::thread::spawn(move || { - let key = range_cache_key(&format!("/data/file_{}.parquet", i), 0, 1024); - cache.put(&key, Bytes::copy_from_slice(&vec![i as u8; 1024])); + let handles: Vec<_> = (0..16) + .map(|i| { + let cache = Arc::clone(&cache); + std::thread::spawn(move || { + let key = range_cache_key(&format!("/data/file_{}.parquet", i), 0, 1024); + cache.put(&key, Bytes::copy_from_slice(&vec![i as u8; 1024])); + }) }) - }).collect(); - for h in handles { h.join().expect("thread panicked"); } + .collect(); + for h in handles { + h.join().expect("thread panicked"); + } for i in 0u8..16 { let key = range_cache_key(&format!("/data/file_{}.parquet", i), 0, 1024); let result = block_on(cache.get(&key)).expect("entry should be retrievable"); @@ -436,7 +536,9 @@ fn concurrent_evict_and_put_does_not_panic() { }); let evictor_cache = Arc::clone(&cache); let evictor = std::thread::spawn(move || { - for _ in 0..50 { evictor_cache.evict_prefix("/data/file.parquet"); } + for _ in 0..50 { + evictor_cache.evict_prefix("/data/file.parquet"); + } }); writer.join().expect("writer panicked"); evictor.join().expect("evictor panicked"); @@ -487,8 +589,15 @@ fn concurrent_put_and_evict_same_prefix_does_not_corrupt() { evictor.join().expect("evictor panicked"); // used_bytes must never go negative — that would indicate double-subtraction of stats. - let used = cache.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed); - assert!(used >= 0, "used_bytes must not be negative after concurrent put/evict; got {}", used); + let used = cache + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed); + assert!( + used >= 0, + "used_bytes must not be negative after concurrent put/evict; got {}", + used + ); // Cache must remain fully usable after the concurrent activity. let key = range_cache_key("/data/shard.parquet", 999_000, 999_100); @@ -503,7 +612,18 @@ fn put_and_get_work_after_cache_nears_capacity() { let dir = TempDir::new().unwrap(); // disk=2MB ≥ block_size=512KB (Foyer invariant: block_size ≤ disk). // Writes 4 × 512KB = 2MB total into a 2MB cache to exercise near-capacity behaviour. - let cache = FoyerCache::new(2 * 1024 * 1024, dir.path(), 512 * 1024, 512 * 1024, 1024 * 1024, IO_ENGINE, 0, 0.0, 0); + let cache = FoyerCache::new( + 2 * 1024 * 1024, + dir.path(), + 512 * 1024, + 512 * 1024, + 1024 * 1024, + IO_ENGINE, + 0, + 0.0, + 0, + false, + ); let chunk = vec![0u8; 512 * 1024]; for i in 0u64..4 { let key = range_cache_key("/data/file.parquet", i * 524288, (i + 1) * 524288); @@ -527,12 +647,27 @@ fn lru_eviction_retains_keys_in_key_index() { // disk=1MB, block_size=256KB (256KB ≤ 1MB — Foyer invariant satisfied). // Writes 8 × 256KB = 2MB total into a 1MB cache to trigger LRU eviction pressure. let dir = TempDir::new().unwrap(); - let cache = FoyerCache::new(1 * 1024 * 1024, dir.path(), 256 * 1024, 256 * 1024, 512 * 1024, IO_ENGINE, 0, 0.0, 0); + let cache = FoyerCache::new( + 1 * 1024 * 1024, + dir.path(), + 256 * 1024, + 256 * 1024, + 512 * 1024, + IO_ENGINE, + 0, + 0.0, + 0, + false, + ); const CHUNK_SIZE: usize = 256 * 1024; const TOTAL_WRITES: usize = 8; let chunk = vec![0xABu8; CHUNK_SIZE]; for i in 0u64..TOTAL_WRITES as u64 { - let key = range_cache_key("/data/big.parquet", i * CHUNK_SIZE as u64, (i + 1) * CHUNK_SIZE as u64); + let key = range_cache_key( + "/data/big.parquet", + i * CHUNK_SIZE as u64, + (i + 1) * CHUNK_SIZE as u64, + ); cache.put(&key, Bytes::copy_from_slice(&chunk)); } // Busy-assert: poll until all TOTAL_WRITES keys appear in key_index or deadline expires. @@ -540,7 +675,11 @@ fn lru_eviction_retains_keys_in_key_index() { // rather than using a fixed sleep — avoids flakiness on slow CI machines. let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); let key_count = loop { - let count = cache.key_index.get("data/big.parquet").map(|v| v.len()).unwrap_or(0); + let count = cache + .key_index + .get("data/big.parquet") + .map(|v| v.len()) + .unwrap_or(0); if count >= TOTAL_WRITES || std::time::Instant::now() >= deadline { break count; } @@ -564,29 +703,55 @@ fn replace_event_key_index_retains_entry() { let (cache, _dir) = test_cache(); let key = range_cache_key("/data/file.parquet", 0, 100); cache.put(&key, Bytes::from_static(b"version_1")); - let used_after_v1 = cache.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed); + let used_after_v1 = cache + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed); cache.put(&key, Bytes::from_static(b"version_2")); std::thread::sleep(std::time::Duration::from_millis(100)); // With HashSet key_index, putting the same key twice must result in exactly 1 entry // (not 2), because HashSet::insert is idempotent for duplicate keys. - let count = cache.key_index.get("data/file.parquet").map(|v| v.len()).unwrap_or(0); - assert_eq!(count, 1, "key must appear exactly once in key_index after overwrite (HashSet dedup); got {}", count); + let count = cache + .key_index + .get("data/file.parquet") + .map(|v| v.len()) + .unwrap_or(0); + assert_eq!( + count, 1, + "key must appear exactly once in key_index after overwrite (HashSet dedup); got {}", + count + ); // used_bytes grows monotonically: v2 was added on top of v1 (no reliable subtraction). - let used_after_v2 = cache.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed); - assert!(used_after_v2 >= used_after_v1, "used_bytes must not decrease on overwrite"); + let used_after_v2 = cache + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed); + assert!( + used_after_v2 >= used_after_v1, + "used_bytes must not decrease on overwrite" + ); // Latest value must be readable. let result = block_on(cache.get(&key)); assert_eq!(result.as_deref(), Some(b"version_2".as_slice())); // evict_prefix must still clean up correctly and increment removed_count. - let removed_before = cache.stats.removed_count.load(std::sync::atomic::Ordering::Relaxed); + let removed_before = cache + .stats + .removed_count + .load(std::sync::atomic::Ordering::Relaxed); cache.evict_prefix("/data/file.parquet"); assert!(!cache.key_index.contains_key("data/file.parquet")); - let removed_after = cache.stats.removed_count.load(std::sync::atomic::Ordering::Relaxed); - assert!(removed_after > removed_before, "removed_count must increase after evict_prefix"); + let removed_after = cache + .stats + .removed_count + .load(std::sync::atomic::Ordering::Relaxed); + assert!( + removed_after > removed_before, + "removed_count must increase after evict_prefix" + ); } // ── Background sweeper tests ────────────────────────────────────────────────── @@ -599,7 +764,10 @@ fn sweep_once_returns_zero_when_all_entries_still_on_disk() { put_range(&cache, "/data/file.parquet", 0, 100, b"data"); put_range(&cache, "/data/file.parquet", 100, 200, b"more"); let removed = cache.sweep_once(); - assert_eq!(removed, 0, "no stale entries expected immediately after put"); + assert_eq!( + removed, 0, + "no stale entries expected immediately after put" + ); assert!(cache.key_index.contains_key("data/file.parquet")); } @@ -618,41 +786,70 @@ fn sweep_once_removes_stale_keys_and_updates_eviction_count() { // Inject a fake key that Foyer has never seen — inner.contains() will return false. // Range: 99999-100000 → 1 byte, parseable by key_byte_size(). let fake_key = "data/file.parquet\x1F99999-100000".to_string(); - cache.key_index + cache + .key_index .entry("data/file.parquet".to_string()) .or_default() .insert(fake_key.clone()); - let eviction_count_before = cache.stats.eviction_count.load(std::sync::atomic::Ordering::Relaxed); - let eviction_bytes_before = cache.stats.eviction_bytes.load(std::sync::atomic::Ordering::Relaxed); - let used_bytes_before = cache.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed); + let eviction_count_before = cache + .stats + .eviction_count + .load(std::sync::atomic::Ordering::Relaxed); + let eviction_bytes_before = cache + .stats + .eviction_bytes + .load(std::sync::atomic::Ordering::Relaxed); + let used_bytes_before = cache + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed); // Loop shard_count times to guarantee the shard containing the fake key is visited. // DashMap hashes keys to one of N shards (N=64 on macOS); sweep_once() processes ONE shard // per call, so we need N calls to cover all shards exactly once. let shard_count = cache.key_index.shards().len(); let removed: usize = (0..shard_count).map(|_| cache.sweep_once()).sum(); - assert_eq!(removed, 1, "sweeper should have removed the 1 fake/stale key"); assert_eq!( - cache.stats.eviction_count.load(std::sync::atomic::Ordering::Relaxed), + removed, 1, + "sweeper should have removed the 1 fake/stale key" + ); + assert_eq!( + cache + .stats + .eviction_count + .load(std::sync::atomic::Ordering::Relaxed), eviction_count_before + 1, "eviction_count must be incremented for stale entries removed by sweeper" ); // Fake key range 99999-100000 = 1 byte. assert_eq!( - cache.stats.eviction_bytes.load(std::sync::atomic::Ordering::Relaxed), + cache + .stats + .eviction_bytes + .load(std::sync::atomic::Ordering::Relaxed), eviction_bytes_before + 1, "eviction_bytes must reflect the byte size of stale entries (parsed from key)" ); assert_eq!( - cache.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed), + cache + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed), used_bytes_before - 1, "used_bytes must be decremented by freed bytes when sweeper removes stale entries" ); // Real key is still in key_index. - let remaining = cache.key_index.get("data/file.parquet").map(|v| v.len()).unwrap_or(0); - assert_eq!(remaining, 1, "real key must remain in key_index after sweep"); + let remaining = cache + .key_index + .get("data/file.parquet") + .map(|v| v.len()) + .unwrap_or(0); + assert_eq!( + remaining, 1, + "real key must remain in key_index after sweep" + ); } #[test] @@ -663,7 +860,8 @@ fn sweep_once_removes_empty_prefix_buckets() { // Inject only fake keys for a prefix — no real entries. let fake1 = "data/gone.parquet\x1F0-100".to_string(); let fake2 = "data/gone.parquet\x1F100-200".to_string(); - cache.key_index + cache + .key_index .entry("data/gone.parquet".to_string()) .or_default() .extend(vec![fake1, fake2]); @@ -695,23 +893,53 @@ fn evict_prefix_updates_removed_bytes_and_used_bytes() { let (cache, _dir) = test_cache(); // Two ranges: 0-100 = 100 bytes, 100-300 = 200 bytes → total 300 bytes. - put_range(&cache, "/data/file.parquet", 0, 100, &vec![0u8; 100]); + put_range(&cache, "/data/file.parquet", 0, 100, &vec![0u8; 100]); put_range(&cache, "/data/file.parquet", 100, 300, &vec![0u8; 200]); std::thread::sleep(std::time::Duration::from_millis(100)); - let used_before = cache.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed); - let removed_count_before = cache.stats.removed_count.load(std::sync::atomic::Ordering::Relaxed); - let removed_bytes_before = cache.stats.removed_bytes.load(std::sync::atomic::Ordering::Relaxed); + let used_before = cache + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed); + let removed_count_before = cache + .stats + .removed_count + .load(std::sync::atomic::Ordering::Relaxed); + let removed_bytes_before = cache + .stats + .removed_bytes + .load(std::sync::atomic::Ordering::Relaxed); cache.evict_prefix("/data/file.parquet"); - let used_after = cache.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed); - let removed_count = cache.stats.removed_count.load(std::sync::atomic::Ordering::Relaxed); - let removed_bytes = cache.stats.removed_bytes.load(std::sync::atomic::Ordering::Relaxed); + let used_after = cache + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed); + let removed_count = cache + .stats + .removed_count + .load(std::sync::atomic::Ordering::Relaxed); + let removed_bytes = cache + .stats + .removed_bytes + .load(std::sync::atomic::Ordering::Relaxed); - assert_eq!(removed_count, removed_count_before + 2, "removed_count: 2 entries evicted"); - assert_eq!(removed_bytes, removed_bytes_before + 300, "removed_bytes: 100 + 200 = 300"); - assert_eq!(used_after, used_before - 300, "used_bytes must decrease by 300 after eviction"); + assert_eq!( + removed_count, + removed_count_before + 2, + "removed_count: 2 entries evicted" + ); + assert_eq!( + removed_bytes, + removed_bytes_before + 300, + "removed_bytes: 100 + 200 = 300" + ); + assert_eq!( + used_after, + used_before - 300, + "used_bytes must decrease by 300 after eviction" + ); } #[test] @@ -719,27 +947,63 @@ fn evict_prefix_on_nonexistent_prefix_does_not_change_stats() { let (cache, _dir) = test_cache(); put_range(&cache, "/data/file.parquet", 0, 100, &vec![0u8; 100]); - let removed_before = cache.stats.removed_count.load(std::sync::atomic::Ordering::Relaxed); - let used_before = cache.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed); + let removed_before = cache + .stats + .removed_count + .load(std::sync::atomic::Ordering::Relaxed); + let used_before = cache + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed); cache.evict_prefix("/data/other.parquet"); - assert_eq!(cache.stats.removed_count.load(std::sync::atomic::Ordering::Relaxed), removed_before); - assert_eq!(cache.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed), used_before); + assert_eq!( + cache + .stats + .removed_count + .load(std::sync::atomic::Ordering::Relaxed), + removed_before + ); + assert_eq!( + cache + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed), + used_before + ); } #[test] fn used_bytes_is_correct_after_put_then_evict() { // used_bytes starts at 0, put adds size, evict_prefix subtracts — net result = 0. let (cache, _dir) = test_cache(); - assert_eq!(cache.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed), 0); + assert_eq!( + cache + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed), + 0 + ); put_range(&cache, "/data/file.parquet", 0, 1024, &vec![0u8; 1024]); - assert_eq!(cache.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed), 1024); + assert_eq!( + cache + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed), + 1024 + ); cache.evict_prefix("/data/file.parquet"); - assert_eq!(cache.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed), 0, - "used_bytes must return to 0 after evicting all entries"); + assert_eq!( + cache + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed), + 0, + "used_bytes must return to 0 after evicting all entries" + ); } // ── sweep idempotency and edge cases ───────────────────────────────────────── @@ -751,14 +1015,23 @@ fn sweep_once_is_idempotent_for_real_entries() { put_range(&cache, "/data/file.parquet", 0, 1024, &vec![0u8; 1024]); let removed1 = cache.sweep_once(); - let eviction_bytes_after_1 = cache.stats.eviction_bytes.load(std::sync::atomic::Ordering::Relaxed); + let eviction_bytes_after_1 = cache + .stats + .eviction_bytes + .load(std::sync::atomic::Ordering::Relaxed); let removed2 = cache.sweep_once(); - let eviction_bytes_after_2 = cache.stats.eviction_bytes.load(std::sync::atomic::Ordering::Relaxed); + let eviction_bytes_after_2 = cache + .stats + .eviction_bytes + .load(std::sync::atomic::Ordering::Relaxed); assert_eq!(removed1, 0, "no stale entries on first sweep"); assert_eq!(removed2, 0, "no stale entries on second sweep"); assert_eq!(eviction_bytes_after_1, 0); - assert_eq!(eviction_bytes_after_2, 0, "eviction_bytes must not change across two sweeps of live entries"); + assert_eq!( + eviction_bytes_after_2, 0, + "eviction_bytes must not change across two sweeps of live entries" + ); } #[test] @@ -768,24 +1041,33 @@ fn sweep_once_stale_key_with_malformed_range_does_not_panic() { let (cache, _dir) = test_cache(); // Inject a malformed stale key — no separator, Foyer has never seen it. - cache.key_index + cache + .key_index .entry("data/bad.parquet".to_string()) .or_default() .insert("data/bad.parquet-not-a-range-key".to_string()); - let eviction_bytes_before = cache.stats.eviction_bytes.load(std::sync::atomic::Ordering::Relaxed); + let eviction_bytes_before = cache + .stats + .eviction_bytes + .load(std::sync::atomic::Ordering::Relaxed); let shard_count = cache.key_index.shards().len(); let removed: usize = (0..shard_count).map(|_| cache.sweep_once()).sum(); assert_eq!(removed, 1, "stale malformed key must be removed"); // key_byte_size returns 0 for malformed key — eviction_bytes unchanged. assert_eq!( - cache.stats.eviction_bytes.load(std::sync::atomic::Ordering::Relaxed), + cache + .stats + .eviction_bytes + .load(std::sync::atomic::Ordering::Relaxed), eviction_bytes_before, "eviction_bytes must not change for malformed (size=0) key" ); - assert!(!cache.key_index.contains_key("data/bad.parquet"), - "empty prefix bucket must be removed"); + assert!( + !cache.key_index.contains_key("data/bad.parquet"), + "empty prefix bucket must be removed" + ); } #[test] @@ -796,13 +1078,20 @@ fn sweep_once_removes_empty_prefix_buckets_and_updates_eviction_bytes() { let fake1 = "data/gone.parquet\x1F0-100".to_string(); let fake2 = "data/gone.parquet\x1F100-200".to_string(); - cache.key_index + cache + .key_index .entry("data/gone.parquet".to_string()) .or_default() .extend(vec![fake1, fake2]); - let eviction_bytes_before = cache.stats.eviction_bytes.load(std::sync::atomic::Ordering::Relaxed); - let used_bytes_before = cache.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed); + let eviction_bytes_before = cache + .stats + .eviction_bytes + .load(std::sync::atomic::Ordering::Relaxed); + let used_bytes_before = cache + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed); let shard_count = cache.key_index.shards().len(); let removed: usize = (0..shard_count).map(|_| cache.sweep_once()).sum(); @@ -810,12 +1099,18 @@ fn sweep_once_removes_empty_prefix_buckets_and_updates_eviction_bytes() { assert_eq!(removed, 2); assert!(!cache.key_index.contains_key("data/gone.parquet")); assert_eq!( - cache.stats.eviction_bytes.load(std::sync::atomic::Ordering::Relaxed), + cache + .stats + .eviction_bytes + .load(std::sync::atomic::Ordering::Relaxed), eviction_bytes_before + 200, "eviction_bytes must reflect both stale keys (100 + 100 = 200)" ); assert_eq!( - cache.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed), + cache + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed), used_bytes_before - 200, "used_bytes must decrease by 200" ); @@ -824,13 +1119,16 @@ fn sweep_once_removes_empty_prefix_buckets_and_updates_eviction_bytes() { #[test] fn event_remove_after_evict_prefix_does_not_panic_or_corrupt_key_index() { let (cache, _dir) = test_cache(); - put_range(&cache, "/data/file.parquet", 0, 100, b"data"); + put_range(&cache, "/data/file.parquet", 0, 100, b"data"); put_range(&cache, "/data/file.parquet", 100, 200, b"more"); cache.evict_prefix("/data/file.parquet"); std::thread::sleep(std::time::Duration::from_millis(100)); assert!(!cache.key_index.contains_key("data/file.parquet")); put_range(&cache, "/data/file.parquet", 0, 100, b"fresh"); - assert_eq!(block_on(cache.get(&range_cache_key("/data/file.parquet", 0, 100))).as_deref(), Some(b"fresh".as_slice())); + assert_eq!( + block_on(cache.get(&range_cache_key("/data/file.parquet", 0, 100))).as_deref(), + Some(b"fresh".as_slice()) + ); } // ── FFM lifecycle ───────────────────────────────────────────────────────────── @@ -840,14 +1138,21 @@ fn ffm_create_returns_positive_pointer() { let dir = TempDir::new().unwrap(); let dir_str = dir.path().to_str().unwrap(); let engine = IO_ENGINE.as_bytes(); - let ptr = unsafe { foyer_create_cache( - 128 * 1024 * 1024, - dir_str.as_ptr(), dir_str.len() as u64, - BLOCK_SIZE as u64, BLOCK_SIZE as u64, (BLOCK_SIZE * 2) as u64, - engine.as_ptr(), engine.len() as u64, - 0, 0.0_f64, - 0, - )}; + let ptr = unsafe { + foyer_create_cache( + 128 * 1024 * 1024, + dir_str.as_ptr(), + dir_str.len() as u64, + BLOCK_SIZE as u64, + BLOCK_SIZE as u64, + (BLOCK_SIZE * 2) as u64, + engine.as_ptr(), + engine.len() as u64, + 0, + 0.0_f64, + 0, + ) + }; assert!(ptr > 0); let result = unsafe { foyer_destroy_cache(ptr) }; assert_eq!(result, 0); @@ -856,46 +1161,76 @@ fn ffm_create_returns_positive_pointer() { #[test] fn ffm_create_with_null_ptr_returns_error() { let engine = IO_ENGINE.as_bytes(); - let ptr = unsafe { foyer_create_cache( - 128 * 1024 * 1024, - std::ptr::null(), 10, - BLOCK_SIZE as u64, BLOCK_SIZE as u64, (BLOCK_SIZE * 2) as u64, - engine.as_ptr(), engine.len() as u64, - 0, 0.0_f64, - 0, - )}; + let ptr = unsafe { + foyer_create_cache( + 128 * 1024 * 1024, + std::ptr::null(), + 10, + BLOCK_SIZE as u64, + BLOCK_SIZE as u64, + (BLOCK_SIZE * 2) as u64, + engine.as_ptr(), + engine.len() as u64, + 0, + 0.0_f64, + 0, + ) + }; assert!(ptr < 0); - if ptr < 0 { unsafe { native_bridge_common::error::native_error_free(-ptr); } } + if ptr < 0 { + unsafe { + native_bridge_common::error::native_error_free(-ptr); + } + } } #[test] fn ffm_create_with_invalid_utf8_returns_error() { let invalid_utf8 = [0xFF, 0xFE, 0xFD]; let engine = IO_ENGINE.as_bytes(); - let ptr = unsafe { foyer_create_cache( - 128 * 1024 * 1024, - invalid_utf8.as_ptr(), invalid_utf8.len() as u64, - BLOCK_SIZE as u64, BLOCK_SIZE as u64, (BLOCK_SIZE * 2) as u64, - engine.as_ptr(), engine.len() as u64, - 0, 0.0_f64, - 0, - )}; + let ptr = unsafe { + foyer_create_cache( + 128 * 1024 * 1024, + invalid_utf8.as_ptr(), + invalid_utf8.len() as u64, + BLOCK_SIZE as u64, + BLOCK_SIZE as u64, + (BLOCK_SIZE * 2) as u64, + engine.as_ptr(), + engine.len() as u64, + 0, + 0.0_f64, + 0, + ) + }; assert!(ptr < 0); - if ptr < 0 { unsafe { native_bridge_common::error::native_error_free(-ptr); } } + if ptr < 0 { + unsafe { + native_bridge_common::error::native_error_free(-ptr); + } + } } #[test] fn ffm_destroy_with_zero_ptr_returns_error() { let result = unsafe { foyer_destroy_cache(0) }; assert!(result < 0); - if result < 0 { unsafe { native_bridge_common::error::native_error_free(-result); } } + if result < 0 { + unsafe { + native_bridge_common::error::native_error_free(-result); + } + } } #[test] fn ffm_destroy_with_negative_ptr_returns_error() { let result = unsafe { foyer_destroy_cache(-1) }; assert!(result < 0); - if result < 0 { unsafe { native_bridge_common::error::native_error_free(-result); } } + if result < 0 { + unsafe { + native_bridge_common::error::native_error_free(-result); + } + } } #[test] @@ -904,21 +1239,27 @@ fn ffm_create_destroy_lifecycle_no_leak() { for _ in 0..3 { let dir = TempDir::new().unwrap(); let dir_str = dir.path().to_str().unwrap(); - let ptr = unsafe { foyer_create_cache( - 128 * 1024 * 1024, - dir_str.as_ptr(), dir_str.len() as u64, - BLOCK_SIZE as u64, BLOCK_SIZE as u64, (BLOCK_SIZE * 2) as u64, - engine.as_ptr(), engine.len() as u64, - 0, 0.0_f64, - 0, - )}; + let ptr = unsafe { + foyer_create_cache( + 128 * 1024 * 1024, + dir_str.as_ptr(), + dir_str.len() as u64, + BLOCK_SIZE as u64, + BLOCK_SIZE as u64, + (BLOCK_SIZE * 2) as u64, + engine.as_ptr(), + engine.len() as u64, + 0, + 0.0_f64, + 0, + ) + }; assert!(ptr > 0); let result = unsafe { foyer_destroy_cache(ptr) }; assert_eq!(result, 0); } } - // ── foyer_snapshot_stats tests ─────────────────────────────────────────────── use crate::foyer::ffm::foyer_snapshot_stats; @@ -931,14 +1272,21 @@ fn ffm_snapshot_stats_valid_ptr_returns_zero_and_fills_buffer() { let dir = TempDir::new().unwrap(); let dir_str = dir.path().to_str().unwrap(); let engine = IO_ENGINE.as_bytes(); - let ptr = unsafe { foyer_create_cache( - 128 * 1024 * 1024, - dir_str.as_ptr(), dir_str.len() as u64, - BLOCK_SIZE as u64, BLOCK_SIZE as u64, (BLOCK_SIZE * 2) as u64, - engine.as_ptr(), engine.len() as u64, - 0, 0.0_f64, - 0, - )}; + let ptr = unsafe { + foyer_create_cache( + 128 * 1024 * 1024, + dir_str.as_ptr(), + dir_str.len() as u64, + BLOCK_SIZE as u64, + BLOCK_SIZE as u64, + (BLOCK_SIZE * 2) as u64, + engine.as_ptr(), + engine.len() as u64, + 0, + 0.0_f64, + 0, + ) + }; assert!(ptr > 0); // 10 fields × 2 sections = 20 longs @@ -949,7 +1297,11 @@ fn ffm_snapshot_stats_valid_ptr_returns_zero_and_fills_buffer() { // A freshly created cache has no hits, misses, evictions, used bytes, or active reads. // Sections 0 and 1 are identical (Foyer is currently single-tier). for i in 0..20 { - assert_eq!(out[i], 0, "out[{i}] should be 0 for a fresh cache, got {}", out[i]); + assert_eq!( + out[i], 0, + "out[{i}] should be 0 for a fresh cache, got {}", + out[i] + ); } let destroy_rc = unsafe { foyer_destroy_cache(ptr) }; @@ -964,12 +1316,18 @@ fn snapshot_stats_returns_zero_for_fresh_cache() { let ptr = unsafe { foyer_create_cache( 128 * 1024 * 1024, - dir_str.as_ptr(), dir_str.len() as u64, - BLOCK_SIZE as u64, BLOCK_SIZE as u64, (BLOCK_SIZE * 2) as u64, - engine.as_ptr(), engine.len() as u64, - 0, 0.0_f64, - 0, - )}; + dir_str.as_ptr(), + dir_str.len() as u64, + BLOCK_SIZE as u64, + BLOCK_SIZE as u64, + (BLOCK_SIZE * 2) as u64, + engine.as_ptr(), + engine.len() as u64, + 0, + 0.0_f64, + 0, + ) + }; assert!(ptr > 0); // 10 fields × 2 sections = 20 longs @@ -983,7 +1341,11 @@ fn snapshot_stats_returns_zero_for_fresh_cache() { } // Both sections are identical (single-tier: overall mirrors block_level). - assert_eq!(&buf[..10], &buf[10..], "overall and block_level sections must be identical"); + assert_eq!( + &buf[..10], + &buf[10..], + "overall and block_level sections must be identical" + ); unsafe { foyer_destroy_cache(ptr) }; } @@ -1002,18 +1364,28 @@ fn ffm_snapshot_stats_null_out_returns_error() { let dir = TempDir::new().unwrap(); let dir_str = dir.path().to_str().unwrap(); let engine = IO_ENGINE.as_bytes(); - let ptr = unsafe { foyer_create_cache( - 128 * 1024 * 1024, - dir_str.as_ptr(), dir_str.len() as u64, - BLOCK_SIZE as u64, BLOCK_SIZE as u64, (BLOCK_SIZE * 2) as u64, - engine.as_ptr(), engine.len() as u64, - 0, 0.0_f64, - 0, - )}; + let ptr = unsafe { + foyer_create_cache( + 128 * 1024 * 1024, + dir_str.as_ptr(), + dir_str.len() as u64, + BLOCK_SIZE as u64, + BLOCK_SIZE as u64, + (BLOCK_SIZE * 2) as u64, + engine.as_ptr(), + engine.len() as u64, + 0, + 0.0_f64, + 0, + ) + }; assert!(ptr > 0); let rc = unsafe { crate::foyer::ffm::foyer_snapshot_stats(ptr, std::ptr::null_mut()) }; - assert!(rc < 0, "foyer_snapshot_stats with null out should return < 0, got {rc}"); + assert!( + rc < 0, + "foyer_snapshot_stats with null out should return < 0, got {rc}" + ); let destroy_rc = unsafe { foyer_destroy_cache(ptr) }; assert_eq!(destroy_rc, 0); @@ -1023,7 +1395,10 @@ fn ffm_snapshot_stats_null_out_returns_error() { fn snapshot_stats_returns_error_for_invalid_ptr() { let mut out = [0i64; 14]; let rc = unsafe { crate::foyer::ffm::foyer_snapshot_stats(0, out.as_mut_ptr()) }; - assert!(rc < 0, "foyer_snapshot_stats with ptr=0 should return < 0, got {rc}"); + assert!( + rc < 0, + "foyer_snapshot_stats with ptr=0 should return < 0, got {rc}" + ); } #[test] @@ -1049,8 +1424,11 @@ fn snapshot_stats_two_sections_identical_for_single_tier() { let _ = unsafe { Box::from_raw(raw_ptr as *mut Arc) }; // Section 0 (indices 0–9) and section 1 (indices 10–19) must be identical. - assert_eq!(&out[0..10], &out[10..20], - "overall and block_level sections should be identical for single-tier Foyer"); + assert_eq!( + &out[0..10], + &out[10..20], + "overall and block_level sections should be identical for single-tier Foyer" + ); } #[test] @@ -1063,7 +1441,10 @@ fn snapshot_stats_used_bytes_reflects_put() { // used_bytes counter is updated synchronously on put(). // Verify it is reflected immediately (index 6 = used_bytes). let snap = cache.stats.snapshot(); - assert_eq!(snap[6], 1024, "used_bytes should be 1024 after a single 1KB put"); + assert_eq!( + snap[6], 1024, + "used_bytes should be 1024 after a single 1KB put" + ); } #[test] @@ -1075,7 +1456,10 @@ fn snapshot_stats_null_out_via_created_cache() { cache.put(&key, Bytes::copy_from_slice(&data)); let snap = cache.stats.snapshot(); - assert_eq!(snap[6], 1024, "used_bytes should be 1024 after a single 1KB put"); + assert_eq!( + snap[6], 1024, + "used_bytes should be 1024 after a single 1KB put" + ); } /// foyer_create_cache returns a fat Box> pointer. @@ -1088,21 +1472,26 @@ fn ffm_create_cache_returns_fat_ptr_with_strong_count_one() { let dir = TempDir::new().unwrap(); let dir_str = dir.path().to_str().unwrap(); let engine = IO_ENGINE.as_bytes(); - let ptr = unsafe { foyer_create_cache( - 16 * 1024 * 1024, - dir_str.as_ptr(), dir_str.len() as u64, - BLOCK_SIZE as u64, BLOCK_SIZE as u64, (BLOCK_SIZE * 2) as u64, - engine.as_ptr(), engine.len() as u64, - 0, 0.0_f64, - 0, - )}; + let ptr = unsafe { + foyer_create_cache( + 16 * 1024 * 1024, + dir_str.as_ptr(), + dir_str.len() as u64, + BLOCK_SIZE as u64, + BLOCK_SIZE as u64, + (BLOCK_SIZE * 2) as u64, + engine.as_ptr(), + engine.len() as u64, + 0, + 0.0_f64, + 0, + ) + }; assert!(ptr > 0); // Interpret as Box> — if the pointer type is wrong this will crash. // Take ownership and immediately check the strong count via a clone probe. - let boxed: Box> = unsafe { - Box::from_raw(ptr as *mut Arc) - }; + let boxed: Box> = unsafe { Box::from_raw(ptr as *mut Arc) }; // Clone to bump strong count by 1 — original was 1, now 2. let clone = Arc::clone(&*boxed); assert_eq!(Arc::strong_count(&*boxed), 2); @@ -1121,14 +1510,21 @@ fn ffm_create_cache_ptr_cloneable_for_multiple_shards() { let dir = TempDir::new().unwrap(); let dir_str = dir.path().to_str().unwrap(); let engine = IO_ENGINE.as_bytes(); - let ptr = unsafe { foyer_create_cache( - 16 * 1024 * 1024, - dir_str.as_ptr(), dir_str.len() as u64, - BLOCK_SIZE as u64, BLOCK_SIZE as u64, (BLOCK_SIZE * 2) as u64, - engine.as_ptr(), engine.len() as u64, - 0, 0.0_f64, - 0, - )}; + let ptr = unsafe { + foyer_create_cache( + 16 * 1024 * 1024, + dir_str.as_ptr(), + dir_str.len() as u64, + BLOCK_SIZE as u64, + BLOCK_SIZE as u64, + (BLOCK_SIZE * 2) as u64, + engine.as_ptr(), + engine.len() as u64, + 0, + 0.0_f64, + 0, + ) + }; assert!(ptr > 0); // Simulate 3 shards each cloning the Arc (as ts_create_tiered_object_store would). @@ -1157,7 +1553,7 @@ fn ffm_create_cache_ptr_cloneable_for_multiple_shards() { #[test] fn drop_without_sweep_task_does_not_panic() { let (cache, _dir) = test_cache(); // sweep_interval_secs=0 → no task spawned - drop(cache); // calls shutdown.cancel() — must be a no-op + drop(cache); // calls shutdown.cancel() — must be a no-op } /// Dropping a cache with an active sweep task must not panic. @@ -1178,6 +1574,7 @@ fn drop_with_active_sweep_task_does_not_panic() { 3600, // 1-hour interval — task sleeps, drop cancels it immediately 0.0, // threshold disabled 0, // persist disabled + false, ); drop(cache); // shutdown.cancel() wakes the select! branch → task exits } @@ -1199,10 +1596,14 @@ fn drop_cancels_the_token() { 0, // no sweep task 0.0, // threshold disabled 0, // no persist task + false, ); // Clone the token before drop so we can inspect it after. let token: CancellationToken = cache.shutdown.clone(); - assert!(!token.is_cancelled(), "token must not be cancelled before drop"); + assert!( + !token.is_cancelled(), + "token must not be cancelled before drop" + ); drop(cache); assert!(token.is_cancelled(), "token must be cancelled after drop"); } @@ -1214,7 +1615,18 @@ fn drop_cancels_the_token() { fn cache_functional_before_drop() { let dir = TempDir::new().unwrap(); { - let cache = FoyerCache::new(64 * 1024 * 1024, dir.path(), BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE * 2, IO_ENGINE, 0, 0.0, 0); + let cache = FoyerCache::new( + 64 * 1024 * 1024, + dir.path(), + BLOCK_SIZE, + BLOCK_SIZE, + BLOCK_SIZE * 2, + IO_ENGINE, + 0, + 0.0, + 0, + false, + ); let key = range_cache_key("/data/file.parquet", 0, 100); cache.put(&key, Bytes::from_static(b"hello")); let result = block_on(cache.get(&key)); @@ -1249,7 +1661,18 @@ fn sweep_disabled_when_interval_is_zero() { #[test] fn sweep_enabled_cache_is_usable_while_task_sleeping() { let dir = TempDir::new().unwrap(); - let cache = FoyerCache::new(64 * 1024 * 1024, dir.path(), BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE * 2, IO_ENGINE, 3600, 0.0, 0); + let cache = FoyerCache::new( + 64 * 1024 * 1024, + dir.path(), + BLOCK_SIZE, + BLOCK_SIZE, + BLOCK_SIZE * 2, + IO_ENGINE, + 3600, + 0.0, + 0, + false, + ); let key = range_cache_key("/data/file.parquet", 0, 512); cache.put(&key, Bytes::from(vec![0xABu8; 512])); let result = block_on(cache.get(&key)); @@ -1292,10 +1715,18 @@ fn persist_task_catch_unwind_does_not_kill_loop_on_panic() { assert!(result.is_err(), "catch_unwind must catch the save panic"); // Cache is still usable. put_range(&cache, "/data/b.parquet", 0, 200, b"persist_data"); - assert!(cache.key_index.contains_key("data/b.parquet"), - "key_index must be intact after simulated panic"); - assert!(cache.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed) > 0, - "used_bytes must be > 0 after put"); + assert!( + cache.key_index.contains_key("data/b.parquet"), + "key_index must be intact after simulated panic" + ); + assert!( + cache + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed) + > 0, + "used_bytes must be > 0 after put" + ); } /// Verify that the sweep task with the watchdog outer loop stops cleanly @@ -1306,13 +1737,25 @@ fn sweep_task_with_active_watchdog_stops_cleanly_on_cancel() { let dir = TempDir::new().unwrap(); // Short sweep interval: the inner loop fires at least once within 2s. let cache = FoyerCache::new( - TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, - 1, // 1-second interval - 0.0, // threshold disabled — sweep runs every tick - 0, // persist disabled + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 1, // 1-second interval + 0.0, // threshold disabled — sweep runs every tick + 0, // persist disabled + false, ); // Put something so the sweep has a non-trivial key_index to process. - put_range(&cache, "/data/watchdog_test.parquet", 0, 512, &vec![0u8; 512]); + put_range( + &cache, + "/data/watchdog_test.parquet", + 0, + 512, + &vec![0u8; 512], + ); // Let the sweep fire at least once. std::thread::sleep(std::time::Duration::from_millis(1200)); // Drop cancels the token — the 'inner loop's cancelled() arm fires `return`. @@ -1328,20 +1771,40 @@ fn sweep_task_with_active_watchdog_stops_cleanly_on_cancel() { fn persist_task_with_active_watchdog_stops_cleanly_on_cancel() { let dir = TempDir::new().unwrap(); let cache = FoyerCache::new( - TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, - 0, // sweep disabled + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, // sweep disabled 0.0, - 1, // 1-second persist interval + 1, // 1-second persist interval + false, + ); + put_range( + &cache, + "/data/persist_watchdog.parquet", + 0, + 256, + &vec![0u8; 256], ); - put_range(&cache, "/data/persist_watchdog.parquet", 0, 256, &vec![0u8; 256]); // Poll for key_index.json — the persist task must write it within 5s. let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - while !dir.path().join(crate::key_index_store::KEY_INDEX_FILENAME).exists() { - if std::time::Instant::now() >= deadline { break; } + while !dir + .path() + .join(crate::key_index_store::KEY_INDEX_FILENAME) + .exists() + { + if std::time::Instant::now() >= deadline { + break; + } std::thread::sleep(std::time::Duration::from_millis(100)); } assert!( - dir.path().join(crate::key_index_store::KEY_INDEX_FILENAME).exists(), + dir.path() + .join(crate::key_index_store::KEY_INDEX_FILENAME) + .exists(), "persist task must have written key_index.json before drop" ); // Drop cancels token — persist task stops via `return` in cancelled() arm. @@ -1361,14 +1824,24 @@ fn persist_task_last_persisted_reset_forces_persist_after_recovery() { // Session 1: put data and let Drop write key_index.json. { let cache1 = FoyerCache::new( - TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, - 0, 0.0, 0, + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 0, + false, ); put_range(&cache1, "/data/reset_test.parquet", 0, 100, &vec![0u8; 100]); // Drop writes key_index.json with used_bytes=100. } assert!( - dir.path().join(crate::key_index_store::KEY_INDEX_FILENAME).exists(), + dir.path() + .join(crate::key_index_store::KEY_INDEX_FILENAME) + .exists(), "key_index.json must exist after session 1 Drop" ); // Session 2: open with persist_interval=1s. @@ -1376,18 +1849,30 @@ fn persist_task_last_persisted_reset_forces_persist_after_recovery() { // First tick: i64::MIN != 100 → persist fires → mtime advances. { let cache2 = FoyerCache::new( - TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, - 0, 0.0, 1, + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 1, + false, ); // Record mtime written by Drop of session 1. - let mtime_before = std::fs::metadata( - dir.path().join(crate::key_index_store::KEY_INDEX_FILENAME) - ).unwrap().modified().unwrap(); + let mtime_before = + std::fs::metadata(dir.path().join(crate::key_index_store::KEY_INDEX_FILENAME)) + .unwrap() + .modified() + .unwrap(); // Wait 2s for the first persist tick. std::thread::sleep(std::time::Duration::from_millis(2000)); - let mtime_after = std::fs::metadata( - dir.path().join(crate::key_index_store::KEY_INDEX_FILENAME) - ).unwrap().modified().unwrap(); + let mtime_after = + std::fs::metadata(dir.path().join(crate::key_index_store::KEY_INDEX_FILENAME)) + .unwrap() + .modified() + .unwrap(); assert!( mtime_after > mtime_before, "persist task must fire on first tick after recovery (last_persisted=MIN != recovered used_bytes)" @@ -1403,16 +1888,26 @@ fn persist_task_last_persisted_reset_forces_persist_after_recovery() { #[test] fn sweep_cursor_advances_by_one_per_call() { let (cache, _dir) = test_cache(); - assert_eq!(cache.sweep_cursor.load(std::sync::atomic::Ordering::Relaxed), 0, - "cursor must start at 0"); + assert_eq!( + cache + .sweep_cursor + .load(std::sync::atomic::Ordering::Relaxed), + 0, + "cursor must start at 0" + ); // 16 sweep calls — cursor increments on each (modulo 16 internally via fetch_add). for i in 1usize..=16 { cache.sweep_once(); assert_eq!( - cache.sweep_cursor.load(std::sync::atomic::Ordering::Relaxed) % 16, + cache + .sweep_cursor + .load(std::sync::atomic::Ordering::Relaxed) + % 16, i % 16, - "cursor must be {} after {} calls", i % 16, i + "cursor must be {} after {} calls", + i % 16, + i ); } } @@ -1423,10 +1918,15 @@ fn sweep_cursor_advances_by_one_per_call() { fn sweep_cursor_preset_is_respected() { let (cache, _dir) = test_cache(); // Preset to shard 5. - cache.sweep_cursor.store(5, std::sync::atomic::Ordering::Relaxed); + cache + .sweep_cursor + .store(5, std::sync::atomic::Ordering::Relaxed); cache.sweep_once(); assert_eq!( - cache.sweep_cursor.load(std::sync::atomic::Ordering::Relaxed), 6, + cache + .sweep_cursor + .load(std::sync::atomic::Ordering::Relaxed), + 6, "cursor must be 6 (5+1) after one sweep from preset 5" ); } @@ -1440,13 +1940,21 @@ fn sweep_cursor_wraps_at_shard_count() { let shard_count = cache.key_index.shards().len(); // Set cursor to last shard index. - cache.sweep_cursor.store(shard_count - 1, std::sync::atomic::Ordering::Relaxed); + cache + .sweep_cursor + .store(shard_count - 1, std::sync::atomic::Ordering::Relaxed); cache.sweep_once(); // processes shard (shard_count - 1), cursor becomes shard_count - let raw = cache.sweep_cursor.load(std::sync::atomic::Ordering::Relaxed); + let raw = cache + .sweep_cursor + .load(std::sync::atomic::Ordering::Relaxed); assert_eq!( - raw % shard_count, 0, - "cursor raw={} must wrap: {} % {} == 0", raw, raw, shard_count + raw % shard_count, + 0, + "cursor raw={} must wrap: {} % {} == 0", + raw, + raw, + shard_count ); } @@ -1477,7 +1985,9 @@ fn sweep_once_processes_exactly_one_shard() { } // Reset cursor to shard 0 for a predictable starting point. - cache.sweep_cursor.store(0, std::sync::atomic::Ordering::Relaxed); + cache + .sweep_cursor + .store(0, std::sync::atomic::Ordering::Relaxed); // One sweep_once() call must process exactly one shard. cache.sweep_once(); @@ -1488,7 +1998,9 @@ fn sweep_once_processes_exactly_one_shard() { shards_with_entries, shard_count - 1, "exactly one shard must have been swept; {} of {} shards still have entries (expected {})", - shards_with_entries, shard_count, shard_count - 1 + shards_with_entries, + shard_count, + shard_count - 1 ); } @@ -1505,27 +2017,48 @@ fn sweep_once_does_not_touch_other_shards() { // Inject stale keys directly into shards 0 and 1. let key0 = "stale_shard0\x1F0-100".to_string(); let key1 = "stale_shard1\x1F0-200".to_string(); - shards[0].write().insert("stale_shard0".to_string(), dashmap::SharedValue::new({ - let mut s = std::collections::HashSet::new(); s.insert(key0); s - })); - shards[1].write().insert("stale_shard1".to_string(), dashmap::SharedValue::new({ - let mut s = std::collections::HashSet::new(); s.insert(key1); s - })); + shards[0].write().insert( + "stale_shard0".to_string(), + dashmap::SharedValue::new({ + let mut s = std::collections::HashSet::new(); + s.insert(key0); + s + }), + ); + shards[1].write().insert( + "stale_shard1".to_string(), + dashmap::SharedValue::new({ + let mut s = std::collections::HashSet::new(); + s.insert(key1); + s + }), + ); // Set cursor to shard 0. - cache.sweep_cursor.store(0, std::sync::atomic::Ordering::Relaxed); + cache + .sweep_cursor + .store(0, std::sync::atomic::Ordering::Relaxed); // One call sweeps shard 0 only. let removed = cache.sweep_once(); assert_eq!(removed, 1, "only the stale key in shard 0 must be removed"); // Shard 0 must be clean. - assert!(shards[0].read().is_empty(), "shard 0 must be empty after sweep"); + assert!( + shards[0].read().is_empty(), + "shard 0 must be empty after sweep" + ); // Shard 1 must still have its stale key. - assert!(!shards[1].read().is_empty(), "shard 1 must be untouched after sweeping shard 0"); + assert!( + !shards[1].read().is_empty(), + "shard 1 must be untouched after sweeping shard 0" + ); assert_eq!( - cache.sweep_cursor.load(std::sync::atomic::Ordering::Relaxed), 1, + cache + .sweep_cursor + .load(std::sync::atomic::Ordering::Relaxed), + 1, "cursor must point to shard 1 after sweeping shard 0" ); } @@ -1540,13 +2073,14 @@ fn sweep_cursor_eventually_removes_all_stale_keys_across_multiple_calls() { // Inject stale keys under several different prefixes (they will hash to different shards). let stale_keys = [ ("data/alpha.parquet", "data/alpha.parquet\x1F0-100"), - ("data/beta.parquet", "data/beta.parquet\x1F0-200"), + ("data/beta.parquet", "data/beta.parquet\x1F0-200"), ("data/gamma.parquet", "data/gamma.parquet\x1F0-300"), ("data/delta.parquet", "data/delta.parquet\x1F0-400"), ]; for (prefix, key) in &stale_keys { - cache.key_index + cache + .key_index .entry(prefix.to_string()) .or_default() .insert(key.to_string()); @@ -1562,8 +2096,14 @@ fn sweep_cursor_eventually_removes_all_stale_keys_across_multiple_calls() { total_removed += cache.sweep_once(); } - assert_eq!(total_removed, 4, "all 4 stale keys must be removed within shard_count sweep cycles"); - assert!(cache.key_index.is_empty(), "key_index must be empty after all stale keys are swept"); + assert_eq!( + total_removed, 4, + "all 4 stale keys must be removed within shard_count sweep cycles" + ); + assert!( + cache.key_index.is_empty(), + "key_index must be empty after all stale keys are swept" + ); } /// A shard containing both live and stale keys: sweep removes only the stale keys, @@ -1577,19 +2117,27 @@ fn sweep_cursor_removes_stale_but_preserves_live_keys_in_same_prefix() { // Inject a fake stale key for the same prefix — Foyer has never seen this key. let stale_key = "data/mixed.parquet\x1F999000-999100".to_string(); - cache.key_index + cache + .key_index .entry("data/mixed.parquet".to_string()) .or_default() .insert(stale_key.clone()); // Key_index now has 2 keys for the prefix: 1 live + 1 stale. - let before_count = cache.key_index + let before_count = cache + .key_index .get("data/mixed.parquet") .map(|v| v.len()) .unwrap_or(0); - assert_eq!(before_count, 2, "should have 1 live + 1 stale key before sweep"); + assert_eq!( + before_count, 2, + "should have 1 live + 1 stale key before sweep" + ); - let eviction_count_before = cache.stats.eviction_count.load(std::sync::atomic::Ordering::Relaxed); + let eviction_count_before = cache + .stats + .eviction_count + .load(std::sync::atomic::Ordering::Relaxed); // Run enough sweeps to guarantee the shard containing "data/mixed.parquet" is processed. // DashMap defaults to 64 shards on macOS — must sweep at least shard_count times. @@ -1599,13 +2147,15 @@ fn sweep_cursor_removes_stale_but_preserves_live_keys_in_same_prefix() { } // Live key must remain; stale key must be gone. - let after_count = cache.key_index + let after_count = cache + .key_index .get("data/mixed.parquet") .map(|v| v.len()) .unwrap_or(0); assert_eq!(after_count, 1, "only the live key must remain after sweep"); assert!( - !cache.key_index + !cache + .key_index .get("data/mixed.parquet") .map(|v| v.contains(&stale_key)) .unwrap_or(false), @@ -1614,7 +2164,11 @@ fn sweep_cursor_removes_stale_but_preserves_live_keys_in_same_prefix() { // Eviction stats must have been updated for the stale key. assert!( - cache.stats.eviction_count.load(std::sync::atomic::Ordering::Relaxed) > eviction_count_before, + cache + .stats + .eviction_count + .load(std::sync::atomic::Ordering::Relaxed) + > eviction_count_before, "eviction_count must increase for the stale key removed by sweep" ); } @@ -1630,7 +2184,8 @@ fn sweep_cursor_cache_remains_functional_after_sweep() { put_range(&cache, "/data/keep.parquet", 512, 1024, &vec![0xBBu8; 512]); // Also inject a fake stale key. - cache.key_index + cache + .key_index .entry("data/keep.parquet".to_string()) .or_default() .insert("data/keep.parquet\x1F9000-9100".to_string()); @@ -1643,13 +2198,25 @@ fn sweep_cursor_cache_remains_functional_after_sweep() { // Real entries must still be retrievable after sweep. let r1 = block_on(cache.get(&range_cache_key("/data/keep.parquet", 0, 512))); let r2 = block_on(cache.get(&range_cache_key("/data/keep.parquet", 512, 1024))); - assert_eq!(r1.as_ref().map(|b| b.len()), Some(512), "first range must still be readable after sweep"); - assert_eq!(r2.as_ref().map(|b| b.len()), Some(512), "second range must still be readable after sweep"); + assert_eq!( + r1.as_ref().map(|b| b.len()), + Some(512), + "first range must still be readable after sweep" + ); + assert_eq!( + r2.as_ref().map(|b| b.len()), + Some(512), + "second range must still be readable after sweep" + ); // New puts and gets must work normally after sweep. put_range(&cache, "/data/new.parquet", 0, 256, &vec![0xCCu8; 256]); let r3 = block_on(cache.get(&range_cache_key("/data/new.parquet", 0, 256))); - assert_eq!(r3.as_ref().map(|b| b.len()), Some(256), "new put after sweep must be retrievable"); + assert_eq!( + r3.as_ref().map(|b| b.len()), + Some(256), + "new put after sweep must be retrievable" + ); } /// The cursor sweep correctly updates eviction_bytes when removing stale keys. @@ -1662,7 +2229,8 @@ fn sweep_cursor_eviction_bytes_correctly_tracked() { // "data/f.parquet\x1F0-100" → 100 bytes // "data/f.parquet\x1F100-300" → 200 bytes // Total stale bytes = 300 - cache.key_index + cache + .key_index .entry("data/f.parquet".to_string()) .or_default() .extend(vec![ @@ -1670,8 +2238,14 @@ fn sweep_cursor_eviction_bytes_correctly_tracked() { "data/f.parquet\x1F100-300".to_string(), ]); - let eviction_bytes_before = cache.stats.eviction_bytes.load(std::sync::atomic::Ordering::Relaxed); - let used_bytes_before = cache.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed); + let eviction_bytes_before = cache + .stats + .eviction_bytes + .load(std::sync::atomic::Ordering::Relaxed); + let used_bytes_before = cache + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed); // Run enough sweeps to cover all shards. // DashMap defaults to 64 shards on macOS — must sweep at least shard_count times. @@ -1680,18 +2254,29 @@ fn sweep_cursor_eviction_bytes_correctly_tracked() { cache.sweep_once(); } - let eviction_bytes_after = cache.stats.eviction_bytes.load(std::sync::atomic::Ordering::Relaxed); - let used_bytes_after = cache.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed); + let eviction_bytes_after = cache + .stats + .eviction_bytes + .load(std::sync::atomic::Ordering::Relaxed); + let used_bytes_after = cache + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed); assert_eq!( - eviction_bytes_after - eviction_bytes_before, 300, + eviction_bytes_after - eviction_bytes_before, + 300, "eviction_bytes must reflect 100 + 200 = 300 bytes for the two stale keys" ); assert_eq!( - used_bytes_after - used_bytes_before, -300, + used_bytes_after - used_bytes_before, + -300, "used_bytes must decrease by 300 when stale keys are swept" ); - assert!(cache.key_index.is_empty(), "key_index must be empty after stale keys are swept"); + assert!( + cache.key_index.is_empty(), + "key_index must be empty after stale keys are swept" + ); } // ── ActiveBytesGuard tests ──────────────────────────────────────────────────── @@ -1707,12 +2292,18 @@ fn active_bytes_guard_increments_and_decrements() { { let _guard = ActiveBytesGuard::new(&counter, 512); - assert_eq!(counter.load(std::sync::atomic::Ordering::Relaxed), 512, - "counter must be 512 while guard is alive"); + assert_eq!( + counter.load(std::sync::atomic::Ordering::Relaxed), + 512, + "counter must be 512 while guard is alive" + ); } // guard dropped here - assert_eq!(counter.load(std::sync::atomic::Ordering::Relaxed), 0, - "counter must return to 0 after guard is dropped"); + assert_eq!( + counter.load(std::sync::atomic::Ordering::Relaxed), + 0, + "counter must return to 0 after guard is dropped" + ); } /// Multiple guards at the same time accumulate correctly and each restores its @@ -1729,18 +2320,27 @@ fn active_bytes_guard_multiple_concurrent_guards() { assert_eq!(counter.load(std::sync::atomic::Ordering::Relaxed), 600); drop(g2); - assert_eq!(counter.load(std::sync::atomic::Ordering::Relaxed), 400, - "dropping g2 (200) must leave 100+300=400"); + assert_eq!( + counter.load(std::sync::atomic::Ordering::Relaxed), + 400, + "dropping g2 (200) must leave 100+300=400" + ); drop(g1); - assert_eq!(counter.load(std::sync::atomic::Ordering::Relaxed), 300, - "dropping g1 (100) must leave 300"); + assert_eq!( + counter.load(std::sync::atomic::Ordering::Relaxed), + 300, + "dropping g1 (100) must leave 300" + ); drop(g3); - assert_eq!(counter.load(std::sync::atomic::Ordering::Relaxed), 0, - "dropping g3 (300) must leave 0"); -} - + assert_eq!( + counter.load(std::sync::atomic::Ordering::Relaxed), + 0, + "dropping g3 (300) must leave 0" + ); +} + /// A guard with value 0 is a no-op — counter stays unchanged. #[test] fn active_bytes_guard_zero_value_is_noop() { @@ -1762,14 +2362,26 @@ fn active_in_bytes_is_zero_before_and_after_get() { cache.put(&key, Bytes::from(vec![0u8; 1024])); // Before get(): active_in_bytes must be 0. - assert_eq!(cache.stats.active_in_bytes.load(std::sync::atomic::Ordering::Relaxed), 0, - "active_in_bytes must be 0 before any get()"); + assert_eq!( + cache + .stats + .active_in_bytes + .load(std::sync::atomic::Ordering::Relaxed), + 0, + "active_in_bytes must be 0 before any get()" + ); block_on(cache.get(&key)); // After get() completes: guard has been dropped, counter back to 0. - assert_eq!(cache.stats.active_in_bytes.load(std::sync::atomic::Ordering::Relaxed), 0, - "active_in_bytes must be 0 after get() completes"); + assert_eq!( + cache + .stats + .active_in_bytes + .load(std::sync::atomic::Ordering::Relaxed), + 0, + "active_in_bytes must be 0 after get() completes" + ); } /// active_in_bytes is 0 even after a cache miss — the guard restores it regardless @@ -1779,11 +2391,26 @@ fn active_in_bytes_is_zero_after_cache_miss() { let (cache, _dir) = test_cache(); let key = range_cache_key("/data/never_inserted.parquet", 0, 512); - assert_eq!(cache.stats.active_in_bytes.load(std::sync::atomic::Ordering::Relaxed), 0); + assert_eq!( + cache + .stats + .active_in_bytes + .load(std::sync::atomic::Ordering::Relaxed), + 0 + ); let result = block_on(cache.get(&key)); - assert!(result.is_none(), "key was never inserted — should be a miss"); - assert_eq!(cache.stats.active_in_bytes.load(std::sync::atomic::Ordering::Relaxed), 0, - "active_in_bytes must be 0 after a cache miss"); + assert!( + result.is_none(), + "key was never inserted — should be a miss" + ); + assert_eq!( + cache + .stats + .active_in_bytes + .load(std::sync::atomic::Ordering::Relaxed), + 0, + "active_in_bytes must be 0 after a cache miss" + ); } /// Dropping a get() future before it completes simulates future cancellation. @@ -1804,8 +2431,11 @@ fn active_bytes_guard_drop_on_cancellation_restores_counter() { // Simulate: future cancelled (tokio::select! timeout or explicit drop). // Guard drops here — must restore counter to 0 even without the async block completing. drop(guard); - assert_eq!(counter.load(std::sync::atomic::Ordering::Relaxed), 0, - "active_in_bytes must be restored to 0 when get() future is cancelled mid-flight"); + assert_eq!( + counter.load(std::sync::atomic::Ordering::Relaxed), + 0, + "active_in_bytes must be restored to 0 when get() future is cancelled mid-flight" + ); } // ── Sweep threshold (should_skip_sweep) tests ───────────────────────────────── @@ -1816,16 +2446,36 @@ fn active_bytes_guard_drop_on_cancellation_restores_counter() { fn sweep_threshold_disabled_never_skips() { let dir = TempDir::new().unwrap(); // threshold = 0.0: disabled — always sweep - let cache = FoyerCache::new(TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, 0, 0.0, 0); + let cache = FoyerCache::new( + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 0, + false, + ); // used_bytes = 0 (empty cache) - assert_eq!(cache.should_skip_sweep(), false, - "threshold=0.0: should never skip even when cache is empty"); + assert_eq!( + cache.should_skip_sweep(), + false, + "threshold=0.0: should never skip even when cache is empty" + ); // used_bytes = 100% of disk - cache.stats.used_bytes.store(TEST_CACHE_DISK_BYTES as i64, std::sync::atomic::Ordering::Relaxed); - assert_eq!(cache.should_skip_sweep(), false, - "threshold=0.0: should never skip even when cache is 100% full"); + cache.stats.used_bytes.store( + TEST_CACHE_DISK_BYTES as i64, + std::sync::atomic::Ordering::Relaxed, + ); + assert_eq!( + cache.should_skip_sweep(), + false, + "threshold=0.0: should never skip even when cache is 100% full" + ); } /// When usage is strictly below the threshold, should_skip_sweep() returns true. @@ -1833,24 +2483,53 @@ fn sweep_threshold_disabled_never_skips() { fn sweep_threshold_skips_when_usage_below_threshold() { let dir = TempDir::new().unwrap(); // disk = 4MB, threshold = 0.75 (75%) - let cache = FoyerCache::new(TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, 0, 0.75, 0); + let cache = FoyerCache::new( + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.75, + 0, + false, + ); // usage = 0% → below 75% → skip - cache.stats.used_bytes.store(0, std::sync::atomic::Ordering::Relaxed); - assert_eq!(cache.should_skip_sweep(), true, - "usage=0% < threshold=75%: sweep must be skipped"); + cache + .stats + .used_bytes + .store(0, std::sync::atomic::Ordering::Relaxed); + assert_eq!( + cache.should_skip_sweep(), + true, + "usage=0% < threshold=75%: sweep must be skipped" + ); // usage = 50% → below 75% → skip let half = (TEST_CACHE_DISK_BYTES / 2) as i64; - cache.stats.used_bytes.store(half, std::sync::atomic::Ordering::Relaxed); - assert_eq!(cache.should_skip_sweep(), true, - "usage=50% < threshold=75%: sweep must be skipped"); + cache + .stats + .used_bytes + .store(half, std::sync::atomic::Ordering::Relaxed); + assert_eq!( + cache.should_skip_sweep(), + true, + "usage=50% < threshold=75%: sweep must be skipped" + ); // usage = 74.9% → below 75% → skip let below = ((TEST_CACHE_DISK_BYTES as f64 * 0.749) as i64); - cache.stats.used_bytes.store(below, std::sync::atomic::Ordering::Relaxed); - assert_eq!(cache.should_skip_sweep(), true, - "usage=74.9% < threshold=75%: sweep must be skipped"); + cache + .stats + .used_bytes + .store(below, std::sync::atomic::Ordering::Relaxed); + assert_eq!( + cache.should_skip_sweep(), + true, + "usage=74.9% < threshold=75%: sweep must be skipped" + ); } /// When usage is at or above the threshold, should_skip_sweep() returns false. @@ -1858,24 +2537,53 @@ fn sweep_threshold_skips_when_usage_below_threshold() { fn sweep_threshold_runs_when_usage_at_or_above_threshold() { let dir = TempDir::new().unwrap(); // disk = 4MB, threshold = 0.75 (75%) - let cache = FoyerCache::new(TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, 0, 0.75, 0); + let cache = FoyerCache::new( + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.75, + 0, + false, + ); // usage = exactly 75% → NOT below → do NOT skip let exact = ((TEST_CACHE_DISK_BYTES as f64 * 0.75) as i64); - cache.stats.used_bytes.store(exact, std::sync::atomic::Ordering::Relaxed); - assert_eq!(cache.should_skip_sweep(), false, - "usage=75% == threshold=75%: sweep must NOT be skipped"); + cache + .stats + .used_bytes + .store(exact, std::sync::atomic::Ordering::Relaxed); + assert_eq!( + cache.should_skip_sweep(), + false, + "usage=75% == threshold=75%: sweep must NOT be skipped" + ); // usage = 90% → above 75% → do NOT skip let above = ((TEST_CACHE_DISK_BYTES as f64 * 0.90) as i64); - cache.stats.used_bytes.store(above, std::sync::atomic::Ordering::Relaxed); - assert_eq!(cache.should_skip_sweep(), false, - "usage=90% > threshold=75%: sweep must NOT be skipped"); + cache + .stats + .used_bytes + .store(above, std::sync::atomic::Ordering::Relaxed); + assert_eq!( + cache.should_skip_sweep(), + false, + "usage=90% > threshold=75%: sweep must NOT be skipped" + ); // usage = 100% → above 75% → do NOT skip - cache.stats.used_bytes.store(TEST_CACHE_DISK_BYTES as i64, std::sync::atomic::Ordering::Relaxed); - assert_eq!(cache.should_skip_sweep(), false, - "usage=100% > threshold=75%: sweep must NOT be skipped"); + cache.stats.used_bytes.store( + TEST_CACHE_DISK_BYTES as i64, + std::sync::atomic::Ordering::Relaxed, + ); + assert_eq!( + cache.should_skip_sweep(), + false, + "usage=100% > threshold=75%: sweep must NOT be skipped" + ); } /// Threshold of 1.0 means "only sweep when cache is 100% full" — any usage below @@ -1883,18 +2591,41 @@ fn sweep_threshold_runs_when_usage_at_or_above_threshold() { #[test] fn sweep_threshold_one_skips_unless_completely_full() { let dir = TempDir::new().unwrap(); - let cache = FoyerCache::new(TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, 0, 1.0, 0); + let cache = FoyerCache::new( + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 1.0, + 0, + false, + ); // usage = 99.9% → still below 100% → skip let almost_full = ((TEST_CACHE_DISK_BYTES as f64 * 0.999) as i64); - cache.stats.used_bytes.store(almost_full, std::sync::atomic::Ordering::Relaxed); - assert_eq!(cache.should_skip_sweep(), true, - "threshold=1.0, usage=99.9%: sweep must be skipped"); + cache + .stats + .used_bytes + .store(almost_full, std::sync::atomic::Ordering::Relaxed); + assert_eq!( + cache.should_skip_sweep(), + true, + "threshold=1.0, usage=99.9%: sweep must be skipped" + ); // usage = exactly 100% → ratio = 1.0 = threshold → NOT below → do NOT skip - cache.stats.used_bytes.store(TEST_CACHE_DISK_BYTES as i64, std::sync::atomic::Ordering::Relaxed); - assert_eq!(cache.should_skip_sweep(), false, - "threshold=1.0, usage=100%: sweep must NOT be skipped"); + cache.stats.used_bytes.store( + TEST_CACHE_DISK_BYTES as i64, + std::sync::atomic::Ordering::Relaxed, + ); + assert_eq!( + cache.should_skip_sweep(), + false, + "threshold=1.0, usage=100%: sweep must NOT be skipped" + ); } /// Negative used_bytes (which can transiently occur due to relaxed ordering on @@ -1902,13 +2633,30 @@ fn sweep_threshold_one_skips_unless_completely_full() { #[test] fn sweep_threshold_negative_used_bytes_treated_as_zero() { let dir = TempDir::new().unwrap(); - let cache = FoyerCache::new(TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, 0, 0.5, 0); + let cache = FoyerCache::new( + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.5, + 0, + false, + ); // Simulate a transient underflow (negative used_bytes) — should be clamped to 0. - cache.stats.used_bytes.store(-100, std::sync::atomic::Ordering::Relaxed); + cache + .stats + .used_bytes + .store(-100, std::sync::atomic::Ordering::Relaxed); // usage = max(−100, 0) / disk = 0 / disk = 0.0 < 0.5 → skip - assert_eq!(cache.should_skip_sweep(), true, - "negative used_bytes must be clamped to 0; ratio 0.0 < threshold 0.5 → skip"); + assert_eq!( + cache.should_skip_sweep(), + true, + "negative used_bytes must be clamped to 0; ratio 0.0 < threshold 0.5 → skip" + ); } /// sweep_once() ignores the threshold — it calls reconcile_key_index directly. @@ -1917,22 +2665,44 @@ fn sweep_threshold_negative_used_bytes_treated_as_zero() { fn sweep_once_ignores_threshold_guard() { let dir = TempDir::new().unwrap(); // Set a high threshold so should_skip_sweep() returns true - let cache = FoyerCache::new(TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, 0, 0.99, 0); + let cache = FoyerCache::new( + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.99, + 0, + false, + ); // Inject a stale key - cache.key_index + cache + .key_index .entry("data/file.parquet".to_string()) .or_default() .insert("data/file.parquet\x1F0-100".to_string()); // usage = 0 → should_skip_sweep() is true (below 99% threshold) - assert_eq!(cache.should_skip_sweep(), true, "precondition: threshold guard would skip"); + assert_eq!( + cache.should_skip_sweep(), + true, + "precondition: threshold guard would skip" + ); // But sweep_once() bypasses the guard and still sweeps let shard_count = cache.key_index.shards().len(); let removed: usize = (0..shard_count).map(|_| cache.sweep_once()).sum(); - assert_eq!(removed, 1, "sweep_once() must sweep regardless of threshold"); - assert!(cache.key_index.is_empty(), "stale key must be removed by sweep_once() even when threshold would skip"); + assert_eq!( + removed, 1, + "sweep_once() must sweep regardless of threshold" + ); + assert!( + cache.key_index.is_empty(), + "stale key must be removed by sweep_once() even when threshold would skip" + ); } // ── Recovery / persistence integration tests ───────────────────────────────── @@ -1953,29 +2723,67 @@ fn recovery_key_index_bulk_loaded_after_graceful_shutdown() { // Write entries into the first cache instance. { - let cache = FoyerCache::new(TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, 0, 0.0, 0); - put_range(&cache, "/data/a.parquet", 0, 512, &vec![0u8; 512]); + let cache = FoyerCache::new( + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 0, + false, + ); + put_range(&cache, "/data/a.parquet", 0, 512, &vec![0u8; 512]); put_range(&cache, "/data/a.parquet", 512, 1024, &vec![0u8; 512]); - put_range(&cache, "/data/b.parquet", 0, 256, &vec![0u8; 256]); + put_range(&cache, "/data/b.parquet", 0, 256, &vec![0u8; 256]); // Drop calls save() — writes key_index.json } // key_index.json must exist after graceful shutdown. - assert!(dir.path().join(key_index_store::KEY_INDEX_FILENAME).exists(), - "key_index.json must be written on Drop"); + assert!( + dir.path() + .join(key_index_store::KEY_INDEX_FILENAME) + .exists(), + "key_index.json must be written on Drop" + ); // Second instance: recover from the snapshot. - let cache2 = FoyerCache::new(TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, 0, 0.0, 0); + let cache2 = FoyerCache::new( + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 0, + false, + ); // Both prefix buckets must be present immediately after new(). - assert!(cache2.key_index.contains_key("data/a.parquet"), - "data/a.parquet must be in key_index after recovery"); - assert!(cache2.key_index.contains_key("data/b.parquet"), - "data/b.parquet must be in key_index after recovery"); + assert!( + cache2.key_index.contains_key("data/a.parquet"), + "data/a.parquet must be in key_index after recovery" + ); + assert!( + cache2.key_index.contains_key("data/b.parquet"), + "data/b.parquet must be in key_index after recovery" + ); // 2 keys for a.parquet + 1 key for b.parquet = 3 total. - let a_count = cache2.key_index.get("data/a.parquet").map(|v| v.len()).unwrap_or(0); - let b_count = cache2.key_index.get("data/b.parquet").map(|v| v.len()).unwrap_or(0); + let a_count = cache2 + .key_index + .get("data/a.parquet") + .map(|v| v.len()) + .unwrap_or(0); + let b_count = cache2 + .key_index + .get("data/b.parquet") + .map(|v| v.len()) + .unwrap_or(0); assert_eq!(a_count, 2, "data/a.parquet must have 2 keys"); assert_eq!(b_count, 1, "data/b.parquet must have 1 key"); } @@ -1988,18 +2796,45 @@ fn recovery_used_bytes_initialized_from_snapshot() { let dir = TempDir::new().unwrap(); { - let cache = FoyerCache::new(TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, 0, 0.0, 0); + let cache = FoyerCache::new( + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 0, + false, + ); // 3 ranges: 100 + 200 + 300 = 600 bytes total. - put_range(&cache, "/data/f.parquet", 0, 100, &vec![0u8; 100]); + put_range(&cache, "/data/f.parquet", 0, 100, &vec![0u8; 100]); put_range(&cache, "/data/f.parquet", 100, 300, &vec![0u8; 200]); put_range(&cache, "/data/f.parquet", 300, 600, &vec![0u8; 300]); // Drop persists. } - let cache2 = FoyerCache::new(TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, 0, 0.0, 0); - let used = cache2.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed); - assert_eq!(used, 600, - "used_bytes must be 600 (sum of all recovered key ranges) after recovery"); + let cache2 = FoyerCache::new( + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 0, + false, + ); + let used = cache2 + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed); + assert_eq!( + used, 600, + "used_bytes must be 600 (sum of all recovered key ranges) after recovery" + ); } /// After recovery, evict_prefix() correctly removes entries that were loaded @@ -2009,22 +2844,48 @@ fn recovery_evict_prefix_works_on_recovered_keys() { let dir = TempDir::new().unwrap(); { - let cache = FoyerCache::new(TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, 0, 0.0, 0); + let cache = FoyerCache::new( + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 0, + false, + ); put_range(&cache, "/data/shard1/file.parquet", 0, 512, &vec![0u8; 512]); put_range(&cache, "/data/shard2/file.parquet", 0, 512, &vec![0u8; 512]); // Drop persists. } - let cache2 = FoyerCache::new(TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, 0, 0.0, 0); + let cache2 = FoyerCache::new( + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 0, + false, + ); assert!(cache2.key_index.contains_key("data/shard1/file.parquet")); assert!(cache2.key_index.contains_key("data/shard2/file.parquet")); // Evict one shard — only that shard's entry must be removed. cache2.evict_prefix("/data/shard1"); - assert!(!cache2.key_index.contains_key("data/shard1/file.parquet"), - "evict_prefix must remove recovered shard1 entries"); - assert!(cache2.key_index.contains_key("data/shard2/file.parquet"), - "shard2 must be untouched"); + assert!( + !cache2.key_index.contains_key("data/shard1/file.parquet"), + "evict_prefix must remove recovered shard1 entries" + ); + assert!( + cache2.key_index.contains_key("data/shard2/file.parquet"), + "shard2 must be untouched" + ); } /// Clean startup (no key_index.json) is a no-op: key_index starts empty, @@ -2033,11 +2894,34 @@ fn recovery_evict_prefix_works_on_recovered_keys() { fn recovery_with_no_snapshot_is_clean_startup() { let dir = TempDir::new().unwrap(); // No prior cache instance — key_index.json does not exist. - assert!(!dir.path().join(key_index_store::KEY_INDEX_FILENAME).exists()); + assert!(!dir + .path() + .join(key_index_store::KEY_INDEX_FILENAME) + .exists()); - let cache = FoyerCache::new(TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, 0, 0.0, 0); - assert!(cache.key_index.is_empty(), "key_index must be empty on clean startup"); - assert_eq!(cache.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed), 0); + let cache = FoyerCache::new( + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 0, + false, + ); + assert!( + cache.key_index.is_empty(), + "key_index must be empty on clean startup" + ); + assert_eq!( + cache + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed), + 0 + ); // Cache must still be fully functional. put_range(&cache, "/data/file.parquet", 0, 100, b"data"); @@ -2049,12 +2933,35 @@ fn recovery_with_no_snapshot_is_clean_startup() { #[test] fn recovery_with_corrupt_snapshot_starts_empty() { let dir = TempDir::new().unwrap(); - std::fs::write(dir.path().join(key_index_store::KEY_INDEX_FILENAME), b"{{corrupt}}") - .unwrap(); + std::fs::write( + dir.path().join(key_index_store::KEY_INDEX_FILENAME), + b"{{corrupt}}", + ) + .unwrap(); - let cache = FoyerCache::new(TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, 0, 0.0, 0); - assert!(cache.key_index.is_empty(), "corrupt snapshot must produce empty key_index"); - assert_eq!(cache.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed), 0); + let cache = FoyerCache::new( + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 0, + false, + ); + assert!( + cache.key_index.is_empty(), + "corrupt snapshot must produce empty key_index" + ); + assert_eq!( + cache + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed), + 0 + ); // Cache must still be fully functional. put_range(&cache, "/data/file.parquet", 0, 100, b"data"); @@ -2068,9 +2975,20 @@ fn recovery_evicted_prefix_not_persisted() { let dir = TempDir::new().unwrap(); { - let cache = FoyerCache::new(TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, 0, 0.0, 0); + let cache = FoyerCache::new( + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 0, + false, + ); put_range(&cache, "/data/evicted.parquet", 0, 512, &vec![0u8; 512]); - put_range(&cache, "/data/kept.parquet", 0, 512, &vec![0u8; 512]); + put_range(&cache, "/data/kept.parquet", 0, 512, &vec![0u8; 512]); // Evict one prefix before shutdown. cache.evict_prefix("/data/evicted.parquet"); @@ -2078,11 +2996,26 @@ fn recovery_evicted_prefix_not_persisted() { // Drop persists the remaining key_index (only kept.parquet). } - let cache2 = FoyerCache::new(TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, 0, 0.0, 0); - assert!(!cache2.key_index.contains_key("data/evicted.parquet"), - "evicted prefix must not appear in recovered key_index"); - assert!(cache2.key_index.contains_key("data/kept.parquet"), - "non-evicted prefix must still appear after recovery"); + let cache2 = FoyerCache::new( + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 0, + false, + ); + assert!( + !cache2.key_index.contains_key("data/evicted.parquet"), + "evicted prefix must not appear in recovered key_index" + ); + assert!( + cache2.key_index.contains_key("data/kept.parquet"), + "non-evicted prefix must still appear after recovery" + ); } /// clear() deletes key_index.json so the next startup does not load stale keys. @@ -2092,24 +3025,63 @@ fn recovery_clear_deletes_snapshot_file() { // Create and drop a cache to write key_index.json. { - let cache = FoyerCache::new(TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, 0, 0.0, 0); + let cache = FoyerCache::new( + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 0, + false, + ); put_range(&cache, "/data/file.parquet", 0, 100, b"data"); // Drop writes key_index.json. } - assert!(dir.path().join(key_index_store::KEY_INDEX_FILENAME).exists(), - "key_index.json must exist after first Drop"); + assert!( + dir.path() + .join(key_index_store::KEY_INDEX_FILENAME) + .exists(), + "key_index.json must exist after first Drop" + ); // Create a second cache and call clear(). { - let cache2 = FoyerCache::new(TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, 0, 0.0, 0); + let cache2 = FoyerCache::new( + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 0, + false, + ); block_on(cache2.clear()); // Drop of cache2 writes an empty key_index.json (empty key_index after clear). } // Third instance: must start with an empty key_index (clear deleted the stale snapshot). - let cache3 = FoyerCache::new(TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, 0, 0.0, 0); - assert!(cache3.key_index.is_empty(), - "key_index must be empty after clear() deleted the snapshot"); + let cache3 = FoyerCache::new( + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 0, + false, + ); + assert!( + cache3.key_index.is_empty(), + "key_index must be empty after clear() deleted the snapshot" + ); } // ── Periodic persist task tests ────────────────────────────────────────────── @@ -2127,17 +3099,27 @@ fn persist_task_writes_snapshot_within_interval() { { // persist_interval=1s: task should fire within ~2s of a put(). let cache = FoyerCache::new( - TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, - 0, // sweep disabled - 0.0, // threshold disabled - 1, // persist every 1 second + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, // sweep disabled + 0.0, // threshold disabled + 1, // persist every 1 second + false, ); put_range(&cache, "/data/periodic.parquet", 0, 512, &vec![0u8; 512]); // Poll for key_index.json to appear (written by the persist task, not Drop). let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); let appeared = loop { - if dir.path().join(key_index_store::KEY_INDEX_FILENAME).exists() { + if dir + .path() + .join(key_index_store::KEY_INDEX_FILENAME) + .exists() + { break true; } if std::time::Instant::now() >= deadline { @@ -2145,10 +3127,14 @@ fn persist_task_writes_snapshot_within_interval() { } std::thread::sleep(std::time::Duration::from_millis(100)); }; - assert!(appeared, "key_index.json must be written by persist task within 5s"); + assert!( + appeared, + "key_index.json must be written by persist task within 5s" + ); // Verify the content is valid (not just an empty file). - let contents = std::fs::read_to_string(dir.path().join(key_index_store::KEY_INDEX_FILENAME)).unwrap(); + let contents = + std::fs::read_to_string(dir.path().join(key_index_store::KEY_INDEX_FILENAME)).unwrap(); let snap: serde_json::Value = serde_json::from_str(&contents).unwrap(); assert_eq!(snap["version"], 1, "snapshot must have version=1"); let index = snap["index"].as_object().unwrap(); @@ -2169,30 +3155,54 @@ fn persist_task_does_not_fire_when_cache_idle() { { // persist_interval=1s let cache = FoyerCache::new( - TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, - 0, 0.0, 1, + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 1, + false, ); // Single put to trigger the first persist. put_range(&cache, "/data/idle.parquet", 0, 100, &vec![0u8; 100]); // Wait for the first persist (sentinel → current triggers it). let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - while !dir.path().join(key_index_store::KEY_INDEX_FILENAME).exists() { - if std::time::Instant::now() >= deadline { break; } + while !dir + .path() + .join(key_index_store::KEY_INDEX_FILENAME) + .exists() + { + if std::time::Instant::now() >= deadline { + break; + } std::thread::sleep(std::time::Duration::from_millis(100)); } - assert!(dir.path().join(key_index_store::KEY_INDEX_FILENAME).exists(), - "first persist must have fired"); + assert!( + dir.path() + .join(key_index_store::KEY_INDEX_FILENAME) + .exists(), + "first persist must have fired" + ); // Record mtime after first persist. - let mtime_after_first = std::fs::metadata(dir.path().join(key_index_store::KEY_INDEX_FILENAME)) - .unwrap().modified().unwrap(); + let mtime_after_first = + std::fs::metadata(dir.path().join(key_index_store::KEY_INDEX_FILENAME)) + .unwrap() + .modified() + .unwrap(); // Wait 2.5 intervals with NO new puts — used_bytes is unchanged so persist must NOT fire. std::thread::sleep(std::time::Duration::from_millis(2500)); - let mtime_after_idle = std::fs::metadata(dir.path().join(key_index_store::KEY_INDEX_FILENAME)) - .unwrap().modified().unwrap(); + let mtime_after_idle = + std::fs::metadata(dir.path().join(key_index_store::KEY_INDEX_FILENAME)) + .unwrap() + .modified() + .unwrap(); assert_eq!(mtime_after_first, mtime_after_idle, "persist task must NOT rewrite key_index.json when cache is idle (used_bytes unchanged)"); @@ -2205,33 +3215,59 @@ fn persist_task_fires_after_evict_prefix_changes_used_bytes() { let dir = TempDir::new().unwrap(); { let cache = FoyerCache::new( - TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, - 0, 0.0, 1, + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 1, + false, ); put_range(&cache, "/data/evict_me.parquet", 0, 512, &vec![0u8; 512]); - put_range(&cache, "/data/keep_me.parquet", 0, 512, &vec![0u8; 512]); + put_range(&cache, "/data/keep_me.parquet", 0, 512, &vec![0u8; 512]); // Wait for first persist (sentinel fires on first tick). let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - while !dir.path().join(key_index_store::KEY_INDEX_FILENAME).exists() { - if std::time::Instant::now() >= deadline { break; } + while !dir + .path() + .join(key_index_store::KEY_INDEX_FILENAME) + .exists() + { + if std::time::Instant::now() >= deadline { + break; + } std::thread::sleep(std::time::Duration::from_millis(100)); } // Evict one prefix — changes used_bytes so next tick must persist. cache.evict_prefix("/data/evict_me.parquet"); - let used_after_evict = cache.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed); - assert!(used_after_evict < 1024, "used_bytes must decrease after evict"); + let used_after_evict = cache + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed); + assert!( + used_after_evict < 1024, + "used_bytes must decrease after evict" + ); let mtime_before = std::fs::metadata(dir.path().join(key_index_store::KEY_INDEX_FILENAME)) - .unwrap().modified().unwrap(); + .unwrap() + .modified() + .unwrap(); // Wait up to 3 intervals for the next persist. std::thread::sleep(std::time::Duration::from_millis(3000)); let mtime_after = std::fs::metadata(dir.path().join(key_index_store::KEY_INDEX_FILENAME)) - .unwrap().modified().unwrap(); - assert!(mtime_after > mtime_before, - "persist task must rewrite key_index.json after evict_prefix() changed used_bytes"); + .unwrap() + .modified() + .unwrap(); + assert!( + mtime_after > mtime_before, + "persist task must rewrite key_index.json after evict_prefix() changed used_bytes" + ); } } @@ -2243,22 +3279,37 @@ fn persist_task_not_spawned_when_interval_is_zero() { { // persist_interval=0: only Drop persists. let cache = FoyerCache::new( - TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, - 0, 0.0, - 0, // disabled + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 0, // disabled + false, ); put_range(&cache, "/data/file.parquet", 0, 100, &vec![0u8; 100]); // Wait 2 seconds — file must NOT appear (no persist task). std::thread::sleep(std::time::Duration::from_millis(2000)); - assert!(!dir.path().join(key_index_store::KEY_INDEX_FILENAME).exists(), - "key_index.json must NOT exist while cache is alive with persist_interval=0"); + assert!( + !dir.path() + .join(key_index_store::KEY_INDEX_FILENAME) + .exists(), + "key_index.json must NOT exist while cache is alive with persist_interval=0" + ); // Drop is called here — final persist happens. } // After Drop, key_index.json must exist. - assert!(dir.path().join(key_index_store::KEY_INDEX_FILENAME).exists(), - "key_index.json must be written by Drop even when persist task is disabled"); + assert!( + dir.path() + .join(key_index_store::KEY_INDEX_FILENAME) + .exists(), + "key_index.json must be written by Drop even when persist task is disabled" + ); } /// CR-05 simulation: simulate a crash by using `std::mem::forget` to prevent Drop. @@ -2272,30 +3323,62 @@ fn simulated_crash_skip_drop_no_final_persist() { // Build a cache with persist_interval=0 (no periodic persist either). // This simulates a node with only Drop-based persistence. let cache = FoyerCache::new( - TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, - 0, 0.0, 0, + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 0, + false, ); put_range(&cache, "/data/crash.parquet", 0, 512, &vec![0u8; 512]); // key_index.json does NOT exist yet (no periodic persist, Drop not called). - assert!(!dir.path().join(key_index_store::KEY_INDEX_FILENAME).exists(), - "key_index.json must not exist before Drop"); + assert!( + !dir.path() + .join(key_index_store::KEY_INDEX_FILENAME) + .exists(), + "key_index.json must not exist before Drop" + ); // Simulate crash: forget the cache — Drop is NOT called. std::mem::forget(cache); // key_index.json must still NOT exist (Drop was skipped). - assert!(!dir.path().join(key_index_store::KEY_INDEX_FILENAME).exists(), - "key_index.json must not exist after simulated crash (Drop skipped)"); + assert!( + !dir.path() + .join(key_index_store::KEY_INDEX_FILENAME) + .exists(), + "key_index.json must not exist after simulated crash (Drop skipped)" + ); // Next startup: key_index starts empty (clean startup path). let cache2 = FoyerCache::new( - TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, - 0, 0.0, 0, + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 0, + false, + ); + assert!( + cache2.key_index.is_empty(), + "key_index must be empty after simulated crash with no prior snapshot" + ); + assert_eq!( + cache2 + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed), + 0 ); - assert!(cache2.key_index.is_empty(), - "key_index must be empty after simulated crash with no prior snapshot"); - assert_eq!(cache2.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed), 0); } /// GS-03 / GS-04: verify key_index.json content is valid JSON with correct version and index. @@ -2304,27 +3387,58 @@ fn graceful_shutdown_snapshot_is_valid_json_with_correct_content() { let dir = TempDir::new().unwrap(); { let cache = FoyerCache::new( - TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, - 0, 0.0, 0, + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 0, + false, + ); + put_range( + &cache, + "/data/nodes/0/shard1.parquet", + 0, + 256, + &vec![0u8; 256], + ); + put_range( + &cache, + "/data/nodes/0/shard1.parquet", + 256, + 512, + &vec![0u8; 256], + ); + put_range( + &cache, + "/data/nodes/0/shard2.parquet", + 0, + 128, + &vec![0u8; 128], ); - put_range(&cache, "/data/nodes/0/shard1.parquet", 0, 256, &vec![0u8; 256]); - put_range(&cache, "/data/nodes/0/shard1.parquet", 256, 512, &vec![0u8; 256]); - put_range(&cache, "/data/nodes/0/shard2.parquet", 0, 128, &vec![0u8; 128]); // Drop writes key_index.json. } let path = dir.path().join(key_index_store::KEY_INDEX_FILENAME); - assert!(path.exists(), "key_index.json must exist after graceful shutdown"); + assert!( + path.exists(), + "key_index.json must exist after graceful shutdown" + ); let contents = std::fs::read_to_string(&path).unwrap(); assert!(!contents.is_empty(), "key_index.json must not be empty"); // Parse and validate structure. - let parsed: serde_json::Value = serde_json::from_str(&contents) - .expect("key_index.json must be valid JSON"); + let parsed: serde_json::Value = + serde_json::from_str(&contents).expect("key_index.json must be valid JSON"); assert_eq!(parsed["version"], 1, "version must be 1"); - let index = parsed["index"].as_object().expect("index must be a JSON object"); + let index = parsed["index"] + .as_object() + .expect("index must be a JSON object"); assert_eq!(index.len(), 2, "must have 2 prefix buckets"); // shard1 should have 2 keys, shard2 should have 1. @@ -2340,16 +3454,32 @@ fn no_tmp_file_left_after_graceful_shutdown() { let dir = TempDir::new().unwrap(); { let cache = FoyerCache::new( - TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, - 0, 0.0, 0, + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 0, + false, ); put_range(&cache, "/data/file.parquet", 0, 100, &vec![0u8; 100]); // Drop writes and renames. } - assert!(!dir.path().join(key_index_store::KEY_INDEX_TMP_FILENAME).exists(), - ".key_index.json.tmp must not exist after graceful shutdown (rename completed)"); - assert!(dir.path().join(key_index_store::KEY_INDEX_FILENAME).exists(), - "key_index.json must exist after graceful shutdown"); + assert!( + !dir.path() + .join(key_index_store::KEY_INDEX_TMP_FILENAME) + .exists(), + ".key_index.json.tmp must not exist after graceful shutdown (rename completed)" + ); + assert!( + dir.path() + .join(key_index_store::KEY_INDEX_FILENAME) + .exists(), + "key_index.json must exist after graceful shutdown" + ); } /// VM-03: zero-byte key_index.json → treated as corrupt → clean startup. @@ -2360,12 +3490,28 @@ fn zero_byte_snapshot_file_treated_as_corrupt() { std::fs::write(dir.path().join(key_index_store::KEY_INDEX_FILENAME), b"").unwrap(); let cache = FoyerCache::new( - TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, - 0, 0.0, 0, + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 0, + false, + ); + assert!( + cache.key_index.is_empty(), + "zero-byte snapshot must produce empty key_index (clean startup)" + ); + assert_eq!( + cache + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed), + 0 ); - assert!(cache.key_index.is_empty(), - "zero-byte snapshot must produce empty key_index (clean startup)"); - assert_eq!(cache.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed), 0); // Cache must be fully functional. put_range(&cache, "/data/file.parquet", 0, 100, b"data"); assert!(cache.key_index.contains_key("data/file.parquet")); @@ -2376,19 +3522,41 @@ fn zero_byte_snapshot_file_treated_as_corrupt() { fn no_tmp_file_on_clean_startup() { let dir = TempDir::new().unwrap(); // Verify neither file exists before creating the cache. - assert!(!dir.path().join(key_index_store::KEY_INDEX_FILENAME).exists()); - assert!(!dir.path().join(key_index_store::KEY_INDEX_TMP_FILENAME).exists()); + assert!(!dir + .path() + .join(key_index_store::KEY_INDEX_FILENAME) + .exists()); + assert!(!dir + .path() + .join(key_index_store::KEY_INDEX_TMP_FILENAME) + .exists()); let cache = FoyerCache::new( - TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, - 0, 0.0, 0, + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 0, + false, ); // On startup, no .tmp should be created or left behind. - assert!(!dir.path().join(key_index_store::KEY_INDEX_TMP_FILENAME).exists(), - ".key_index.json.tmp must not exist after clean startup"); + assert!( + !dir.path() + .join(key_index_store::KEY_INDEX_TMP_FILENAME) + .exists(), + ".key_index.json.tmp must not exist after clean startup" + ); drop(cache); - assert!(!dir.path().join(key_index_store::KEY_INDEX_TMP_FILENAME).exists(), - ".key_index.json.tmp must not exist after graceful shutdown"); + assert!( + !dir.path() + .join(key_index_store::KEY_INDEX_TMP_FILENAME) + .exists(), + ".key_index.json.tmp must not exist after graceful shutdown" + ); } /// Stale entries in the recovered snapshot (keys that Foyer did not recover due @@ -2402,8 +3570,8 @@ fn no_tmp_file_on_clean_startup() { /// parses cleanly, and contains every prefix that was inserted. #[test] fn key_index_serialization_with_10k_entries() { - use std::collections::HashSet; use dashmap::DashMap; + use std::collections::HashSet; let dir = TempDir::new().unwrap(); const NUM_PREFIXES: usize = 100; @@ -2425,22 +3593,32 @@ fn key_index_serialization_with_10k_entries() { // Load back and verify. let loaded = key_index_store::load_or_empty(dir.path()); - assert_eq!(loaded.index.len(), NUM_PREFIXES, "must have {NUM_PREFIXES} prefix buckets"); + assert_eq!( + loaded.index.len(), + NUM_PREFIXES, + "must have {NUM_PREFIXES} prefix buckets" + ); let total_keys: usize = loaded.index.values().map(|v| v.len()).sum(); - assert_eq!(total_keys, expected_total, "must have {expected_total} total keys"); + assert_eq!( + total_keys, expected_total, + "must have {expected_total} total keys" + ); // The file itself must be readable JSON above a minimum size. let path = dir.path().join(key_index_store::KEY_INDEX_FILENAME); let bytes = std::fs::metadata(&path).unwrap().len(); - assert!(bytes > 100_000, "key_index.json must be > 100KB for 10k entries, got {bytes}"); + assert!( + bytes > 100_000, + "key_index.json must be > 100KB for 10k entries, got {bytes}" + ); } /// Build a 10k-entry snapshot, write it to disk, then create a new FoyerCache /// over the same dir — verify the key_index is bulk-loaded correctly. #[test] fn key_index_recovery_with_10k_entries() { - use std::collections::HashSet; use dashmap::DashMap; + use std::collections::HashSet; let dir = TempDir::new().unwrap(); const NUM_PREFIXES: usize = 100; @@ -2458,13 +3636,31 @@ fn key_index_recovery_with_10k_entries() { key_index_store::save(dir.path(), &dash).unwrap(); // Create a new cache from the same dir — should bulk-load the snapshot. - let cache = FoyerCache::new(TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, 0, 0.0, 0); + let cache = FoyerCache::new( + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 0, + false, + ); - assert_eq!(cache.key_index.len(), NUM_PREFIXES, - "key_index must have {NUM_PREFIXES} buckets after recovery"); + assert_eq!( + cache.key_index.len(), + NUM_PREFIXES, + "key_index must have {NUM_PREFIXES} buckets after recovery" + ); let total_keys: usize = cache.key_index.iter().map(|e| e.value().len()).sum(); - assert_eq!(total_keys, NUM_PREFIXES * KEYS_PER_PREFIX, - "must have {} total keys after recovery", NUM_PREFIXES * KEYS_PER_PREFIX); + assert_eq!( + total_keys, + NUM_PREFIXES * KEYS_PER_PREFIX, + "must have {} total keys after recovery", + NUM_PREFIXES * KEYS_PER_PREFIX + ); } /// Populate 10k entries directly into the key_index (simulating cache puts), @@ -2472,7 +3668,18 @@ fn key_index_recovery_with_10k_entries() { #[test] fn key_index_evict_prefix_bulk_with_10k_entries() { let dir = TempDir::new().unwrap(); - let cache = FoyerCache::new(TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, 0, 0.0, 0); + let cache = FoyerCache::new( + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 0, + false, + ); const NUM_PREFIXES: usize = 100; const KEYS_PER_PREFIX: usize = 100; @@ -2484,13 +3691,25 @@ fn key_index_evict_prefix_bulk_with_10k_entries() { let keys: HashSet = (0..KEYS_PER_PREFIX) .map(|k| format!("{prefix}\x1F{}-{}", k * 4096, (k + 1) * 4096)) .collect(); - let byte_size: i64 = keys.iter().map(|k| crate::range_cache::key_byte_size(k)).sum(); + let byte_size: i64 = keys + .iter() + .map(|k| crate::range_cache::key_byte_size(k)) + .sum(); cache.key_index.insert(prefix, keys); - cache.stats.used_bytes.fetch_add(byte_size, std::sync::atomic::Ordering::Relaxed); + cache + .stats + .used_bytes + .fetch_add(byte_size, std::sync::atomic::Ordering::Relaxed); } - let used_before = cache.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed); - assert!(used_before > 0, "used_bytes must be > 0 after populating {NUM_PREFIXES} prefixes"); + let used_before = cache + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed); + assert!( + used_before > 0, + "used_bytes must be > 0 after populating {NUM_PREFIXES} prefixes" + ); // Evict first 50 prefixes. for p in 0..NUM_PREFIXES / 2 { @@ -2498,10 +3717,20 @@ fn key_index_evict_prefix_bulk_with_10k_entries() { cache.evict_prefix(&prefix); } - let used_after = cache.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed); - assert!(used_after < used_before, "used_bytes must decrease after bulk evict_prefix"); - assert_eq!(cache.key_index.len(), NUM_PREFIXES / 2, - "key_index must have {} buckets after evicting half", NUM_PREFIXES / 2); + let used_after = cache + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed); + assert!( + used_after < used_before, + "used_bytes must decrease after bulk evict_prefix" + ); + assert_eq!( + cache.key_index.len(), + NUM_PREFIXES / 2, + "key_index must have {} buckets after evicting half", + NUM_PREFIXES / 2 + ); } /// Populate 10k entries, call clear(), verify the key_index is empty and @@ -2511,7 +3740,18 @@ fn key_index_clear_with_10k_entries() { use std::collections::HashSet; let dir = TempDir::new().unwrap(); - let cache = FoyerCache::new(TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, 0, 0.0, 0); + let cache = FoyerCache::new( + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 0, + false, + ); const NUM_PREFIXES: usize = 100; const KEYS_PER_PREFIX: usize = 100; @@ -2521,24 +3761,48 @@ fn key_index_clear_with_10k_entries() { let keys: HashSet = (0..KEYS_PER_PREFIX) .map(|k| format!("{prefix}\x1F{}-{}", k * 4096, (k + 1) * 4096)) .collect(); - let byte_size: i64 = keys.iter().map(|k| crate::range_cache::key_byte_size(k)).sum(); + let byte_size: i64 = keys + .iter() + .map(|k| crate::range_cache::key_byte_size(k)) + .sum(); cache.key_index.insert(prefix, keys); - cache.stats.used_bytes.fetch_add(byte_size, std::sync::atomic::Ordering::Relaxed); + cache + .stats + .used_bytes + .fetch_add(byte_size, std::sync::atomic::Ordering::Relaxed); } // Persist first so there's a file to delete. key_index_store::save(&dir.path(), &cache.key_index).unwrap(); - assert!(dir.path().join(key_index_store::KEY_INDEX_FILENAME).exists(), "file must exist before clear"); + assert!( + dir.path() + .join(key_index_store::KEY_INDEX_FILENAME) + .exists(), + "file must exist before clear" + ); // Clear via the public async API. block_on(cache.clear()); - assert_eq!(cache.key_index.len(), 0, "key_index must be empty after clear"); - assert_eq!(cache.stats.used_bytes.load(std::sync::atomic::Ordering::Relaxed), 0, - "used_bytes must be 0 after clear"); + assert_eq!( + cache.key_index.len(), + 0, + "key_index must be empty after clear" + ); + assert_eq!( + cache + .stats + .used_bytes + .load(std::sync::atomic::Ordering::Relaxed), + 0, + "used_bytes must be 0 after clear" + ); // After clear() the key_index.json is deleted; Drop will write an empty one. // Assert it is absent while the cache is still alive (before Drop). - let snap_exists = dir.path().join(key_index_store::KEY_INDEX_FILENAME).exists(); + let snap_exists = dir + .path() + .join(key_index_store::KEY_INDEX_FILENAME) + .exists(); // clear() calls key_index_store::delete() which removes the file. assert!(!snap_exists, "key_index.json must be deleted by clear()"); } @@ -2560,20 +3824,427 @@ fn recovery_stale_snapshot_keys_cleaned_by_sweep() { ); let snap = key_index_store::KeyIndexSnapshot { version: 1, index }; let json = serde_json::to_string(&snap).unwrap(); - std::fs::write(dir.path().join(key_index_store::KEY_INDEX_FILENAME), json.as_bytes()).unwrap(); + std::fs::write( + dir.path().join(key_index_store::KEY_INDEX_FILENAME), + json.as_bytes(), + ) + .unwrap(); } // Create a fresh Foyer cache over the same dir. Foyer has no data for "data/stale.parquet". - let cache = FoyerCache::new(TEST_CACHE_DISK_BYTES, dir.path(), TEST_CACHE_BLOCK_SIZE, TEST_BUFFER_POOL_SIZE, TEST_SUBMIT_QUEUE_SIZE, IO_ENGINE, 0, 0.0, 0); + let cache = FoyerCache::new( + TEST_CACHE_DISK_BYTES, + dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 0, + false, + ); // Bulk-loaded snapshot has the stale key — used_bytes is temporarily over-counted. - assert!(cache.key_index.contains_key("data/stale.parquet"), - "stale key must be present immediately after bulk-load recovery"); + assert!( + cache.key_index.contains_key("data/stale.parquet"), + "stale key must be present immediately after bulk-load recovery" + ); // After sweeping all shards, the stale key is removed (inner.contains() returns false). let shard_count = cache.key_index.shards().len(); let removed: usize = (0..shard_count).map(|_| cache.sweep_once()).sum(); assert_eq!(removed, 1, "sweep must remove the 1 stale key"); - assert!(!cache.key_index.contains_key("data/stale.parquet"), - "stale key must be gone after sweep"); + assert!( + !cache.key_index.contains_key("data/stale.parquet"), + "stale key must be gone after sweep" + ); +} + +// ── TieredBlockCache tests ────────────────────────────────────────────────────── + +use crate::tiered_block_cache::TieredBlockCache; +use std::sync::atomic::Ordering; + +fn tiered_test_cache() -> (TieredBlockCache, TempDir, TempDir) { + let data_dir = TempDir::new().expect("data temp dir"); + let meta_dir = TempDir::new().expect("metadata temp dir"); + let data_cache = Arc::new(FoyerCache::new( + TEST_CACHE_DISK_BYTES, + data_dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 0, + false, + )); + let metadata_cache = Arc::new(FoyerCache::new( + TEST_CACHE_DISK_BYTES, + meta_dir.path(), + TEST_CACHE_BLOCK_SIZE, + TEST_BUFFER_POOL_SIZE, + TEST_SUBMIT_QUEUE_SIZE, + IO_ENGINE, + 0, + 0.0, + 0, + true, + )); + let tiered = TieredBlockCache::new(data_cache, metadata_cache); + (tiered, data_dir, meta_dir) +} + +/// put_metadata() routes to metadata cache; get() finds it there. +#[test] +fn tiered_cache_put_metadata_routes_to_metadata_cache() { + let (tiered, _data_dir, _meta_dir) = tiered_test_cache(); + + let footer_key = range_cache_key("seg0/_0.parquet", 9990000, 10000000); + let col_idx_key = range_cache_key("seg0/_0.parquet", 8000000, 8500000); + + tiered.put_metadata(&footer_key, Bytes::from_static(b"footer_bytes")); + tiered.put_metadata(&col_idx_key, Bytes::from_static(b"column_index_bytes")); + + // get() probes metadata cache first → hit + assert_eq!( + block_on(tiered.get(&footer_key)).as_deref(), + Some(b"footer_bytes".as_slice()) + ); + assert_eq!( + block_on(tiered.get(&col_idx_key)).as_deref(), + Some(b"column_index_bytes".as_slice()) + ); + + // Verify metadata is in metadata_cache, not data_cache + assert!(block_on(tiered.metadata_cache().get(&footer_key)).is_some()); + assert!(block_on(tiered.data_cache().get(&footer_key)).is_none()); +} + +/// put() (normal path from TieredObjectStore::populate_cache) routes to data cache. +#[test] +fn tiered_cache_put_routes_to_data_cache() { + let (tiered, _data_dir, _meta_dir) = tiered_test_cache(); + + let col_a_key = range_cache_key("seg0/_0.parquet", 0, 2097152); + tiered.put(&col_a_key, Bytes::from_static(b"col_a_data")); + + // get() misses metadata cache, hits data cache + assert_eq!( + block_on(tiered.get(&col_a_key)).as_deref(), + Some(b"col_a_data".as_slice()) + ); + + // Verify data is in data_cache only + assert!(block_on(tiered.data_cache().get(&col_a_key)).is_some()); + assert!(block_on(tiered.metadata_cache().get(&col_a_key)).is_none()); +} + +/// get() checks metadata cache first — so on warm restart (Foyer recovered +/// metadata SSD), metadata is found without any re-registration. +#[test] +fn tiered_cache_get_finds_metadata_without_reregistration() { + let (tiered, _data_dir, _meta_dir) = tiered_test_cache(); + + // Simulate prior session: metadata was put via put_metadata + let key = range_cache_key("seg0/_0.parquet", 9990000, 10000000); + tiered.put_metadata(&key, Bytes::from_static(b"old_footer")); + + // get() finds it — no re-registration needed (metadata cache probed first) + assert_eq!( + block_on(tiered.get(&key)).as_deref(), + Some(b"old_footer".as_slice()) + ); +} + +/// On a data key miss (not in either cache), metadata cache is still probed +/// first (miss) then data cache (miss) — both miss, returns None. Caller goes to S3. +#[test] +fn tiered_cache_total_miss_returns_none() { + let (tiered, _data_dir, _meta_dir) = tiered_test_cache(); + + let key = range_cache_key("seg0/_0.parquet", 5000000, 6000000); + assert!(block_on(tiered.get(&key)).is_none()); +} + +/// Full DataFusion query simulation: +/// 1. Shard init: warmup puts metadata via put_metadata() +/// 2. Query: DataFusion reads metadata → hit from metadata cache (probed first) +/// 3. Query: DataFusion reads data → miss → S3 → put() → data cache +/// 4. Query 2: same data → hit from data cache +#[test] +fn tiered_cache_full_datafusion_query_simulation() { + let (tiered, _data_dir, _meta_dir) = tiered_test_cache(); + let path = "seg0/_0.parquet"; + + // ── Warmup: put metadata explicitly ────────────────────────────── + let footer_key = range_cache_key(path, 9_990_000, 10_000_000); + let col_idx_key = range_cache_key(path, 8_000_000, 8_500_000); + tiered.put_metadata(&footer_key, Bytes::from(vec![0xF; 10_000])); + tiered.put_metadata(&col_idx_key, Bytes::from(vec![0xC; 500_000])); + + // ── Query: metadata reads → hit (metadata cache probed first) ──── + assert!( + block_on(tiered.get(&footer_key)).is_some(), + "footer must hit" + ); + assert!( + block_on(tiered.get(&col_idx_key)).is_some(), + "column index must hit" + ); + + // ── Query: data read → miss first time ─────────────────────────── + let data_key = range_cache_key(path, 0, 2_000_000); + assert!( + block_on(tiered.get(&data_key)).is_none(), + "data miss on first access" + ); + + // ── S3 fetch → populate_cache → put() → data cache ────────────── + tiered.put(&data_key, Bytes::from(vec![0xAA; 2_000_000])); + + // ── Query 2: same data → hit from data cache ───────────────────── + assert_eq!( + block_on(tiered.get(&data_key)).map(|b| b.len()), + Some(2_000_000) + ); + + // ── Verify isolation ───────────────────────────────────────────── + assert!(block_on(tiered.metadata_cache().get(&footer_key)).is_some()); + assert!(block_on(tiered.data_cache().get(&footer_key)).is_none()); + assert!(block_on(tiered.data_cache().get(&data_key)).is_some()); + assert!(block_on(tiered.metadata_cache().get(&data_key)).is_none()); +} + +/// evict_prefix removes entries from both caches. +#[test] +fn tiered_cache_evict_prefix_cleans_both_caches() { + let (tiered, _data_dir, _meta_dir) = tiered_test_cache(); + let path = "seg0/_0.parquet"; + + tiered.put_metadata( + &range_cache_key(path, 9000000, 10000000), + Bytes::from_static(b"meta"), + ); + tiered.put( + &range_cache_key(path, 0, 1000000), + Bytes::from_static(b"data"), + ); + + tiered.evict_prefix(path); + + assert!(block_on(tiered.get(&range_cache_key(path, 9000000, 10000000))).is_none()); + assert!(block_on(tiered.get(&range_cache_key(path, 0, 1000000))).is_none()); +} + +/// Multiple shards — evicting one doesn't affect others. +#[test] +fn tiered_cache_multiple_shards_are_independent() { + let (tiered, _data_dir, _meta_dir) = tiered_test_cache(); + + let s0 = range_cache_key("seg0/_0.parquet", 9000, 10000); + let s1 = range_cache_key("seg1/_0.parquet", 9000, 10000); + tiered.put_metadata(&s0, Bytes::from_static(b"shard0")); + tiered.put_metadata(&s1, Bytes::from_static(b"shard1")); + + tiered.evict_prefix("seg0/"); + + assert!(block_on(tiered.get(&s0)).is_none()); + assert_eq!( + block_on(tiered.get(&s1)).as_deref(), + Some(b"shard1".as_slice()) + ); +} + +/// Metadata hit does not touch data cache SSD. +#[test] +fn tiered_cache_metadata_hit_never_probes_data_ssd() { + let (tiered, _data_dir, _meta_dir) = tiered_test_cache(); + + let key = range_cache_key("seg0/_0.parquet", 9990000, 10000000); + tiered.put_metadata(&key, Bytes::from_static(b"footer")); + + let data_hits_before = tiered.data_cache().stats.hit_count.load(Ordering::Relaxed); + let data_misses_before = tiered.data_cache().stats.miss_count.load(Ordering::Relaxed); + + let result = block_on(tiered.get(&key)); + assert!(result.is_some()); + + // Data cache untouched + assert_eq!( + tiered.data_cache().stats.hit_count.load(Ordering::Relaxed), + data_hits_before + ); + assert_eq!( + tiered.data_cache().stats.miss_count.load(Ordering::Relaxed), + data_misses_before + ); +} + +/// Key alignment: exact range match = hit, subset/superset = miss. +#[test] +fn tiered_cache_key_alignment_warmup_matches_query() { + let (tiered, _data_dir, _meta_dir) = tiered_test_cache(); + + let key = range_cache_key("seg0/_0.parquet", 8_000_000, 8_500_000); + tiered.put_metadata(&key, Bytes::from(vec![0xC; 500_000])); + + // Exact same range → hit + assert!( + block_on(tiered.get(&range_cache_key("seg0/_0.parquet", 8_000_000, 8_500_000))).is_some() + ); + + // Subset → miss (different key) + assert!( + block_on(tiered.get(&range_cache_key("seg0/_0.parquet", 8_000_000, 8_250_000))).is_none() + ); + + // Superset → miss (different key) + assert!( + block_on(tiered.get(&range_cache_key("seg0/_0.parquet", 7_500_000, 9_000_000))).is_none() + ); +} + +/// put_metadata() skips entries larger than max_metadata_entry_size — oversized +/// metadata is never written, bounding memory for pathological (wide-schema) files. +#[test] +fn tiered_cache_put_metadata_skips_oversized_entry() { + let (tiered, _data_dir, _meta_dir) = tiered_test_cache(); + + // Shrink the bound so the test stays small (no multi-MB allocations). + tiered.update_max_metadata_entry_size(2048); + + let big_key = range_cache_key("seg0/_0.parquet", 0, 4096); + tiered.put_metadata(&big_key, Bytes::from(vec![0xAB; 4096])); // 4KB > 2KB → skipped + assert!( + block_on(tiered.get(&big_key)).is_none(), + "metadata entry exceeding max_metadata_entry_size must not be cached" + ); + assert!( + block_on(tiered.metadata_cache().get(&big_key)).is_none(), + "oversized metadata must not reach the metadata tier" + ); + + let small_key = range_cache_key("seg0/_0.parquet", 0, 1024); + tiered.put_metadata(&small_key, Bytes::from(vec![0xCD; 1024])); // 1KB <= 2KB → cached + assert_eq!( + block_on(tiered.get(&small_key)).map(|b| b.len()), + Some(1024), + "metadata entry within the limit must be cached" + ); + assert!(block_on(tiered.metadata_cache().get(&small_key)).is_some()); +} + +/// put() skips data entries larger than max_data_entry_size; the getter reflects +/// the configured bound (used by TieredObjectStore::get_opts to avoid buffering). +#[test] +fn tiered_cache_put_skips_oversized_data_entry() { + let (tiered, _data_dir, _meta_dir) = tiered_test_cache(); + + tiered.update_max_data_entry_size(2048); + assert_eq!( + tiered.max_data_entry_size(), + 2048, + "getter must reflect the updated bound" + ); + + let big_key = range_cache_key("seg0/_0.parquet", 0, 4096); + tiered.put(&big_key, Bytes::from(vec![0xAB; 4096])); // 4KB > 2KB → skipped + assert!( + block_on(tiered.get(&big_key)).is_none(), + "data entry exceeding max_data_entry_size must not be cached" + ); + + let small_key = range_cache_key("seg0/_0.parquet", 4096, 5120); + tiered.put(&small_key, Bytes::from(vec![0xCD; 1024])); // 1KB <= 2KB → cached + assert!( + block_on(tiered.data_cache().get(&small_key)).is_some(), + "data entry within the limit must be cached" + ); +} + +/// Size bounds update dynamically — raising the limit admits a previously +/// rejected entry on the next put. +#[test] +fn tiered_cache_size_bound_update_takes_effect_immediately() { + let (tiered, _data_dir, _meta_dir) = tiered_test_cache(); + + let key = range_cache_key("seg0/_0.parquet", 0, 4096); + + // Tight bound → rejected. + tiered.update_max_data_entry_size(1024); + tiered.put(&key, Bytes::from(vec![0x11; 4096])); + assert!( + block_on(tiered.get(&key)).is_none(), + "4KB rejected under 1KB bound" + ); + + // Raise the bound → same entry now admitted. + tiered.update_max_data_entry_size(8192); + tiered.put(&key, Bytes::from(vec![0x22; 4096])); + assert_eq!( + block_on(tiered.get(&key)).map(|b| b.len()), + Some(4096), + "4KB admitted after raising bound to 8KB" + ); +} + +/// clear_sync() empties both tiers. This is the production entry point +/// (FFM `foyer_clear_cache` → `clear_sync`); the async `clear()` trait method +/// internally delegates to it. +#[test] +fn tiered_cache_clear_empties_both_tiers() { + let (tiered, _data_dir, _meta_dir) = tiered_test_cache(); + let path = "seg0/_0.parquet"; + + tiered.put_metadata( + &range_cache_key(path, 9_000_000, 10_000_000), + Bytes::from_static(b"meta"), + ); + tiered.put( + &range_cache_key(path, 0, 1_000_000), + Bytes::from_static(b"data"), + ); + + tiered.clear_sync(); + + assert!( + block_on( + tiered + .metadata_cache() + .get(&range_cache_key(path, 9_000_000, 10_000_000)) + ) + .is_none(), + "metadata tier must be empty after clear_sync()" + ); + assert!( + block_on( + tiered + .data_cache() + .get(&range_cache_key(path, 0, 1_000_000)) + ) + .is_none(), + "data tier must be empty after clear_sync()" + ); +} + +/// A single-tier FoyerCache (metadata_cache_ratio=0) inherits the +/// `BlockCache::put_metadata` default, which routes to put() — so warmup's +/// put_metadata still lands in the one cache. Dispatched via `&dyn BlockCache` +/// to exercise the trait default explicitly. +#[test] +fn foyer_cache_put_metadata_default_routes_to_put() { + let (cache, _dir) = test_cache(); + let dyn_cache: &dyn crate::traits::BlockCache = &cache; + + let key = range_cache_key("seg0/_0.parquet", 0, 64); + dyn_cache.put_metadata(&key, Bytes::from_static(b"footer")); + + assert_eq!( + block_on(dyn_cache.get(&key)).as_deref(), + Some(b"footer".as_slice()), + "put_metadata default must route to the single cache and be retrievable via get()" + ); } diff --git a/sandbox/plugins/block-cache-foyer/src/main/rust/src/tiered_block_cache.rs b/sandbox/plugins/block-cache-foyer/src/main/rust/src/tiered_block_cache.rs new file mode 100644 index 0000000000000..c3291996dcce6 --- /dev/null +++ b/sandbox/plugins/block-cache-foyer/src/main/rust/src/tiered_block_cache.rs @@ -0,0 +1,191 @@ +/* + * 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. + */ + +//! [`TieredBlockCache`] — routes entries between a data cache and a metadata cache. +//! +//! The data cache is a large SSD-backed [`FoyerCache`] with default eviction (RejectAll +//! reinsertion). The metadata cache is a smaller SSD-backed [`FoyerCache`] configured +//! with AdmitAll reinsertion so that metadata entries are never evicted by the disk +//! reclaimer — they are always reinserted. +//! +//! ## Routing +//! +//! - `get()`: tries metadata cache first, then data cache. No marking needed. +//! - `put()`: always goes to data cache (normal query-time caching). +//! - `put_metadata()`: explicit warmup call — goes to metadata cache only. +//! +//! ## Restart +//! +//! No persistence needed for routing state. Foyer recovers metadata SSD blocks +//! via `RecoverMode::Quiet`. After restart, `get()` probes metadata cache first +//! and finds the recovered entries — zero S3 calls for metadata. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use bytes::Bytes; + +use crate::foyer::foyer_cache::FoyerCache; +use crate::range_cache::CacheKey; +use crate::traits::BlockCache; + +/// Default max metadata entry size: 8MB. +/// Covers up to ~1000-column schemas with page indexes. +const DEFAULT_MAX_METADATA_ENTRY_SIZE: u64 = 8 * 1024 * 1024; + +/// Default max data entry size: 32MB. +/// Covers individual column chunks for most schemas. Skips full-RG fetches. +const DEFAULT_MAX_DATA_ENTRY_SIZE: u64 = 32 * 1024 * 1024; + +/// A two-tier block cache: metadata cache + data cache on separate SSDs. +/// +/// ## Lookup order +/// +/// `get()` always probes metadata cache first (small, fast), then data cache. +/// +/// ## Write routing +/// +/// - `put()` → data cache if entry ≤ max_data_entry_size, else skip. +/// - `put_metadata()` → metadata cache if entry ≤ max_metadata_entry_size, else skip. +/// +/// Entries exceeding their respective limits are not cached (returned to caller +/// without caching). This bounds memory usage for large entries. +pub struct TieredBlockCache { + data_cache: Arc, + metadata_cache: Arc, + /// Max entry size for metadata cache. Entries larger than this are not cached. + max_metadata_entry_size: AtomicU64, + /// Max entry size for data cache. Entries larger than this are not cached. + max_data_entry_size: AtomicU64, +} + +impl TieredBlockCache { + pub fn new(data_cache: Arc, metadata_cache: Arc) -> Self { + native_bridge_common::log_info!( + "[tiered-block-cache] created: data_disk={}B, metadata_disk={}B, \ + max_metadata_entry={}B, max_data_entry={}B", + data_cache.disk_bytes, + metadata_cache.disk_bytes, + DEFAULT_MAX_METADATA_ENTRY_SIZE, + DEFAULT_MAX_DATA_ENTRY_SIZE + ); + Self { + data_cache, + metadata_cache, + max_metadata_entry_size: AtomicU64::new(DEFAULT_MAX_METADATA_ENTRY_SIZE), + max_data_entry_size: AtomicU64::new(DEFAULT_MAX_DATA_ENTRY_SIZE), + } + } + + /// Put bytes into the metadata cache. Called during shard warmup only. + /// + /// Entries exceeding max_metadata_entry_size are skipped (not cached). + /// Metadata cache uses LRU eviction on a separate SSD. + pub fn put_metadata(&self, key: &CacheKey, data: Bytes) { + let limit = self.max_metadata_entry_size.load(Ordering::Relaxed); + if data.len() as u64 > limit { + return; + } + self.metadata_cache.put(key, data); + } + + /// Update max metadata entry size dynamically. Takes effect immediately. + pub fn update_max_metadata_entry_size(&self, size: u64) { + self.max_metadata_entry_size.store(size, Ordering::Relaxed); + native_bridge_common::log_info!( + "[tiered-block-cache] max_metadata_entry_size updated to {}B", + size + ); + } + + /// Update max data entry size dynamically. Takes effect immediately. + pub fn update_max_data_entry_size(&self, size: u64) { + self.max_data_entry_size.store(size, Ordering::Relaxed); + native_bridge_common::log_info!( + "[tiered-block-cache] max_data_entry_size updated to {}B", + size + ); + } + + /// Current max data entry size (used by TieredObjectStore for get_opts threshold). + pub fn max_data_entry_size(&self) -> u64 { + self.max_data_entry_size.load(Ordering::Relaxed) + } + + /// Wait for both caches' flushers to drain. After this, all entries are on + /// SSD and findable via get(). Used in tests and warmup to ensure durability. + pub async fn wait_for_flush(&self) { + self.metadata_cache.wait_for_flush().await; + self.data_cache.wait_for_flush().await; + } + + /// Access the underlying data cache (e.g. for stats). + pub fn data_cache(&self) -> &FoyerCache { + &self.data_cache + } + + /// Access the underlying metadata cache (e.g. for stats). + pub fn metadata_cache(&self) -> &FoyerCache { + &self.metadata_cache + } + + /// Clear all entries synchronously. + pub(crate) fn clear_sync(&self) { + self.data_cache.clear_sync(); + self.metadata_cache.clear_sync(); + native_bridge_common::log_info!("[tiered-block-cache] clear_sync completed"); + } +} + +impl BlockCache for TieredBlockCache { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn get<'a>( + &'a self, + key: &'a CacheKey, + ) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async move { + // Metadata cache first — small SSD, fast probe, never evicts. + // On warm restart, Foyer recovers these from disk — instant hit. + if let Some(bytes) = self.metadata_cache.get(key).await { + return Some(bytes); + } + // Fall through to data cache. + self.data_cache.get(key).await + }) + } + + fn put(&self, key: &CacheKey, data: Bytes) { + // Data cache put — entries exceeding max_data_entry_size are skipped. + let limit = self.max_data_entry_size.load(Ordering::Relaxed); + if data.len() as u64 > limit { + return; + } + self.data_cache.put(key, data); + } + + fn put_metadata(&self, key: &CacheKey, data: Bytes) { + // Delegate to the inherent method which applies the size bound. + TieredBlockCache::put_metadata(self, key, data); + } + + fn evict_prefix(&self, prefix: &str) { + self.data_cache.evict_prefix(prefix); + self.metadata_cache.evict_prefix(prefix); + } + + fn clear(&self) -> std::pin::Pin + Send + '_>> { + Box::pin(async move { + self.data_cache.clear_sync(); + self.metadata_cache.clear_sync(); + native_bridge_common::log_info!("[tiered-block-cache] cleared"); + }) + } +} diff --git a/sandbox/plugins/block-cache-foyer/src/main/rust/src/traits.rs b/sandbox/plugins/block-cache-foyer/src/main/rust/src/traits.rs index 4d6f32e27ff33..cc7d7e51123cf 100644 --- a/sandbox/plugins/block-cache-foyer/src/main/rust/src/traits.rs +++ b/sandbox/plugins/block-cache-foyer/src/main/rust/src/traits.rs @@ -8,8 +8,8 @@ //! [`BlockCache`] trait — the abstraction for disk caching with typed keys. -use bytes::Bytes; use crate::range_cache::CacheKey; +use bytes::Bytes; /// A disk block cache. /// @@ -28,12 +28,27 @@ use crate::range_cache::CacheKey; pub trait BlockCache: Send + Sync + std::any::Any { /// Look up a cached entry. Returns `Some(Bytes)` on hit, `None` on miss. fn as_any(&self) -> &dyn std::any::Any; - fn get<'a>(&'a self, key: &'a CacheKey) - -> std::pin::Pin> + Send + 'a>>; + fn get<'a>( + &'a self, + key: &'a CacheKey, + ) -> std::pin::Pin> + Send + 'a>>; - /// Insert bytes under the given key. + /// Insert bytes under the given key (data cache — evictable by LRU). fn put(&self, key: &CacheKey, data: Bytes); + /// Insert bytes into the metadata cache (never evicted by LRU). + /// + /// For caches without a separate metadata tier (e.g., `FoyerCache` used standalone), + /// this falls back to `put()` — which is correct since the single-tier cache does + /// not distinguish metadata from data. `TieredBlockCache` overrides this to route + /// metadata to its dedicated non-evictable metadata cache. + /// + /// Called by the warmup path to ensure metadata bytes are stored in the durable + /// (non-evictable) tier, surviving LRU pressure from data scan workloads. + fn put_metadata(&self, key: &CacheKey, data: Bytes) { + self.put(key, data); + } + /// Evict all entries whose key starts with `prefix`. A no-op if nothing matches. /// /// For range entries: pass the file path — evicts all byte-range keys for that file. diff --git a/sandbox/plugins/block-cache-foyer/src/test/java/org/opensearch/blockcache/foyer/BlockCacheFoyerPluginTests.java b/sandbox/plugins/block-cache-foyer/src/test/java/org/opensearch/blockcache/foyer/BlockCacheFoyerPluginTests.java index 82a5aaff4c13b..5e057ef036aaa 100644 --- a/sandbox/plugins/block-cache-foyer/src/test/java/org/opensearch/blockcache/foyer/BlockCacheFoyerPluginTests.java +++ b/sandbox/plugins/block-cache-foyer/src/test/java/org/opensearch/blockcache/foyer/BlockCacheFoyerPluginTests.java @@ -167,15 +167,15 @@ public void testSetReservedCapacityBytesDoesNotThrow() { public void testGetSettingsRegistersAllSettings() { BlockCacheFoyerPlugin plugin = new BlockCacheFoyerPlugin(Settings.EMPTY); List> settings = plugin.getSettings(); - // DATA_TO_CACHE_RATIO_SETTING removed — ratio now read from cluster.filecache.remote_data_ratio - // KEY_INDEX_PERSIST_INTERVAL_SETTING added for independent periodic key_index persistence - assertEquals(8, settings.size()); + assertEquals(10, settings.size()); assertTrue(settings.contains(FoyerBlockCacheSettings.CACHE_SIZE_SETTING)); assertTrue(settings.contains(FoyerBlockCacheSettings.BLOCK_SIZE_SETTING)); assertTrue(settings.contains(FoyerBlockCacheSettings.IO_ENGINE_SETTING)); assertTrue(settings.contains(FoyerBlockCacheSettings.KEY_INDEX_SWEEP_INTERVAL_SETTING)); assertTrue(settings.contains(FoyerBlockCacheSettings.KEY_INDEX_SWEEP_THRESHOLD_SETTING)); assertTrue(settings.contains(FoyerBlockCacheSettings.KEY_INDEX_PERSIST_INTERVAL_SETTING)); + assertTrue(settings.contains(FoyerBlockCacheSettings.METADATA_CACHE_RATIO_SETTING)); + assertTrue(settings.contains(FoyerBlockCacheSettings.METADATA_BLOCK_SIZE_SETTING)); } public void testGetSettingsNoNulls() { diff --git a/sandbox/plugins/block-cache-foyer/src/test/java/org/opensearch/blockcache/foyer/FoyerAggregatedStatsTests.java b/sandbox/plugins/block-cache-foyer/src/test/java/org/opensearch/blockcache/foyer/FoyerAggregatedStatsTests.java index b5f60d31b4b2f..b49f941742020 100644 --- a/sandbox/plugins/block-cache-foyer/src/test/java/org/opensearch/blockcache/foyer/FoyerAggregatedStatsTests.java +++ b/sandbox/plugins/block-cache-foyer/src/test/java/org/opensearch/blockcache/foyer/FoyerAggregatedStatsTests.java @@ -279,6 +279,68 @@ public void testPositiveActiveInBytesPassesThrough() { assertEquals(8192L, FoyerAggregatedStats.snapshot(raw, 0L).blockLevelStats().activeInBytes()); } + // ── Tiered stats (snapshotTiered) ────────────────────────────────────────── + + public void testSnapshotTieredNonNull() { + assertNotNull(FoyerAggregatedStats.snapshotTiered(new long[20], 100L, 10L)); + } + + public void testSnapshotTieredIsTiered() { + assertTrue(FoyerAggregatedStats.snapshotTiered(new long[20], 100L, 10L).isTiered()); + } + + public void testSnapshotNonTieredIsNotTiered() { + assertFalse(FoyerAggregatedStats.snapshot(new long[20], 100L).isTiered()); + } + + public void testSnapshotTieredOverallMergesCounters() { + // data: 10 hits, 100 hit_bytes | metadata: 5 hits, 50 hit_bytes + long[] raw = buf(10, 100, 0, 0, 0, 0, 0, 0, 0, 0, 5, 50, 0, 0, 0, 0, 0, 0, 0, 0); + FoyerAggregatedStats s = FoyerAggregatedStats.snapshotTiered(raw, 1000L, 200L); + assertEquals(15L, s.overallStats().hits()); + assertEquals(150L, s.overallStats().hitBytes()); + } + + public void testSnapshotTieredDataCacheStats() { + long[] raw = buf(10, 100, 3, 300, 1, 50, 500, 0, 0, 0, 5, 50, 2, 200, 0, 0, 100, 0, 0, 0); + FoyerAggregatedStats s = FoyerAggregatedStats.snapshotTiered(raw, 1000L, 200L); + assertEquals(10L, s.dataCacheStats().hits()); + assertEquals(100L, s.dataCacheStats().hitBytes()); + assertEquals(3L, s.dataCacheStats().misses()); + assertEquals(500L, s.dataCacheStats().diskBytesUsed()); + assertEquals(1000L, s.dataCacheStats().totalBytes()); + } + + public void testSnapshotTieredMetadataCacheStats() { + long[] raw = buf(10, 100, 3, 300, 1, 50, 500, 0, 0, 0, 5, 50, 2, 200, 0, 0, 100, 0, 0, 0); + FoyerAggregatedStats s = FoyerAggregatedStats.snapshotTiered(raw, 1000L, 200L); + assertEquals(5L, s.metadataCacheStats().hits()); + assertEquals(50L, s.metadataCacheStats().hitBytes()); + assertEquals(2L, s.metadataCacheStats().misses()); + assertEquals(100L, s.metadataCacheStats().diskBytesUsed()); + assertEquals(200L, s.metadataCacheStats().totalBytes()); + } + + public void testSnapshotTieredCapacityBytes() { + FoyerAggregatedStats s = FoyerAggregatedStats.snapshotTiered(new long[20], 380_000L, 20_000L); + assertEquals(380_000L, s.dataCapacityBytes()); + assertEquals(20_000L, s.metadataCapacityBytes()); + assertEquals(400_000L, s.overallStats().totalBytes()); + } + + public void testSnapshotTieredOverallUsedBytesIsSumOfBoth() { + long[] raw = buf(0, 0, 0, 0, 0, 0, 800, 0, 0, 0, 0, 0, 0, 0, 0, 0, 50, 0, 0, 0); + FoyerAggregatedStats s = FoyerAggregatedStats.snapshotTiered(raw, 1000L, 100L); + assertEquals(850L, s.overallStats().diskBytesUsed()); + } + + public void testSnapshotTieredOverallEvictionsIsSumOfBoth() { + long[] raw = buf(0, 0, 0, 0, 7, 700, 0, 0, 0, 0, 0, 0, 0, 0, 3, 300, 0, 0, 0, 0); + FoyerAggregatedStats s = FoyerAggregatedStats.snapshotTiered(raw, 1000L, 100L); + assertEquals(10L, s.overallStats().evictions()); + assertEquals(1000L, s.overallStats().evictionBytes()); + } + // ── All-zeros ───────────────────────────────────────────────────────────── public void testAllZeroBuffer() { @@ -337,4 +399,71 @@ public void testCompleteProjectionWithActiveInBytes() { assertEquals(150L, blockLevel.removedBytes()); assertEquals(20L, blockLevel.activeInBytes()); } + + // ── Post-refactor: per-tier accessors return their sections directly ───── + + /** + * After the refactor that stores {@code dataCacheStats} and {@code metadataCacheStats} + * directly (no subtraction-from-overall), each accessor must return exactly what the + * native side wrote into its respective FFM section. Independent values per field per + * tier guard against any latent regression that re-introduces subtraction-based math. + */ + public void testTieredPerTierAccessorsReturnSectionsDirectly() { + // Section 0 (data): hits=10, hitBytes=100, misses=3, missBytes=300, evictions=1, + // evictionBytes=50, used=500, removed=5, removedBytes=50, active=0 + // Section 1 (meta): hits=20, hitBytes=200, misses=4, missBytes=400, evictions=2, + // evictionBytes=80, used=100, removed=11, removedBytes=110, active=0 + long[] raw = buf(10, 100, 3, 300, 1, 50, 500, 5, 50, 0, 20, 200, 4, 400, 2, 80, 100, 11, 110, 0); + FoyerAggregatedStats s = FoyerAggregatedStats.snapshotTiered(raw, 1000L, 200L); + + assertTrue("snapshotTiered must be flagged tiered", s.isTiered()); + assertNotNull("dataCacheStats must be non-null in tiered mode", s.dataCacheStats()); + assertNotNull("metadataCacheStats must be non-null in tiered mode", s.metadataCacheStats()); + + // Data tier — section 0 verbatim. + BlockCacheStats data = s.dataCacheStats(); + assertEquals(10L, data.hits()); + assertEquals(100L, data.hitBytes()); + assertEquals(3L, data.misses()); + assertEquals(300L, data.missBytes()); + assertEquals(1L, data.evictions()); + assertEquals(50L, data.evictionBytes()); + assertEquals(5L, data.removed()); + assertEquals(50L, data.removedBytes()); + assertEquals(500L, data.diskBytesUsed()); + assertEquals(1000L, data.totalBytes()); + + // Metadata tier — section 1 verbatim, NOT (overall - data). + BlockCacheStats meta = s.metadataCacheStats(); + assertEquals(20L, meta.hits()); + assertEquals(200L, meta.hitBytes()); + assertEquals(4L, meta.misses()); + assertEquals(400L, meta.missBytes()); + assertEquals(2L, meta.evictions()); + assertEquals(80L, meta.evictionBytes()); + assertEquals(11L, meta.removed()); + assertEquals(110L, meta.removedBytes()); + assertEquals(100L, meta.diskBytesUsed()); + assertEquals(200L, meta.totalBytes()); + + // Overall is the eager merge of both tiers. + BlockCacheStats overall = s.overallStats(); + assertEquals(30L, overall.hits()); + assertEquals(7L, overall.misses()); + assertEquals(16L, overall.removed()); + assertEquals(160L, overall.removedBytes()); + assertEquals(600L, overall.diskBytesUsed()); + assertEquals(1200L, overall.totalBytes()); + } + + /** + * In single-cache mode the per-tier accessors return {@code null}. Callers asking for + * per-tier breakdown are expected to gate on {@link FoyerAggregatedStats#isTiered()}. + */ + public void testSinglePerTierAccessorsReturnNull() { + FoyerAggregatedStats s = FoyerAggregatedStats.snapshot(new long[20], 1024L); + assertFalse("snapshot must not be flagged tiered", s.isTiered()); + assertNull("dataCacheStats must be null in single-cache mode", s.dataCacheStats()); + assertNull("metadataCacheStats must be null in single-cache mode", s.metadataCacheStats()); + } } diff --git a/sandbox/plugins/block-cache-foyer/src/test/java/org/opensearch/blockcache/foyer/FoyerBlockCacheSettingsTests.java b/sandbox/plugins/block-cache-foyer/src/test/java/org/opensearch/blockcache/foyer/FoyerBlockCacheSettingsTests.java index 16ef3a0ca5d92..941ed6def2105 100644 --- a/sandbox/plugins/block-cache-foyer/src/test/java/org/opensearch/blockcache/foyer/FoyerBlockCacheSettingsTests.java +++ b/sandbox/plugins/block-cache-foyer/src/test/java/org/opensearch/blockcache/foyer/FoyerBlockCacheSettingsTests.java @@ -81,7 +81,14 @@ public void testCacheSizeRejectsGarbage() { // ── IO_ENGINE_SETTING ───────────────────────────────────────────────────── public void testIoEngineDefault() { - assertEquals("auto", FoyerBlockCacheSettings.IO_ENGINE_SETTING.get(Settings.EMPTY)); + assertEquals("psync", FoyerBlockCacheSettings.IO_ENGINE_SETTING.get(Settings.EMPTY)); + } + + public void testIoEngineAcceptsAuto() { + assertEquals( + "auto", + FoyerBlockCacheSettings.IO_ENGINE_SETTING.get(Settings.builder().put("block_cache.foyer.io_engine", "auto").build()) + ); } public void testIoEngineAcceptsPsync() { @@ -197,4 +204,116 @@ public void testSweepThresholdRejectsAboveOne() { ); } + // ── METADATA_CACHE_RATIO_SETTING ───────────────────────────────────────── + + public void testMetadataCacheRatioDefault() { + assertEquals("5%", FoyerBlockCacheSettings.METADATA_CACHE_RATIO_SETTING.get(Settings.EMPTY)); + } + + public void testMetadataCacheRatioAcceptsPercentage() { + assertEquals( + "10%", + FoyerBlockCacheSettings.METADATA_CACHE_RATIO_SETTING.get( + Settings.builder().put("block_cache.foyer.metadata_cache_ratio", "10%").build() + ) + ); + } + + public void testMetadataCacheRatioAcceptsRatioForm() { + assertEquals( + "0.05", + FoyerBlockCacheSettings.METADATA_CACHE_RATIO_SETTING.get( + Settings.builder().put("block_cache.foyer.metadata_cache_ratio", "0.05").build() + ) + ); + } + + public void testMetadataCacheRatioAcceptsZero() { + assertEquals( + "0%", + FoyerBlockCacheSettings.METADATA_CACHE_RATIO_SETTING.get( + Settings.builder().put("block_cache.foyer.metadata_cache_ratio", "0%").build() + ) + ); + } + + public void testMetadataCacheRatioAcceptsNearMaximum() { + assertEquals( + "49%", + FoyerBlockCacheSettings.METADATA_CACHE_RATIO_SETTING.get( + Settings.builder().put("block_cache.foyer.metadata_cache_ratio", "49%").build() + ) + ); + } + + public void testMetadataCacheRatioRejectsFiftyPercent() { + IllegalArgumentException ex = expectThrows( + IllegalArgumentException.class, + () -> FoyerBlockCacheSettings.METADATA_CACHE_RATIO_SETTING.get( + Settings.builder().put("block_cache.foyer.metadata_cache_ratio", "50%").build() + ) + ); + assertTrue(ex.getMessage().contains("block_cache.foyer.metadata_cache_ratio")); + } + + public void testMetadataCacheRatioRejectsNegative() { + expectThrows( + IllegalArgumentException.class, + () -> FoyerBlockCacheSettings.METADATA_CACHE_RATIO_SETTING.get( + Settings.builder().put("block_cache.foyer.metadata_cache_ratio", "-1%").build() + ) + ); + } + + public void testMetadataCacheRatioRejectsGarbage() { + expectThrows( + IllegalArgumentException.class, + () -> FoyerBlockCacheSettings.METADATA_CACHE_RATIO_SETTING.get( + Settings.builder().put("block_cache.foyer.metadata_cache_ratio", "notanumber").build() + ) + ); + } + + // ── METADATA_BLOCK_SIZE_SETTING ────────────────────────────────────────── + + public void testMetadataBlockSizeDefault() { + assertEquals(new ByteSizeValue(8, ByteSizeUnit.MB), FoyerBlockCacheSettings.METADATA_BLOCK_SIZE_SETTING.get(Settings.EMPTY)); + } + + public void testMetadataBlockSizeAcceptsMinimum() { + assertEquals( + new ByteSizeValue(1, ByteSizeUnit.MB), + FoyerBlockCacheSettings.METADATA_BLOCK_SIZE_SETTING.get( + Settings.builder().put("block_cache.foyer.metadata_block_size", "1mb").build() + ) + ); + } + + public void testMetadataBlockSizeAcceptsMaximum() { + assertEquals( + new ByteSizeValue(128, ByteSizeUnit.MB), + FoyerBlockCacheSettings.METADATA_BLOCK_SIZE_SETTING.get( + Settings.builder().put("block_cache.foyer.metadata_block_size", "128mb").build() + ) + ); + } + + public void testMetadataBlockSizeRejectsBelowMinimum() { + expectThrows( + IllegalArgumentException.class, + () -> FoyerBlockCacheSettings.METADATA_BLOCK_SIZE_SETTING.get( + Settings.builder().put("block_cache.foyer.metadata_block_size", "512kb").build() + ) + ); + } + + public void testMetadataBlockSizeRejectsAboveMaximum() { + expectThrows( + IllegalArgumentException.class, + () -> FoyerBlockCacheSettings.METADATA_BLOCK_SIZE_SETTING.get( + Settings.builder().put("block_cache.foyer.metadata_block_size", "129mb").build() + ) + ); + } + } diff --git a/sandbox/plugins/composite-engine/build.gradle b/sandbox/plugins/composite-engine/build.gradle index 06c3e157e2647..143a65571fc95 100644 --- a/sandbox/plugins/composite-engine/build.gradle +++ b/sandbox/plugins/composite-engine/build.gradle @@ -59,6 +59,7 @@ dependencies { internalClusterTestImplementation project(':sandbox:plugins:parquet-data-format') internalClusterTestImplementation project(':sandbox:plugins:analytics-backend-lucene') internalClusterTestImplementation project(':sandbox:plugins:analytics-backend-datafusion') + internalClusterTestImplementation project(':sandbox:plugins:analytics-engine') internalClusterTestImplementation project(':sandbox:libs:analytics-framework') internalClusterTestImplementation project(':sandbox:plugins:native-repository-fs') // Netty4 HTTP transport for ITs that use the low-level RestClient (e.g., DataFormatStatsIT, *DataFormatApi*IT). diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/AbstractCompositeEngineIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/AbstractCompositeEngineIT.java index 10eb0d747ca1d..3d09a08cb8b2b 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/AbstractCompositeEngineIT.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/AbstractCompositeEngineIT.java @@ -14,6 +14,7 @@ import org.opensearch.action.admin.indices.flush.FlushResponse; import org.opensearch.action.admin.indices.stats.IndicesStatsResponse; import org.opensearch.action.admin.indices.stats.ShardStats; +import org.opensearch.action.index.IndexResponse; import org.opensearch.arrow.allocator.ArrowBasePlugin; import org.opensearch.be.datafusion.DataFusionPlugin; import org.opensearch.be.lucene.LucenePlugin; @@ -38,8 +39,10 @@ import org.opensearch.transport.Netty4ModulePlugin; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.List; import java.util.function.Function; import java.util.regex.Pattern; @@ -126,18 +129,14 @@ protected void createCompositeIndex(String indexName, boolean withLuceneSecondar ensureGreen(indexName); } - protected void indexDocs(String indexName, int count, int startId) { + protected List indexDocs(String indexName, int count, int startId) { + List ids = new ArrayList<>(); for (int i = startId; i < startId + count; i++) { - assertEquals( - RestStatus.CREATED, - client().prepareIndex() - .setIndex(indexName) - .setId(String.valueOf(i)) - .setSource("name", "doc_" + i, "value", i) - .get() - .status() - ); + IndexResponse indexResponse = client().prepareIndex().setIndex(indexName).setSource("name", "doc_" + i, "value", i).get(); + assertEquals(RestStatus.CREATED, indexResponse.status()); + ids.add(indexResponse.getId()); } + return ids; } protected void refreshIndex(String indexName) { diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/BaseStatsIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/BaseStatsIT.java index e64450a22599f..a83b02e9f4055 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/BaseStatsIT.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/BaseStatsIT.java @@ -35,6 +35,14 @@ protected Map luceneIndexStats(String index, String... queryPara return StatsITHelpers.luceneIndexStats(getRestClient(), index, queryParams); } + protected Map compositeIndexStats(String index, String... queryParams) throws IOException { + return StatsITHelpers.compositeIndexStats(getRestClient(), index, queryParams); + } + + protected Map compositeNodeStats(String nodeIdOrEmpty, String... queryParams) throws IOException { + return StatsITHelpers.compositeNodeStats(getRestClient(), nodeIdOrEmpty, queryParams); + } + protected Map parquetNodeStats(String nodeIdOrEmpty, String... queryParams) throws IOException { return StatsITHelpers.parquetNodeStats(getRestClient(), nodeIdOrEmpty, queryParams); } diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeCommitDeletionIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeCommitDeletionIT.java index e54c10e506b0f..241ff73b66fac 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeCommitDeletionIT.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeCommitDeletionIT.java @@ -90,10 +90,7 @@ private void createCompositeIndex() { private void indexDocs(int count, int startId) { for (int i = startId; i < startId + count; i++) { - assertEquals( - RestStatus.CREATED, - client().prepareIndex().setIndex(INDEX_NAME).setId(String.valueOf(i)).setSource("field", i).get().status() - ); + assertEquals(RestStatus.CREATED, client().prepareIndex().setIndex(INDEX_NAME).setSource("field", i).get().status()); } } diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeDynamicMappingIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeDynamicMappingIT.java index d59fab0091b09..eaea52d0e898e 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeDynamicMappingIT.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeDynamicMappingIT.java @@ -169,11 +169,11 @@ public void testConflictingDynamicMappings() { ensureGreen(indexName); // First doc: foo inferred as long - client().prepareIndex(indexName).setId("1").setSource("foo", 3).get(); + client().prepareIndex(indexName).setSource("foo", 3).get(); // Second doc: foo as text — should fail try { - client().prepareIndex(indexName).setId("2").setSource("foo", "bar").get(); + client().prepareIndex(indexName).setSource("foo", "bar").get(); fail("Indexing request should have failed!"); } catch (Exception e) { assertTrue( @@ -294,16 +294,10 @@ private void runConcurrentIndexing(String indexName, int numThreads) throws Thro indexThreads[i] = new Thread(() -> { try { startLatch.await(); - IndexResponse respA = client().prepareIndex(indexName) - .setId("a_" + threadId) - .setSource("fieldA_" + threadId, "valueA_" + threadId) - .get(); + IndexResponse respA = client().prepareIndex(indexName).setSource("fieldA_" + threadId, "valueA_" + threadId).get(); assert respA.status() == RestStatus.CREATED : "index a_" + threadId + " failed: " + respA.status(); Thread.sleep(1000); - IndexResponse respB = client().prepareIndex(indexName) - .setId("b_" + threadId) - .setSource("fieldB_" + threadId, "valueB_" + threadId) - .get(); + IndexResponse respB = client().prepareIndex(indexName).setSource("fieldB_" + threadId, "valueB_" + threadId).get(); assert respB.status() == RestStatus.CREATED : "index b_" + threadId + " failed: " + respB.status(); Thread.sleep(1000); client().admin().indices().prepareRefresh(indexName).get(); diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeEngineLuceneFailureCheckpointIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeEngineLuceneFailureCheckpointIT.java index c1d5bc45ca330..3042cc84c1522 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeEngineLuceneFailureCheckpointIT.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeEngineLuceneFailureCheckpointIT.java @@ -154,7 +154,7 @@ public void testCheckpointsConvergeAfterLuceneDirectoryFault() throws Exception } private BulkItemResponse indexOne(String id, String value) { - BulkResponse bulk = client().prepareBulk().add(client().prepareIndex(INDEX_NAME).setId(id).setSource("field", value)).get(); + BulkResponse bulk = client().prepareBulk().add(client().prepareIndex(INDEX_NAME).setSource("field", value)).get(); assertEquals(1, bulk.getItems().length); return bulk.getItems()[0]; } diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeEngineLuceneOversizedTermIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeEngineLuceneOversizedTermIT.java index c49ffe6bd772c..3f23ae3403ef8 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeEngineLuceneOversizedTermIT.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeEngineLuceneOversizedTermIT.java @@ -147,7 +147,7 @@ private void runOversizedTermScenario(boolean withIndexSort) throws Exception { createIndex(withIndexSort); // Doc 1: succeeds. - IndexResponse ok1 = client().prepareIndex(INDEX_NAME).setId("1").setSource("field", "value-1", "sort_key", 10L).get(); + IndexResponse ok1 = client().prepareIndex(INDEX_NAME).setSource("field", "value-1", "sort_key", 10L).get(); assertEquals(RestStatus.CREATED, ok1.status()); // Doc 2: a single oversized keyword token. Lucene's IndexingChain throws @@ -155,7 +155,7 @@ private void runOversizedTermScenario(boolean withIndexSort) throws Exception { // remains open, the bulk item surfaces as a per-doc failure. String oversized = "x".repeat(OVERSIZED_TERM_CHARS); BulkResponse bulk = client().prepareBulk() - .add(client().prepareIndex(INDEX_NAME).setId("2").setSource("field", oversized, "sort_key", 5L)) + .add(client().prepareIndex(INDEX_NAME).setSource("field", oversized, "sort_key", 5L)) .get(); assertEquals(1, bulk.getItems().length); BulkItemResponse failed = bulk.getItems()[0]; @@ -177,7 +177,7 @@ private void runOversizedTermScenario(boolean withIndexSort) throws Exception { // Doc 3: succeeds on the same primary — proves the engine and writer pool recovered. // sort_key=1 is less than doc 1's sort_key=10, so the index-sort variant exercises // a non-trivial RowIdMapping permutation rather than the identity case. - IndexResponse ok3 = client().prepareIndex(INDEX_NAME).setId("3").setSource("field", "value-3", "sort_key", 1L).get(); + IndexResponse ok3 = client().prepareIndex(INDEX_NAME).setSource("field", "value-3", "sort_key", 1L).get(); assertEquals(RestStatus.CREATED, ok3.status()); client().admin().indices().prepareFlush(INDEX_NAME).get(); @@ -226,10 +226,7 @@ public void testLuceneFailureReplacesWriterAndClosesOldOne() throws Exception { createIndex(/* withIndexSort= */ false); // Doc 1 mints writer-1 in the pool. - assertEquals( - RestStatus.CREATED, - client().prepareIndex(INDEX_NAME).setId("1").setSource("field", "v1", "sort_key", 10L).get().status() - ); + assertEquals(RestStatus.CREATED, client().prepareIndex(INDEX_NAME).setSource("field", "v1", "sort_key", 10L).get().status()); DataFormatAwareEngine engine = CompositeEngineHelper.getEngine(clusterService(), internalCluster(), INDEX_NAME); Writer writerBefore = singleWriter(engine); @@ -237,15 +234,12 @@ public void testLuceneFailureReplacesWriterAndClosesOldOne() throws Exception { // Doc 2: oversized term → Lucene IAE → RETIRED_FLUSHABLE → DFAE retires + closes. String oversized = "x".repeat(OVERSIZED_TERM_CHARS); BulkResponse bulk = client().prepareBulk() - .add(client().prepareIndex(INDEX_NAME).setId("2").setSource("field", oversized, "sort_key", 5L)) + .add(client().prepareIndex(INDEX_NAME).setSource("field", oversized, "sort_key", 5L)) .get(); assertTrue("oversized term doc must fail", bulk.getItems()[0].isFailed()); // Doc 3 forces the pool to mint a fresh writer (the old one is gone). - assertEquals( - RestStatus.CREATED, - client().prepareIndex(INDEX_NAME).setId("3").setSource("field", "v3", "sort_key", 1L).get().status() - ); + assertEquals(RestStatus.CREATED, client().prepareIndex(INDEX_NAME).setSource("field", "v3", "sort_key", 1L).get().status()); Writer writerAfter = singleWriter(engine); assertNotSame("Lucene retirement must replace the writer instance in the pool", writerBefore, writerAfter); diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeEngineParquetFailureCheckpointIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeEngineParquetFailureCheckpointIT.java index 48f59fcaf8157..86b379c4d60ff 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeEngineParquetFailureCheckpointIT.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeEngineParquetFailureCheckpointIT.java @@ -136,7 +136,7 @@ public void testParquetAddDocFailureSelfRollsAndCheckpointsConverge() throws Exc ensureGreen(INDEX_NAME); // Doc 1 with the original mapping (just f1) — succeeds. - IndexResponse ok1 = client().prepareIndex(INDEX_NAME).setId("1").setSource("f1", "value-1").get(); + IndexResponse ok1 = client().prepareIndex(INDEX_NAME).setSource("f1", "value-1").get(); assertEquals(RestStatus.CREATED, ok1.status()); // Suppress the parquet writer's mapping-version updates. Then add a new field f2 @@ -148,7 +148,7 @@ public void testParquetAddDocFailureSelfRollsAndCheckpointsConverge() throws Exc FailableParquetDataFormatPlugin.armSchemaSuppression(); client().admin().indices().preparePutMapping(INDEX_NAME).setSource("f2", "type=keyword").get(); - BulkResponse bulk = client().prepareBulk().add(client().prepareIndex(INDEX_NAME).setId("2").setSource("f2", "value-2")).get(); + BulkResponse bulk = client().prepareBulk().add(client().prepareIndex(INDEX_NAME).setSource("f2", "value-2")).get(); assertEquals(1, bulk.getItems().length); BulkItemResponse failed = bulk.getItems()[0]; assertTrue("doc 2 must surface as a per-item failure: " + failed.getFailureMessage(), failed.isFailed()); @@ -166,7 +166,7 @@ public void testParquetAddDocFailureSelfRollsAndCheckpointsConverge() throws Exc // Doc 3 with f1 (already in every schema version) succeeds on the fresh writer — // proves the engine survives a per-doc parquet failure and continues serving writes. - IndexResponse ok3 = client().prepareIndex(INDEX_NAME).setId("3").setSource("f1", "value-3").get(); + IndexResponse ok3 = client().prepareIndex(INDEX_NAME).setSource("f1", "value-3").get(); assertEquals(RestStatus.CREATED, ok3.status()); client().admin().indices().prepareFlush(INDEX_NAME).get(); @@ -205,7 +205,7 @@ public void testParquetSelfRollbackKeepsSameWriterInPool() throws Exception { ensureGreen(INDEX_NAME); // Doc 1 mints writer-1 in the pool. - assertEquals(RestStatus.CREATED, client().prepareIndex(INDEX_NAME).setId("1").setSource("f1", "v1").get().status()); + assertEquals(RestStatus.CREATED, client().prepareIndex(INDEX_NAME).setSource("f1", "v1").get().status()); DataFormatAwareEngine engine = CompositeEngineHelper.getEngine(clusterService(), internalCluster(), INDEX_NAME); Writer writerBefore = singleWriter(engine); @@ -214,12 +214,12 @@ public void testParquetSelfRollbackKeepsSameWriterInPool() throws Exception { // → composite rollback → state restored to ACTIVE (no flush, no retire). FailableParquetDataFormatPlugin.armSchemaSuppression(); client().admin().indices().preparePutMapping(INDEX_NAME).setSource("f2", "type=keyword").get(); - BulkResponse bulk = client().prepareBulk().add(client().prepareIndex(INDEX_NAME).setId("fail").setSource("f2", "x")).get(); + BulkResponse bulk = client().prepareBulk().add(client().prepareIndex(INDEX_NAME).setSource("f2", "x")).get(); assertTrue("must surface per-item failure", bulk.getItems()[0].isFailed()); FailableParquetDataFormatPlugin.clearFailure(); // Doc 3 (f1, schema v1-compatible) must land on the same writer instance. - assertEquals(RestStatus.CREATED, client().prepareIndex(INDEX_NAME).setId("3").setSource("f1", "v3").get().status()); + assertEquals(RestStatus.CREATED, client().prepareIndex(INDEX_NAME).setSource("f1", "v3").get().status()); Writer writerAfter = singleWriter(engine); assertSame("parquet self-rollback must keep the same writer instance in the pool", writerBefore, writerAfter); diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeEngineParquetFailureDurabilityIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeEngineParquetFailureDurabilityIT.java index 9ffb162d64368..43b2c7ca4f0e2 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeEngineParquetFailureDurabilityIT.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeEngineParquetFailureDurabilityIT.java @@ -126,7 +126,7 @@ private void runDurabilityScenario(boolean refreshBeforeRestart, boolean flushBe // Phase 1: index N docs with f1 into writer-1. final int initialDocs = 5; for (int i = 0; i < initialDocs; i++) { - IndexResponse r = client().prepareIndex(INDEX_NAME).setId("d" + i).setSource("f1", "v" + i).get(); + IndexResponse r = client().prepareIndex(INDEX_NAME).setSource("f1", "v" + i).get(); assertEquals(RestStatus.CREATED, r.status()); } @@ -137,7 +137,7 @@ private void runDurabilityScenario(boolean refreshBeforeRestart, boolean flushBe FailableParquetDataFormatPlugin.armSchemaSuppression(); client().admin().indices().preparePutMapping(INDEX_NAME).setSource("f2", "type=keyword").get(); - BulkResponse bulk = client().prepareBulk().add(client().prepareIndex(INDEX_NAME).setId("fail").setSource("f2", "value-fail")).get(); + BulkResponse bulk = client().prepareBulk().add(client().prepareIndex(INDEX_NAME).setSource("f2", "value-fail")).get(); assertEquals(1, bulk.getItems().length); BulkItemResponse failed = bulk.getItems()[0]; assertTrue("oversized-schema doc must surface as a per-item failure: " + failed.getFailureMessage(), failed.isFailed()); @@ -147,7 +147,7 @@ private void runDurabilityScenario(boolean refreshBeforeRestart, boolean flushBe FailableParquetDataFormatPlugin.clearFailure(); final int postRetirementDocs = 3; for (int i = 0; i < postRetirementDocs; i++) { - IndexResponse r = client().prepareIndex(INDEX_NAME).setId("p" + i).setSource("f1", "p" + i).get(); + IndexResponse r = client().prepareIndex(INDEX_NAME).setSource("f1", "p" + i).get(); assertEquals(RestStatus.CREATED, r.status()); } diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeEnginePrimaryFailoverCheckpointIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeEnginePrimaryFailoverCheckpointIT.java index 33f1ac621fe7f..7cf5537f17008 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeEnginePrimaryFailoverCheckpointIT.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeEnginePrimaryFailoverCheckpointIT.java @@ -152,7 +152,7 @@ private void waitForPrimaryTerm(long expectedTerm, TimeValue timeout) throws Exc } private BulkItemResponse indexOne(String id, String value) { - BulkResponse bulk = client().prepareBulk().add(client().prepareIndex(INDEX_NAME).setId(id).setSource("field", value)).get(); + BulkResponse bulk = client().prepareBulk().add(client().prepareIndex(INDEX_NAME).setSource("field", value)).get(); assertEquals(1, bulk.getItems().length); return bulk.getItems()[0]; } diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeFieldCapabilityIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeFieldCapabilityIT.java index 275b475c9bb2c..549d37d58151a 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeFieldCapabilityIT.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeFieldCapabilityIT.java @@ -312,7 +312,6 @@ public void testAllSupportedFieldTypesIndexAndVerify() throws Exception { assertEquals( RestStatus.CREATED, client().prepareIndex(indexName) - .setId("1") .setSource( "f_long", 100L, @@ -355,7 +354,6 @@ public void testAllSupportedFieldTypesIndexAndVerify() throws Exception { assertEquals( RestStatus.CREATED, client().prepareIndex(indexName) - .setId("2") .setSource( "f_long", 200L, @@ -378,7 +376,6 @@ public void testAllSupportedFieldTypesIndexAndVerify() throws Exception { assertEquals( RestStatus.CREATED, client().prepareIndex(indexName) - .setId("3") .setSource("f_long", 300L, "f_keyword", "gamma", "f_text", "third with dynamic", "dynamic_new_field", "dynamic_value") .get() .status() @@ -445,28 +442,17 @@ public void testKeywordIgnoreAboveIndexAndVerify() throws Exception { ensureGreen(indexName); // Index doc with value within ignore_above - assertEquals( - RestStatus.CREATED, - client().prepareIndex(indexName).setId("1").setSource("field", "short", "id_field", "doc1").get().status() - ); + assertEquals(RestStatus.CREATED, client().prepareIndex(indexName).setSource("field", "short", "id_field", "doc1").get().status()); // Index doc with value exceeding ignore_above (value ignored, sourceKeywordFieldType stores raw) assertEquals( RestStatus.CREATED, - client().prepareIndex(indexName) - .setId("2") - .setSource("field", "this_exceeds_ignore_above_limit", "id_field", "doc2") - .get() - .status() + client().prepareIndex(indexName).setSource("field", "this_exceeds_ignore_above_limit", "id_field", "doc2").get().status() ); // Dynamic mapping: add a new field assertEquals( RestStatus.CREATED, - client().prepareIndex(indexName) - .setId("3") - .setSource("field", "dynamic", "id_field", "doc3", "new_dynamic_field", "hello") - .get() - .status() + client().prepareIndex(indexName).setSource("field", "dynamic", "id_field", "doc3", "new_dynamic_field", "hello").get().status() ); client().admin().indices().prepareRefresh(indexName).get(); @@ -540,14 +526,8 @@ public void testKeywordNormalizerStoresSourceSeparately() throws Exception { ensureGreen(indexName); // Index doc — value is within any ignore_above but normalizer transforms it - assertEquals( - RestStatus.CREATED, - client().prepareIndex(indexName).setId("1").setSource("field", "Hello", "id_field", "doc1").get().status() - ); - assertEquals( - RestStatus.CREATED, - client().prepareIndex(indexName).setId("2").setSource("field", "WORLD", "id_field", "doc2").get().status() - ); + assertEquals(RestStatus.CREATED, client().prepareIndex(indexName).setSource("field", "Hello", "id_field", "doc1").get().status()); + assertEquals(RestStatus.CREATED, client().prepareIndex(indexName).setSource("field", "WORLD", "id_field", "doc2").get().status()); client().admin().indices().prepareRefresh(indexName).get(); client().admin().indices().prepareFlush(indexName).get(); @@ -597,14 +577,8 @@ public void testKeywordNormalizerStoresSourceSeparatelyWhenDynamicFieldIsPresent ensureGreen(indexName); // Index doc — value is within any ignore_above but normalizer transforms it - assertEquals( - RestStatus.CREATED, - client().prepareIndex(indexName).setId("1").setSource("field", "Hello", "id_field1", "doc1").get().status() - ); - assertEquals( - RestStatus.CREATED, - client().prepareIndex(indexName).setId("2").setSource("field", "WORLD", "id_field2", "doc2").get().status() - ); + assertEquals(RestStatus.CREATED, client().prepareIndex(indexName).setSource("field", "Hello", "id_field1", "doc1").get().status()); + assertEquals(RestStatus.CREATED, client().prepareIndex(indexName).setSource("field", "WORLD", "id_field2", "doc2").get().status()); client().admin().indices().prepareRefresh(indexName).get(); client().admin().indices().prepareFlush(indexName).get(); @@ -658,17 +632,14 @@ public void testMultiFieldIndexAndVerify() throws Exception { // Index initial docs assertEquals( RestStatus.CREATED, - client().prepareIndex(indexName).setId("1").setSource("content", "hello world", "tag", "greeting").get().status() - ); - assertEquals( - RestStatus.CREATED, - client().prepareIndex(indexName).setId("2").setSource("content", "foo bar", "tag", "test").get().status() + client().prepareIndex(indexName).setSource("content", "hello world", "tag", "greeting").get().status() ); + assertEquals(RestStatus.CREATED, client().prepareIndex(indexName).setSource("content", "foo bar", "tag", "test").get().status()); // Dynamic mapping: add new field assertEquals( RestStatus.CREATED, - client().prepareIndex(indexName).setId("3").setSource("content", "dynamic doc", "tag", "new", "extra_field", 42).get().status() + client().prepareIndex(indexName).setSource("content", "dynamic doc", "tag", "new", "extra_field", 42).get().status() ); client().admin().indices().prepareRefresh(indexName).get(); @@ -714,7 +685,6 @@ public void testMultipleFieldTypesIndexAndVerify() throws Exception { assertEquals( RestStatus.CREATED, client().prepareIndex(indexName) - .setId("1") .setSource("f_long", 100L, "f_keyword", "alpha", "f_date", "2024-01-01", "f_text", "some text") .get() .status() @@ -722,7 +692,6 @@ public void testMultipleFieldTypesIndexAndVerify() throws Exception { assertEquals( RestStatus.CREATED, client().prepareIndex(indexName) - .setId("2") .setSource("f_long", 200L, "f_keyword", "beta", "f_date", "2024-06-15", "f_text", "more text") .get() .status() @@ -732,7 +701,6 @@ public void testMultipleFieldTypesIndexAndVerify() throws Exception { assertEquals( RestStatus.CREATED, client().prepareIndex(indexName) - .setId("3") .setSource("f_long", 300L, "f_keyword", "gamma", "f_date", "2024-12-31", "f_text", "final", "dyn_bool", true) .get() .status() @@ -774,7 +742,7 @@ public void testMappingUpdateFailsWithUnsupportedField() throws Exception { assertIndexCreationSucceeds(indexName, "field", "type=keyword"); // Index a document successfully - assertEquals(RestStatus.CREATED, client().prepareIndex(indexName).setId("1").setSource("field", "value1").get().status()); + assertEquals(RestStatus.CREATED, client().prepareIndex(indexName).setSource("field", "value1").get().status()); client().admin().indices().prepareRefresh(indexName).get(); @@ -817,13 +785,10 @@ public void testDynamicMappingNumericField() throws Exception { ensureGreen(indexName); // Index initial doc - assertEquals(RestStatus.CREATED, client().prepareIndex(indexName).setId("1").setSource("name", "first").get().status()); + assertEquals(RestStatus.CREATED, client().prepareIndex(indexName).setSource("name", "first").get().status()); // Index doc with dynamic numeric field - assertEquals( - RestStatus.CREATED, - client().prepareIndex(indexName).setId("2").setSource("name", "second", "dynamic_num", 42).get().status() - ); + assertEquals(RestStatus.CREATED, client().prepareIndex(indexName).setSource("name", "second", "dynamic_num", 42).get().status()); client().admin().indices().prepareRefresh(indexName).get(); client().admin().indices().prepareFlush(indexName).get(); diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeFieldConfigRandomizedIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeFieldConfigRandomizedIT.java index 3a661122674d2..8f9012e4b8815 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeFieldConfigRandomizedIT.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeFieldConfigRandomizedIT.java @@ -136,7 +136,6 @@ public void testRandomizedFieldConfigIndexingAndVerification() throws Exception assertEquals( RestStatus.CREATED, client().prepareIndex(indexName) - .setId(String.valueOf(i)) .setSource( "f_keyword_1", "kw1_val_" + i, @@ -243,7 +242,6 @@ public void testColumnarOnlyFieldsIndexAndVerify() throws Exception { assertEquals( RestStatus.CREATED, client().prepareIndex(indexName) - .setId(String.valueOf(i)) .setSource("col_keyword", "value_" + i, "col_long", (long) (i * 10), "col_double", i * 2.5) .get() .status() diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeForceMergeIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeForceMergeIT.java index 788202f040ca6..0b094ae2b5027 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeForceMergeIT.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeForceMergeIT.java @@ -97,6 +97,7 @@ public void testForceMergeReducesSegmentsToOne() throws Exception { assertEquals(docsPerCycle * cycles, getTotalDocCount()); } + @AwaitsFix(bugUrl = "https://github.com/opensearch-project/OpenSearch/issues/22307") public void testForceMergeWithMaxNumSegmentsGreaterThanOne() throws Exception { client().admin() .indices() diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeIndexingSlowLogIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeIndexingSlowLogIT.java index 31c8ac24bb03c..b6f530bc1fca9 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeIndexingSlowLogIT.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeIndexingSlowLogIT.java @@ -36,13 +36,13 @@ public void testSlowLogEmitsAllFieldsForDFAEngine() throws Exception { appender.addExpectation(expectSeen("contains index name", ".*slowlog-test-idx.*")); appender.addExpectation(expectSeen("contains took", ".*took\\[.*\\].*")); appender.addExpectation(expectSeen("contains took_millis", ".*took_millis\\[\\d+\\].*")); - appender.addExpectation(expectSeen("contains document id", ".*id\\[1\\].*")); + appender.addExpectation(expectSeen("contains document id", ".*id\\[.*\\].*")); appender.addExpectation(expectSeen("contains routing field", ".*routing\\[.*\\].*")); appender.addExpectation(expectSeen("contains document source", ".*source\\[.*test_value.*\\].*")); assertEquals( RestStatus.CREATED, - client().prepareIndex().setIndex(INDEX_NAME).setId("1").setSource("name", "test_value", "value", 42).get().status() + client().prepareIndex().setIndex(INDEX_NAME).setSource("name", "test_value", "value", 42).get().status() ); appender.assertAllExpectationsMatched(); @@ -59,7 +59,7 @@ public void testSlowLogDoesNotFireWhenBelowThreshold() throws Exception { assertEquals( RestStatus.CREATED, - client().prepareIndex().setIndex(INDEX_NAME).setId("1").setSource("name", "fast_doc", "value", 1).get().status() + client().prepareIndex().setIndex(INDEX_NAME).setSource("name", "fast_doc", "value", 1).get().status() ); appender.assertAllExpectationsMatched(); diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeMergeIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeMergeIT.java index 579a98dd096f9..9f444bb77ec70 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeMergeIT.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeMergeIT.java @@ -266,10 +266,7 @@ public void testMergeOnRefresh() throws Exception { startLatch.await(); for (int i = 0; i < docsPerThread; i++) { int docId = threadId * docsPerThread + i; - client().prepareIndex(INDEX_NAME) - .setId(String.valueOf(docId)) - .setSource("name", "doc_" + docId, "age", randomIntBetween(0, 100)) - .get(); + client().prepareIndex(INDEX_NAME).setSource("name", "doc_" + docId, "age", randomIntBetween(0, 100)).get(); } } catch (Exception e) { error.compareAndSet(null, e); diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeMergePoolExhaustionIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeMergePoolExhaustionIT.java new file mode 100644 index 0000000000000..b201536cf9e34 --- /dev/null +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeMergePoolExhaustionIT.java @@ -0,0 +1,311 @@ +/* + * 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.composite; + +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; + +import org.opensearch.action.admin.indices.forcemerge.ForceMergeResponse; +import org.opensearch.action.admin.indices.refresh.RefreshResponse; +import org.opensearch.action.index.IndexResponse; +import org.opensearch.arrow.allocator.ArrowBasePlugin; +import org.opensearch.arrow.allocator.ArrowNativeAllocator; +import org.opensearch.be.datafusion.DataFusionPlugin; +import org.opensearch.be.lucene.LucenePlugin; +import org.opensearch.cluster.metadata.IndexMetadata; +import org.opensearch.common.concurrent.GatedCloseable; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.FeatureFlags; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.index.IndexService; +import org.opensearch.index.engine.exec.coord.CatalogSnapshot; +import org.opensearch.index.engine.exec.coord.DataformatAwareCatalogSnapshot; +import org.opensearch.index.shard.IndexShard; +import org.opensearch.indices.IndicesService; +import org.opensearch.parquet.ParquetDataFormatPlugin; +import org.opensearch.plugins.Plugin; +import org.opensearch.test.OpenSearchIntegTestCase; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; +import java.util.Locale; +import java.util.function.Function; + +/** + * Reproduces the merge-pool starvation problem for the composite data format + * (parquet primary + lucene secondary). + * + *

    The native merge reserves its row-id mapping ({@code total_rows * 8} bytes) from the + * merge pool with {@code PoolBehavior::Reject}. When the merge pool limit is smaller than + * the mapping, {@code try_grow} fails and the merge is rejected. Because a rejected merge + * never raises the pool's observed utilization, the rebalancer never grows the pool, so the + * merge can never succeed on retry — a force merge fails permanently. + * + *

    This test pins the merge pool to a 1–2 byte window and disables the rebalancer so the + * failure is deterministic. It is the baseline that the planned soft-limit / over-commit fix + * must turn green. + */ +// The Tokio IO runtime worker thread used by the Rust merge is a process-lifetime singleton +// that persists after tests complete (mirrors CompositeMergeIT). +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +@OpenSearchIntegTestCase.ClusterScope(scope = OpenSearchIntegTestCase.Scope.TEST, numDataNodes = 1) +public class CompositeMergePoolExhaustionIT extends OpenSearchIntegTestCase { + + private static final String INDEX_NAME = "test-composite-merge-pool-exhaustion"; + + // ══════════════════════════════════════════════════════════════════════ + // Framework lifecycle & configuration + // ══════════════════════════════════════════════════════════════════════ + + @Override + protected Collection> nodePlugins() { + return Arrays.asList( + ArrowBasePlugin.class, + ParquetDataFormatPlugin.class, + CompositeDataFormatPlugin.class, + LucenePlugin.class, + DataFusionPlugin.class + ); + } + + @Override + protected Settings nodeSettings(int nodeOrdinal) { + return Settings.builder() + .put(super.nodeSettings(nodeOrdinal)) + .put(FeatureFlags.PLUGGABLE_DATAFORMAT_EXPERIMENTAL_FLAG, true) + // Starve the merge pool: 1-byte floor, 2-byte ceiling. Any real merge's row-id + // mapping reservation (total_rows * 8 bytes) exceeds this and is rejected. + .put("parquet.native.pool.merge.min", 1L) + .put("parquet.native.pool.merge.max", 2L) + // Disable the rebalancer so the merge pool cannot be grown back. A rejected merge + // never registers utilization, so this reproduces the starvation deterministically. + .put("native.allocator.rebalancer.enabled", false) + .build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // Test + // ══════════════════════════════════════════════════════════════════════ + + /** + * With the merge pool pinned to 2 bytes, a force merge of multiple segments must fail + * because the native merge's row-id mapping reservation is rejected. + */ + public void testForceMergeFailsWhenMergePoolExhausted() throws Exception { + client().admin() + .indices() + .prepareCreate(INDEX_NAME) + .setSettings(compositeSettings()) + .setMapping("name", "type=keyword", "age", "type=integer") + .get(); + ensureGreen(INDEX_NAME); + + // Two ingest + refresh cycles → at least two committed segments to merge. + indexAndRefresh(10); + indexAndRefresh(10); + + assertTrue("expected multiple segments before force merge", getSegmentCount() > 1); + + // Over-commit is enabled by default; disable it explicitly so the exhausted merge pool + // rejects the reservation, reproducing the force-merge-fails-forever behavior. + client().admin() + .cluster() + .prepareUpdateSettings() + .setPersistentSettings(Settings.builder().put("native.allocator.overcommit.enabled", false)) + .get(); + + // Force merge to a single segment. The 2-byte merge pool rejects the native merge's + // row-id mapping reservation, so the merge fails. The failure surfaces either as a + // failed shard in the response or as a thrown exception — accept both. + boolean forceMergeFailed; + String failureDetail; + try { + ForceMergeResponse response = client().admin() + .indices() + .prepareForceMerge(INDEX_NAME) + .setMaxNumSegments(1) + .setFlush(false) + .get(); + forceMergeFailed = response.getFailedShards() >= 1 && response.getSuccessfulShards() == 0; + failureDetail = "failedShards=" + + response.getFailedShards() + + " successfulShards=" + + response.getSuccessfulShards() + + " shardFailures=" + + Arrays.toString(response.getShardFailures()); + // When surfaced via the response, the reason should reference the exhausted merge pool. + if (forceMergeFailed) { + boolean mentionsPool = Arrays.stream(response.getShardFailures()) + .anyMatch(f -> f.reason() != null && f.reason().toLowerCase(Locale.ROOT).contains("merge pool")); + assertTrue("force merge failure should reference the merge pool; detail=" + failureDetail, mentionsPool); + } + } catch (Exception e) { + forceMergeFailed = true; + failureDetail = e.toString(); + } + + assertTrue("force merge should fail when the merge pool is exhausted; detail=" + failureDetail, forceMergeFailed); + + // The segments must not have been consolidated to a single segment. + assertTrue("segments should not have been merged when the merge pool is exhausted", getSegmentCount() > 1); + } + + /** + * With the same 2-byte merge pool but the over-commit fallback enabled and node native-memory + * pressure below the threshold, the force merge must now succeed: the rejected row-id mapping + * reservation is granted an over-commit by the allocator, and the merge consolidates to a single + * segment. + */ + public void testForceMergeSucceedsWithOverCommitEnabled() throws Exception { + client().admin() + .indices() + .prepareCreate(INDEX_NAME) + .setSettings(compositeSettings()) + .setMapping("name", "type=keyword", "age", "type=integer") + .get(); + ensureGreen(INDEX_NAME); + + indexAndRefresh(10); + indexAndRefresh(10); + assertTrue("expected multiple segments before force merge", getSegmentCount() > 1); + + // Over-commit is enabled by default. Raise the pressure threshold so the node's (low) + // native-memory pressure is comfortably below it; the pressure signal itself is the real + // injected node signal, so no per-instance stubbing is needed. With the fallback active, the + // merge's row-id mapping reservation is granted an over-commit instead of being rejected. + client().admin() + .cluster() + .prepareUpdateSettings() + .setPersistentSettings(Settings.builder().put("native.allocator.overcommit.pressure_threshold", 100.0)) + .get(); + + ForceMergeResponse response = client().admin().indices().prepareForceMerge(INDEX_NAME).setMaxNumSegments(1).setFlush(true).get(); + + assertEquals( + "no shard should fail the merge once over-commit is enabled; shardFailures=" + Arrays.toString(response.getShardFailures()), + 0, + response.getFailedShards() + ); + assertTrue("at least one shard should have merged", response.getSuccessfulShards() >= 1); + + // Segments consolidated to a single segment. + assertBusy(() -> assertEquals("segments should be merged to one", 1, getSegmentCount()), 30, java.util.concurrent.TimeUnit.SECONDS); + } + + /** + * Over-commit is enabled, but the node native-memory pressure is manually driven above the + * configured threshold. The allocator's decider must refuse the over-commit, so the force merge + * still fails on the exhausted merge pool. + */ + public void testForceMergeRejectedWhenNativePressureAboveThreshold() throws Exception { + client().admin() + .indices() + .prepareCreate(INDEX_NAME) + .setSettings(compositeSettings()) + .setMapping("name", "type=keyword", "age", "type=integer") + .get(); + ensureGreen(INDEX_NAME); + + indexAndRefresh(10); + indexAndRefresh(10); + assertTrue("expected multiple segments before force merge", getSegmentCount() > 1); + + // Over-commit is enabled by default; set a threshold below the pressure we will inject. + client().admin() + .cluster() + .prepareUpdateSettings() + .setPersistentSettings(Settings.builder().put("native.allocator.overcommit.pressure_threshold", 80.0)) + .get(); + + // Drive native-memory pressure above the threshold on every node's allocator. The pressure + // signal has no cluster setting, and the shared-JVM decider may resolve to any node's + // allocator, so inject it everywhere. + for (String node : internalCluster().getNodeNames()) { + internalCluster().getInstance(ArrowNativeAllocator.class, node).setNativeMemoryPressureSupplier(() -> 95.0); + } + + boolean forceMergeFailed; + String failureDetail; + try { + ForceMergeResponse response = client().admin() + .indices() + .prepareForceMerge(INDEX_NAME) + .setMaxNumSegments(1) + .setFlush(false) + .get(); + forceMergeFailed = response.getFailedShards() >= 1 && response.getSuccessfulShards() == 0; + failureDetail = Arrays.toString(response.getShardFailures()); + } catch (Exception e) { + forceMergeFailed = true; + failureDetail = e.toString(); + } + + assertTrue( + "force merge should fail when native pressure exceeds the over-commit threshold; detail=" + failureDetail, + forceMergeFailed + ); + assertTrue("segments should not have been merged when over-commit is refused", getSegmentCount() > 1); + } + + // ══════════════════════════════════════════════════════════════════════ + // Helpers: index settings & indexing + // ══════════════════════════════════════════════════════════════════════ + + private Settings compositeSettings() { + return Settings.builder() + .put(IndexMetadata.SETTING_NUMBER_OF_SHARDS, 1) + .put(IndexMetadata.SETTING_NUMBER_OF_REPLICAS, 0) + .put("index.refresh_interval", "-1") + .put("index.pluggable.dataformat.enabled", true) + .put("index.pluggable.dataformat", "composite") + .put("index.composite.primary_data_format", "parquet") + .putList("index.composite.secondary_data_formats", "lucene") + .build(); + } + + /** + * Indexes {@code numDocs} documents sequentially, then refreshes once. Sequential indexing + * keeps each refresh cycle to a single writer (one committed segment), and avoids triggering + * merge-on-refresh so the segment count is deterministic. + */ + private void indexAndRefresh(int numDocs) { + for (int i = 0; i < numDocs; i++) { + IndexResponse response = client().prepareIndex() + .setIndex(INDEX_NAME) + .setSource("name", randomAlphaOfLength(10), "age", randomIntBetween(1, 1000)) + .get(); + assertEquals(RestStatus.CREATED, response.status()); + } + RefreshResponse refreshResponse = client().admin().indices().prepareRefresh(INDEX_NAME).get(); + assertEquals(RestStatus.OK, refreshResponse.getStatus()); + } + + // ══════════════════════════════════════════════════════════════════════ + // Helpers: shard/catalog accessors + // ══════════════════════════════════════════════════════════════════════ + + private int getSegmentCount() throws IOException { + return getCatalogSnapshot().getSegments().size(); + } + + private IndexShard getPrimaryShard() { + String nodeId = getClusterState().routingTable().index(INDEX_NAME).shard(0).primaryShard().currentNodeId(); + String nodeName = getClusterState().nodes().get(nodeId).getName(); + IndicesService indicesService = internalCluster().getInstance(IndicesService.class, nodeName); + IndexService indexService = indicesService.indexServiceSafe(resolveIndex(INDEX_NAME)); + return indexService.getShard(0); + } + + private DataformatAwareCatalogSnapshot getCatalogSnapshot() throws IOException { + IndexShard shard = getPrimaryShard(); + try (GatedCloseable snapshot = shard.getCatalogSnapshot()) { + return DataformatAwareCatalogSnapshot.deserializeFromString(snapshot.get().serializeToString(), Function.identity()); + } + } +} diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeParquet3TierSettingsIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeParquet3TierSettingsIT.java index c030ef5e96191..36977cd36d6d2 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeParquet3TierSettingsIT.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeParquet3TierSettingsIT.java @@ -440,7 +440,6 @@ private void indexAllTypeDocs() { for (int i = 0; i < 5; i++) { client().prepareIndex() .setIndex(INDEX_NAME) - .setId(String.valueOf(i)) .setSource( "col_utf8", "val_" + i, diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeRelocationFailureIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeRelocationFailureIT.java index bd473fca32770..e9a7c9901bea6 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeRelocationFailureIT.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeRelocationFailureIT.java @@ -94,10 +94,7 @@ private void createCompositeIndex(int replicas) { private void indexDocs(int count, int startId) { for (int i = startId; i < startId + count; i++) { - assertEquals( - RestStatus.CREATED, - client().prepareIndex(INDEX_NAME).setId(String.valueOf(i)).setSource("name", "doc_" + i, "value", i).get().status() - ); + assertEquals(RestStatus.CREATED, client().prepareIndex(INDEX_NAME).setSource("name", "doc_" + i, "value", i).get().status()); } } @@ -134,7 +131,7 @@ public void testConcurrentIndexingDuringRelocation() throws Exception { int id = 10000; while (!stopIndexing.get()) { try { - client().prepareIndex(INDEX_NAME).setId("concurrent_" + id).setSource("name", "concurrent_" + id, "value", id).get(); + client().prepareIndex(INDEX_NAME).setSource("name", "concurrent_" + id, "value", id).get(); successCount.incrementAndGet(); id++; Thread.sleep(10); // Pace writes to avoid overwhelming during relocation diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeRemoteStoreFsyncRecoveryIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeRemoteStoreFsyncRecoveryIT.java index 3e6e5b765bca0..aac0ea4c7050f 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeRemoteStoreFsyncRecoveryIT.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeRemoteStoreFsyncRecoveryIT.java @@ -93,10 +93,7 @@ private Settings compositeIndexSettings(int replicas) { private void indexDocs(int count, int offset) { for (int i = 0; i < count; i++) { - client().prepareIndex(INDEX_NAME) - .setId(String.valueOf(offset + i)) - .setSource("name", "doc_" + (offset + i), "value", offset + i) - .get(); + client().prepareIndex(INDEX_NAME).setSource("name", "doc_" + (offset + i), "value", offset + i).get(); } } diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeStatsEndpointIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeStatsEndpointIT.java new file mode 100644 index 0000000000000..5916365c9289a --- /dev/null +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/CompositeStatsEndpointIT.java @@ -0,0 +1,116 @@ +/* + * 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.composite; + +import org.opensearch.test.OpenSearchIntegTestCase.ClusterScope; +import org.opensearch.test.OpenSearchIntegTestCase.Scope; + +import java.util.Map; + +/** + * Integration tests for the composite-engine per-format stats endpoint + * ({@code /_plugins/composite/...}) and the parquet {@code native_write_rejections} counter. + * + * @opensearch.experimental + */ +@ClusterScope(scope = Scope.SUITE, numDataNodes = 1) +public class CompositeStatsEndpointIT extends BaseStatsIT { + + /** Fresh composite index: endpoint responds and all counters are zero. */ + public void testCompositeStatsZeroOnFreshIndex() throws Exception { + String idx = "composite-zero-idx"; + createCompositeIndex(idx, true); + + Map c = compositeIndexStats(idx); + assertCounter("fresh refresh_total", c, "indices." + idx + ".refresh.refresh_total", 0L); + assertCounter("fresh refresh_merge_total", c, "indices." + idx + ".refresh.refresh_merge_total", 0L); + assertCounter("fresh refresh_merge_failures", c, "indices." + idx + ".refresh.refresh_merge_failures", 0L); + assertCounter("fresh merge_total", c, "indices." + idx + ".merge.merge_total", 0L); + assertCounter("fresh merge_failures", c, "indices." + idx + ".merge.merge_failures", 0L); + assertCounter("fresh write_total", c, "indices." + idx + ".write.write_total", 0L); + assertCounter("fresh write_primary_failures", c, "indices." + idx + ".write.write_primary_failures", 0L); + assertCounter("fresh write_secondary_failures", c, "indices." + idx + ".write.write_secondary_failures", 0L); + assertCounter("fresh mapping_update_executed_total", c, "indices." + idx + ".mapping.mapping_update_executed_total", 0L); + } + + /** After indexing + refresh, composite refresh counters increment and failures stay zero. */ + public void testCompositeRefreshAndMergeCountersIncrement() throws Exception { + String idx = "composite-refresh-idx"; + createCompositeIndex(idx, true); + indexDocs(idx, 100, 0); + refreshIndex(idx); + + Map c = compositeIndexStats(idx); + assertCounterAtLeast("refresh_total", c, "indices." + idx + ".refresh.refresh_total", 1L); + assertCounterAtLeast("refresh_time_millis", c, "indices." + idx + ".refresh.refresh_time_millis", 0L); + // write_total counts every indexed doc attempt; 100 docs were indexed. + assertCounterAtLeast("write_total", c, "indices." + idx + ".write.write_total", 100L); + // Happy path: no write or merge failures. + assertCounter("write_primary_failures", c, "indices." + idx + ".write.write_primary_failures", 0L); + assertCounter("write_secondary_failures", c, "indices." + idx + ".write.write_secondary_failures", 0L); + assertCounter("merge_failures", c, "indices." + idx + ".merge.merge_failures", 0L); + assertCounter("refresh_merge_failures", c, "indices." + idx + ".refresh.refresh_merge_failures", 0L); + + // refresh_merge_total is the merge-on-refresh subset of merge_total — it must never exceed it. + long refreshMerge = StatsITHelpers.getCounter(c, "indices." + idx + ".refresh.refresh_merge_total"); + long mergeTotal = StatsITHelpers.getCounter(c, "indices." + idx + ".merge.merge_total"); + assertTrue( + "refresh_merge_total (" + refreshMerge + ") must not exceed merge_total (" + mergeTotal + ")", + refreshMerge <= mergeTotal + ); + } + + /** The composite per-node endpoint aggregates and responds after indexing. */ + public void testCompositeNodeStatsEndpoint() throws Exception { + String idx = "composite-node-idx"; + createCompositeIndex(idx, true); + indexDocs(idx, 50, 0); + refreshIndex(idx); + + Map n = compositeNodeStats(""); + // A successful per-node response carries a "nodes" object (one entry per responding node). + assertTrue("per-node response must contain a 'nodes' object", n.get("nodes") instanceof Map); + assertFalse("per-node response 'nodes' object must not be empty", ((Map) n.get("nodes")).isEmpty()); + } + + /** Parquet native_write_rejections is wired and reads zero on the happy path. */ + public void testParquetNativeWriteRejectionsZeroOnHappyPath() throws Exception { + String idx = "parquet-rejection-idx"; + createCompositeIndex(idx, true); + indexDocs(idx, 100, 0); + refreshIndex(idx); + + Map p = parquetIndexStats(idx); + assertCounter("native_write_rejections zero", p, "indices." + idx + ".native_write.native_write_rejections", 0L); + } + + /** The parquet per-node endpoint exposes the live native_ingest_pool block (queue/active/rejected). */ + public void testParquetNodeStatsExposesIngestPool() throws Exception { + String idx = "parquet-ingest-pool-idx"; + createCompositeIndex(idx, true); + indexDocs(idx, 50, 0); + refreshIndex(idx); + + Map n = parquetNodeStats(""); + // Each responding node must carry a native_ingest_pool block with the pool fields. + Map nodes = (Map) n.get("nodes"); + assertNotNull("per-node response must contain 'nodes'", nodes); + assertFalse("'nodes' must not be empty", nodes.isEmpty()); + boolean sawPool = false; + for (Object node : nodes.values()) { + Object pool = ((Map) node).get("native_ingest_pool"); + if (pool instanceof Map) { + assertTrue("native_ingest_pool must report queue_depth", ((Map) pool).containsKey("queue_depth")); + assertTrue("native_ingest_pool must report rejected", ((Map) pool).containsKey("rejected")); + sawPool = true; + } + } + assertTrue("at least one node must expose native_ingest_pool", sawPool); + } +} diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareDFASnapshotBlockingIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareDFASnapshotBlockingIT.java new file mode 100644 index 0000000000000..f8ede58cdc8a0 --- /dev/null +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareDFASnapshotBlockingIT.java @@ -0,0 +1,319 @@ +/* + * 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.composite; + +import org.opensearch.action.admin.cluster.snapshots.create.CreateSnapshotResponse; +import org.opensearch.action.admin.cluster.snapshots.restore.RestoreSnapshotResponse; +import org.opensearch.action.admin.indices.delete.DeleteIndexRequest; +import org.opensearch.cluster.metadata.IndexMetadata; +import org.opensearch.common.settings.Settings; +import org.opensearch.core.common.unit.ByteSizeUnit; +import org.opensearch.core.common.unit.ByteSizeValue; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.index.IndexModule; +import org.opensearch.indices.RemoteStoreSettings; +import org.opensearch.indices.replication.common.ReplicationType; +import org.opensearch.node.Node; +import org.opensearch.repositories.blobstore.BlobStoreRepository; +import org.opensearch.snapshots.SnapshotInfo; +import org.opensearch.snapshots.SnapshotState; +import org.opensearch.transport.client.Client; +import org.junit.After; +import org.junit.Before; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.opensearch.test.hamcrest.OpenSearchAssertions.assertAcked; + +/** + * Integration tests covering the snapshot guardrails for pluggable-data-format-aware (DFA) indexes: + *

      + *
    • Block A ({@code SnapshotsService.createSnapshotV2}): warm-tiered DFA indexes are + * silently filtered out of V2 snapshots; the rest of the cluster is captured normally.
    • + *
    + * + * Together these guarantee: hot DFA + non-DFA flow normally through V2 snapshots; warm DFA never + * appears in any snapshot. + */ +public class DataFormatAwareDFASnapshotBlockingIT extends DataFormatAwareReadonlyEngineBaseIT { + + private static final String V2_REPO = "v2-block-test-repo"; + + private Path v2RepoPath; + + @Before + public void setupSnapshotRepos() throws java.io.IOException { + v2RepoPath = randomRepoPath().toAbsolutePath(); + Files.createDirectories(v2RepoPath); + } + + @After + public void tearDownFileCache() { + for (String nodeName : internalCluster().getNodeNames()) { + try { + Node node = internalCluster().getInstance(Node.class, nodeName); + var fc = node.fileCache(); + if (fc != null) { + fc.clear(); + } + } catch (Exception ignored) { + // Node may have already been stopped; skip it. + } + } + } + + @Override + protected Settings nodeSettings(int nodeOrdinal) { + return Settings.builder() + .put(super.nodeSettings(nodeOrdinal)) + .put(Node.NODE_SEARCH_CACHE_SIZE_SETTING.getKey(), new ByteSizeValue(2, ByteSizeUnit.GB).toString()) + .put(RemoteStoreSettings.CLUSTER_REMOTE_STORE_PINNED_TIMESTAMP_ENABLED.getKey(), true) + .build(); + } + + private Settings.Builder v2RepoSettings(Path location) { + return Settings.builder() + .put("location", location) + .put(BlobStoreRepository.REMOTE_STORE_INDEX_SHALLOW_COPY.getKey(), true) + .put(BlobStoreRepository.SHALLOW_SNAPSHOT_V2.getKey(), true); + } + + /** Create a hot DFA index with the given name, index docs, and flush. */ + private void createHotDFAIndex(String indexName, int docs) { + Settings hot = Settings.builder() + .put(remoteStoreIndexSettings(0, 1)) + .put(IndexMetadata.SETTING_NUMBER_OF_SHARDS, 1) + .put(IndexMetadata.SETTING_NUMBER_OF_REPLICAS, 0) + .put(IndexMetadata.SETTING_REPLICATION_TYPE, ReplicationType.SEGMENT) + .put("index.pluggable.dataformat.enabled", true) + .put("index.pluggable.dataformat", "composite") + .put("index.composite.primary_data_format", "parquet") + .putList("index.composite.secondary_data_formats", List.of("lucene")) + .build(); + client().admin().indices().prepareCreate(indexName).setSettings(hot).get(); + ensureGreen(indexName); + for (int i = 0; i < docs; i++) { + client().prepareIndex(indexName).setSource("n", (long) i).get(); + } + client().admin().indices().prepareFlush(indexName).setForce(true).get(); + } + + /** Create a hot DFA index, ingest docs, then tier to warm. */ + private void createWarmDFAIndex(String indexName, int docs) { + createHotDFAIndex(indexName, docs); + client().admin().indices().prepareClose(indexName).get(); + client().admin() + .indices() + .prepareUpdateSettings(indexName) + .setSettings(Settings.builder().put(IndexModule.IS_WARM_INDEX_SETTING.getKey(), true)) + .get(); + client().admin().indices().prepareOpen(indexName).get(); + ensureGreen(indexName); + } + + /** Create a plain non-DFA, remote-store-backed index. */ + private void createNonDFAIndex(String indexName, int docs) { + Settings nonDfa = Settings.builder() + .put(IndexMetadata.SETTING_NUMBER_OF_SHARDS, 1) + .put(IndexMetadata.SETTING_NUMBER_OF_REPLICAS, 0) + .put(IndexMetadata.SETTING_REPLICATION_TYPE, ReplicationType.SEGMENT) + .build(); + client().admin().indices().prepareCreate(indexName).setSettings(nonDfa).get(); + ensureGreen(indexName); + for (int i = 0; i < docs; i++) { + client().prepareIndex(indexName).setId(String.valueOf(i)).setSource("text", "n" + i).get(); + } + client().admin().indices().prepareFlush(indexName).setForce(true).get(); + client().admin().indices().prepareRefresh(indexName).get(); + } + + /** Create a writable-warm (non-DFA) index directly and ingest docs. */ + private void createWarmNonDFAIndex(String indexName, int docs) { + // Writable warm: the index is created warm from the start (OpenSearch does not support + // tiering a hot non-DFA index to warm). Remote store is enabled cluster-wide by the base. + Settings warmNonDfa = Settings.builder() + .put(IndexMetadata.SETTING_NUMBER_OF_SHARDS, 1) + .put(IndexMetadata.SETTING_NUMBER_OF_REPLICAS, 0) + .put(IndexMetadata.SETTING_REPLICATION_TYPE, ReplicationType.SEGMENT) + .put(IndexModule.IS_WARM_INDEX_SETTING.getKey(), true) + .build(); + client().admin().indices().prepareCreate(indexName).setSettings(warmNonDfa).get(); + ensureGreen(indexName); + for (int i = 0; i < docs; i++) { + client().prepareIndex(indexName).setId(String.valueOf(i)).setSource("text", "n" + i).get(); + } + client().admin().indices().prepareFlush(indexName).setForce(true).get(); + client().admin().indices().prepareRefresh(indexName).get(); + } + + /** + * Block A coverage — the headline V2 partial-filter test for a mixed cluster: + *
      + *
    • Take a V2 snapshot of a cluster with hot DFA + warm DFA + non-DFA indexes.
    • + *
    • Verify the snapshot manifest contains hot DFA + non-DFA, NOT warm DFA.
    • + *
    • Delete all indexes, restore all (*), and verify from cluster state that hot DFA + + * non-DFA come back while the filtered warm DFA index does not.
    • + *
    • Phase 3: explicitly request restore of the warm DFA index from the snapshot — fails + * because it's not in the snapshot manifest (Block A filtered it at create time).
    • + *
    + */ + public void testV2SnapshotMixedClusterFiltersWarmDFAFullLifecycle() throws Exception { + internalCluster().startClusterManagerOnlyNode(); + internalCluster().startDataAndWarmNodes(1); + + final String hotDFA = "hot-dfa-mix"; + final String warmDFA = "warm-dfa-mix"; + final String nonDFA = "non-dfa-mix"; + + createRepository(V2_REPO, "fs", v2RepoSettings(v2RepoPath)); + + createHotDFAIndex(hotDFA, 30); + createWarmDFAIndex(warmDFA, 20); + createNonDFAIndex(nonDFA, 15); + + // Phase 1: take V2 snapshot, verify warm DFA is filtered. + CreateSnapshotResponse snapResp = client().admin() + .cluster() + .prepareCreateSnapshot(V2_REPO, "v2-mixed-snap") + .setWaitForCompletion(true) + .get(); + SnapshotInfo info = snapResp.getSnapshotInfo(); + assertEquals(SnapshotState.SUCCESS, info.state()); + + List snapshotIndices = info.indices(); + assertTrue("snapshot must contain hot DFA index, got: " + snapshotIndices, snapshotIndices.contains(hotDFA)); + assertTrue("snapshot must contain non-DFA index, got: " + snapshotIndices, snapshotIndices.contains(nonDFA)); + assertFalse("warm DFA index must be filtered out, got: " + snapshotIndices, snapshotIndices.contains(warmDFA)); + + // Phase 2: delete every index, then restore all (*). Validate from cluster state that only + // the snapshotted indices (hot DFA + non-DFA) come back; the filtered warm DFA does not. + Client client = client(); + assertAcked(client.admin().indices().delete(new DeleteIndexRequest(hotDFA, nonDFA, warmDFA)).get()); + assertFalse(indexExists(hotDFA)); + assertFalse(indexExists(nonDFA)); + assertFalse(indexExists(warmDFA)); + + RestoreSnapshotResponse restoreResp = client.admin() + .cluster() + .prepareRestoreSnapshot(V2_REPO, "v2-mixed-snap") + .setIndices("*") + .setWaitForCompletion(true) + .get(); + assertEquals(RestStatus.OK, restoreResp.status()); + ensureGreen(hotDFA, nonDFA); + + // Validate the restored set from cluster state. + assertTrue("hot DFA must be restored", indexExists(hotDFA)); + assertTrue("non-DFA must be restored", indexExists(nonDFA)); + assertFalse("warm DFA must NOT be restored (filtered from snapshot)", indexExists(warmDFA)); + + // Phase 3: explicitly try to restore warm DFA from the snapshot → must fail (not in manifest). + Exception ex = expectThrows( + Exception.class, + () -> client.admin() + .cluster() + .prepareRestoreSnapshot(V2_REPO, "v2-mixed-snap") + .setWaitForCompletion(true) + .setIndices(warmDFA) + .get() + ); + // Standard "no indices in snapshot match" / "index does not exist in snapshot" type error. + assertNotNull("explicit restore of filtered warm DFA must fail", ex.getMessage()); + } + + /** + * Block A coverage — only-warm-DFA cluster. Snapshot succeeds with empty index list; explicit + * restore of the warm DFA index fails because it's not in the manifest. + */ + public void testV2SnapshotWhenClusterHasOnlyWarmDFA() throws Exception { + internalCluster().startClusterManagerOnlyNode(); + internalCluster().startDataAndWarmNodes(1); + + final String warmDFA = "only-warm-dfa"; + + createRepository(V2_REPO, "fs", v2RepoSettings(v2RepoPath)); + createWarmDFAIndex(warmDFA, 10); + + CreateSnapshotResponse snapResp = client().admin() + .cluster() + .prepareCreateSnapshot(V2_REPO, "v2-only-warm") + .setWaitForCompletion(true) + .get(); + SnapshotInfo info = snapResp.getSnapshotInfo(); + assertEquals(SnapshotState.SUCCESS, info.state()); + assertTrue("snapshot must be empty when cluster has only warm DFA, got: " + info.indices(), info.indices().isEmpty()); + + // Explicit restore of warm DFA → fails (not in snapshot). + Exception ex = expectThrows( + Exception.class, + () -> client().admin() + .cluster() + .prepareRestoreSnapshot(V2_REPO, "v2-only-warm") + .setWaitForCompletion(true) + .setIndices(warmDFA) + .get() + ); + assertNotNull("explicit restore of warm DFA must fail; snapshot has no such index", ex.getMessage()); + } + + /** + * Block A guard — warm tiering alone must NOT trigger exclusion; only the combination of + * warm tier + pluggable data format is filtered. This verifies that a writable-warm + * non-DFA index is captured by a V2 snapshot (present in the manifest with a + * successful shard), while a warm DFA index in the same cluster is still filtered out. + */ + public void testV2SnapshotIncludesWarmNonDFAIndex() throws Exception { + internalCluster().startClusterManagerOnlyNode(); + internalCluster().startDataAndWarmNodes(1); + + final String warmNonDFA = "warm-non-dfa"; + final String warmDFA = "warm-dfa"; + + createRepository(V2_REPO, "fs", v2RepoSettings(v2RepoPath)); + createWarmNonDFAIndex(warmNonDFA, 15); + createWarmDFAIndex(warmDFA, 10); + + // Take V2 snapshot — warm non-DFA must be included, warm DFA must be filtered. + CreateSnapshotResponse snapResp = client().admin() + .cluster() + .prepareCreateSnapshot(V2_REPO, "v2-warm-mix") + .setWaitForCompletion(true) + .get(); + SnapshotInfo info = snapResp.getSnapshotInfo(); + assertEquals(SnapshotState.SUCCESS, info.state()); + + List snapshotIndices = info.indices(); + assertTrue("warm non-DFA index must be included in snapshot, got: " + snapshotIndices, snapshotIndices.contains(warmNonDFA)); + assertFalse("warm DFA index must be filtered out, got: " + snapshotIndices, snapshotIndices.contains(warmDFA)); + // The warm non-DFA shard must actually be captured, not just listed. + assertTrue("warm non-DFA shard must be snapshotted, successfulShards=" + info.successfulShards(), info.successfulShards() >= 1); + + // Restore all (*): delete both indices, restore, and validate from cluster state that only + // the warm non-DFA index comes back; the warm DFA index (filtered at create time) does not. + Client client = client(); + assertAcked(client.admin().indices().delete(new DeleteIndexRequest(warmNonDFA, warmDFA)).get()); + assertFalse(indexExists(warmNonDFA)); + assertFalse(indexExists(warmDFA)); + + RestoreSnapshotResponse restoreResp = client.admin() + .cluster() + .prepareRestoreSnapshot(V2_REPO, "v2-warm-mix") + .setIndices("*") + .setWaitForCompletion(true) + .get(); + assertEquals(RestStatus.OK, restoreResp.status()); + ensureGreen(warmNonDFA); + + // Validate the restored set from cluster state. + assertTrue("warm non-DFA must be restored", indexExists(warmNonDFA)); + assertFalse("warm DFA must NOT be restored (filtered from snapshot)", indexExists(warmDFA)); + } +} diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareGetByIdIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareGetByIdIT.java new file mode 100644 index 0000000000000..9c8827e23ad30 --- /dev/null +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareGetByIdIT.java @@ -0,0 +1,105 @@ +/* + * 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.composite; + +import org.opensearch.action.get.GetResponse; +import org.opensearch.action.index.IndexResponse; +import org.opensearch.common.settings.Settings; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.test.OpenSearchIntegTestCase; + +import java.util.List; + +/** + * End-to-end get-by-id coverage for the hot {@link org.opensearch.index.engine.DataFormatAwareEngine}: + * exercises both the in-memory version-map path (realtime GET before refresh) and the parquet row + * path (GET after refresh) under active indexing and interleaved refreshes. + */ +@OpenSearchIntegTestCase.ClusterScope(scope = OpenSearchIntegTestCase.Scope.TEST, numDataNodes = 1) +public class DataFormatAwareGetByIdIT extends AbstractCompositeEngineIT { + + private static final String INDEX = "dfae_getbyid"; + + /** + * Creates the composite index (Lucene secondary for {@code _id} resolution) with auto-refresh + * disabled so the version-map vs row paths stay deterministic across the refresh boundary. + */ + private void createManualRefreshIndex() { + createCompositeIndex(INDEX); // withLuceneSecondary = true + client().admin().indices().prepareUpdateSettings(INDEX).setSettings(Settings.builder().put("index.refresh_interval", -1)).get(); + } + + private IndexResponse indexDoc(String name, int value) { + return client().prepareIndex().setIndex(INDEX).setSource("name", name, "value", value).get(); + } + + private static int intValue(GetResponse r) { + return ((Number) r.getSourceAsMap().get("value")).intValue(); + } + + public void testRealtimeGetHitsVersionMapBeforeRefresh() { + createManualRefreshIndex(); + IndexResponse indexResponse = indexDoc("doc_1", 1); + assertEquals(RestStatus.CREATED, indexResponse.status()); + String docId = indexResponse.getId(); + + // Non-realtime GET only sees refreshed (row) data -> not found yet, proving rows are still empty. + GetResponse nonRealtime = client().prepareGet(INDEX, docId).setRealtime(false).get(); + assertFalse("non-realtime get must not see the unrefreshed doc", nonRealtime.isExists()); + + // Realtime GET resolves from the in-memory version map (translog-backed) before any refresh. + GetResponse realtime = client().prepareGet(INDEX, docId).setRealtime(true).get(); + assertTrue("realtime get must find the unrefreshed doc", realtime.isExists()); + assertEquals(1L, realtime.getVersion()); + assertEquals("doc_1", realtime.getSourceAsMap().get("name")); + assertEquals(1, intValue(realtime)); + } + + public void testGetHitsRowsAfterRefresh() { + createManualRefreshIndex(); + IndexResponse indexResponse = indexDoc("doc_2", 2); + assertEquals(RestStatus.CREATED, indexResponse.status()); + refreshIndex(INDEX); + String docId = indexResponse.getId(); + + // After refresh the doc is materialized into parquet rows; non-realtime GET resolves via the row path. + GetResponse resp = client().prepareGet(INDEX, docId).setRealtime(false).get(); + assertTrue("post-refresh get must find the doc via rows", resp.isExists()); + assertEquals(1L, resp.getVersion()); + assertEquals("doc_2", resp.getSourceAsMap().get("name")); + assertEquals(2, intValue(resp)); + } + + public void testActiveIndexingWithInterleavedRefreshes() { + createManualRefreshIndex(); + // First batch then refresh -> these live in rows. + List ids = indexDocs(INDEX, 25, 1); + refreshIndex(INDEX); + // Second batch, NOT refreshed -> these live only in the version map. + ids.addAll(indexDocs(INDEX, 25, 26)); + + // Refreshed id -> row path. + GetResponse refreshed = client().prepareGet(INDEX, ids.get(9)).setRealtime(false).get(); + assertTrue("refreshed id must be found via rows", refreshed.isExists()); + assertEquals("doc_10", refreshed.getSourceAsMap().get("name")); + assertEquals(10, intValue(refreshed)); + + // Unrefreshed id -> version-map path (realtime found), absent from rows (non-realtime not found). + GetResponse unrefreshedRealtime = client().prepareGet(INDEX, ids.get(39)).setRealtime(true).get(); + assertTrue("unrefreshed id must be found realtime via version map", unrefreshedRealtime.isExists()); + assertEquals("doc_40", unrefreshedRealtime.getSourceAsMap().get("name")); + assertFalse("unrefreshed id must be absent from rows", client().prepareGet(INDEX, "40").setRealtime(false).get().isExists()); + + // After a second refresh the previously-unrefreshed id resolves via rows too. + refreshIndex(INDEX); + GetResponse nowInRows = client().prepareGet(INDEX, ids.get(39)).setRealtime(false).get(); + assertTrue("after refresh id must be found via rows", nowInRows.isExists()); + assertEquals(40, intValue(nowInRows)); + } +} diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareMultiIndexRecoveryIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareMultiIndexRecoveryIT.java index f7f1190d512be..0531640957c05 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareMultiIndexRecoveryIT.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareMultiIndexRecoveryIT.java @@ -160,10 +160,7 @@ public void testMultiShardRecoveryNoRedState() throws Exception { private void indexDocsToIndex(String indexName, int count) { for (int i = 0; i < count; i++) { - client().prepareIndex(indexName) - .setId(String.valueOf(i)) - .setSource("field_text", randomAlphaOfLength(10), "field_number", (long) i) - .get(); + client().prepareIndex(indexName).setSource("field_text", randomAlphaOfLength(10), "field_number", (long) i).get(); } } } diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwarePeerRecoveryIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwarePeerRecoveryIT.java index 4f03c13b5b082..35ea8ee8c7a31 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwarePeerRecoveryIT.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwarePeerRecoveryIT.java @@ -51,6 +51,7 @@ public void testPeerRecoveryOfFreshReplicaUnderIndexingLoad() throws Exception { random() ) ) { + indexer.setUseAutoGeneratedIDs(true); indexer.setIgnoreIndexingFailures(true); indexer.start(-1); waitForIndexerDocs(200, indexer); diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwarePrepareTieringAsyncIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwarePrepareTieringAsyncIT.java index 519f14d06373f..c2928e169155b 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwarePrepareTieringAsyncIT.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwarePrepareTieringAsyncIT.java @@ -24,6 +24,7 @@ import java.util.HashSet; import java.util.Set; +import java.util.concurrent.TimeUnit; /** * End-to-end integration test for the asynchronous pre-tiering sync ({@link PrepareTieringAction}). @@ -73,10 +74,7 @@ public void testPrepareTieringRunsAsyncAcrossShardsAndDrainsMerges() throws Exce int id = 0; for (int batch = 0; batch < INDEX_BATCHES; batch++) { for (int i = 0; i < DOCS_PER_BATCH; i++) { - client().prepareIndex(ASYNC_INDEX) - .setId(String.valueOf(id)) - .setSource("field_text", "value_" + id, "field_number", (long) id) - .get(); + client().prepareIndex(ASYNC_INDEX).setSource("field_text", "value_" + id, "field_number", (long) id).get(); id++; } client().admin().indices().prepareRefresh(ASYNC_INDEX).get(); @@ -135,10 +133,7 @@ public void testPrepareTieringIsIdempotentAcrossInvocations() throws Exception { int id = 0; for (int batch = 0; batch < INDEX_BATCHES; batch++) { for (int i = 0; i < DOCS_PER_BATCH; i++) { - client().prepareIndex(ASYNC_INDEX) - .setId(String.valueOf(id)) - .setSource("field_text", "value_" + id, "field_number", (long) id) - .get(); + client().prepareIndex(ASYNC_INDEX).setSource("field_text", "value_" + id, "field_number", (long) id).get(); id++; } client().admin().indices().prepareRefresh(ASYNC_INDEX).get(); @@ -208,4 +203,99 @@ private IndexShard primaryShard(String index, int shardId) { IndicesService indicesService = internalCluster().getInstance(IndicesService.class, nodeName); return indicesService.indexServiceSafe(resolveIndex(index)).getShard(shardId); } + + /** + * Triggers a force merge and immediately calls prepare tiering back-to-back. Whether the force + * merge is still in-flight when prepare runs depends on timing — the test verifies that in either + * case: + *
      + *
    • Prepare succeeds (no shard failures)
    • + *
    • All merges (background and force) are drained after prepare completes
    • + *
    • Replicas are in sync with the primary (checkpoints behind == 0)
    • + *
    + * This guards against the race condition where forceMerge was invisible to onMergesDrained, + * causing tiering to proceed while a force merge was still running. + */ + public void testPrepareTieringAfterForceMerge_MergesDrainedAndReplicasInSync() throws Exception { + internalCluster().startClusterManagerOnlyNode(); + internalCluster().startDataAndWarmNodes(2); + + Settings settings = Settings.builder().put(dfaIndexSettings(1)).put(IndexMetadata.SETTING_NUMBER_OF_SHARDS, 1).build(); + client().admin().indices().prepareCreate(ASYNC_INDEX).setSettings(settings).get(); + ensureGreen(ASYNC_INDEX); + + try { + // Index in many batches to create multiple segments worth merging + int id = 0; + for (int batch = 0; batch < INDEX_BATCHES * 2; batch++) { + for (int i = 0; i < DOCS_PER_BATCH; i++) { + client().prepareIndex(ASYNC_INDEX).setSource("field_text", "value_" + id, "field_number", (long) id).get(); + id++; + } + client().admin().indices().prepareRefresh(ASYNC_INDEX).get(); + } + final int totalDocs = id; + client().admin().indices().prepareFlush(ASYNC_INDEX).setForce(true).get(); + + // Trigger force merge (non-blocking) and immediately call prepare tiering back-to-back. + // Whether the merge is still in-flight or already done when prepare runs is timing-dependent — + // both outcomes must result in a successful prepare with drained merges. + client().admin().indices().prepareForceMerge(ASYNC_INDEX).setMaxNumSegments(1).setFlush(false).execute(); + + PrepareTieringRequest request = new PrepareTieringRequest(ASYNC_INDEX); + request.timeout(TimeValue.timeValueSeconds(90)); + BroadcastResponse response = client().execute(PrepareTieringAction.INSTANCE, request).actionGet(); + + // Prepare must succeed — regardless of whether force merge was still running or already done + assertEquals("all shards targeted", 1, response.getTotalShards()); + assertEquals("no shard should fail prepare", 0, response.getFailedShards()); + assertEquals("shard should prepare successfully", 1, response.getSuccessfulShards()); + + // After prepare completes, all merges must be drained + IndexShard primary = primaryShard(ASYNC_INDEX, 0); + assertEquals("active merges should be drained after prepare", 0, primary.getActiveMergeCount()); + assertFalse("no pending merges should remain after prepare", primary.hasPendingMerges()); + + // Replicas must be in sync — checkpoints behind should be 0 + assertBusy(() -> { + var replicationStats = primary.getReplicationStatsForTrackedReplicas(); + for (var stat : replicationStats) { + assertEquals("replica should be in sync after prepare (checkpoints behind)", 0, stat.getCheckpointsBehindCount()); + assertEquals("replica should be in sync after prepare (bytes behind)", 0, stat.getBytesBehindCount()); + assertEquals("replica should have no in-flight replication", 0, stat.getCurrentReplicationTimeMillis()); + } + }, 30, TimeUnit.SECONDS); + + // Data integrity: doc count should be preserved + assertEquals("all docs should be present", (long) totalDocs, primariesDocCount(ASYNC_INDEX)); + + // After prepare, replica should have the same segment layout as primary (in sync) + long primarySegments = client().admin() + .indices() + .prepareStats(ASYNC_INDEX) + .clear() + .setSegments(true) + .get() + .getIndex(ASYNC_INDEX) + .getPrimaries() + .getSegments() + .getCount(); + + long totalSegments = client().admin() + .indices() + .prepareStats(ASYNC_INDEX) + .clear() + .setSegments(true) + .get() + .getIndex(ASYNC_INDEX) + .getTotal() + .getSegments() + .getCount(); + + // 1P+1R: total should be exactly 2x primary (replica mirrors primary) + assertEquals("replica segment count should match primary after prepare", primarySegments * 2, totalSegments); + } finally { + client().admin().indices().delete(new DeleteIndexRequest(ASYNC_INDEX)).actionGet(); + } + } } diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareReadonlyEngineBaseIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareReadonlyEngineBaseIT.java index 427b2829f5483..a071dd2230da7 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareReadonlyEngineBaseIT.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareReadonlyEngineBaseIT.java @@ -11,6 +11,7 @@ import com.carrotsearch.randomizedtesting.ThreadFilter; import com.carrotsearch.randomizedtesting.annotations.ThreadLeakFilters; +import org.opensearch.action.index.IndexResponse; import org.opensearch.arrow.allocator.ArrowBasePlugin; import org.opensearch.be.datafusion.DataFusionPlugin; import org.opensearch.be.lucene.LucenePlugin; @@ -18,9 +19,9 @@ import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.settings.Settings; import org.opensearch.common.util.FeatureFlags; -import org.opensearch.composite.framework.ParquetOnlyDataFormatPlugin; import org.opensearch.core.common.unit.ByteSizeUnit; import org.opensearch.core.common.unit.ByteSizeValue; +import org.opensearch.core.rest.RestStatus; import org.opensearch.core.xcontent.NamedXContentRegistry; import org.opensearch.env.Environment; import org.opensearch.index.IndexModule; @@ -29,6 +30,7 @@ import org.opensearch.indices.recovery.RecoverySettings; import org.opensearch.indices.replication.common.ReplicationType; import org.opensearch.node.Node; +import org.opensearch.parquet.ParquetDataFormatPlugin; import org.opensearch.plugins.Plugin; import org.opensearch.remotestore.RemoteStoreBaseIntegTestCase; import org.opensearch.remotestore.mocks.MockFsMetadataSupportedRepositoryPlugin; @@ -42,6 +44,7 @@ import java.io.IOException; import java.io.UncheckedIOException; import java.nio.file.Files; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; @@ -136,7 +139,7 @@ protected Collection> nodePlugins() { .filter(p -> p != MockFsRepositoryPlugin.class), Stream.>of( ArrowBasePlugin.class, - ParquetOnlyDataFormatPlugin.class, + ParquetDataFormatPlugin.class, CompositeDataFormatPlugin.class, LucenePlugin.class, DataFusionPlugin.class, @@ -205,15 +208,17 @@ protected IndexShard getIndexShard(String nodeName) { } /** Create hot DFA index, index docs, flush, then tier to warm. */ - protected void createHotIndexAndTierToWarm(int replicaCount) throws Exception { + protected List createHotIndexAndTierToWarm(int replicaCount) throws Exception { client().admin().indices().prepareCreate(INDEX_NAME).setSettings(dfaIndexSettings(replicaCount)).get(); ensureGreen(INDEX_NAME); + List docIds = new ArrayList<>(); for (int i = 0; i < DOC_COUNT; i++) { - client().prepareIndex(INDEX_NAME) - .setId(String.valueOf(i)) + IndexResponse indexResponse = client().prepareIndex(INDEX_NAME) .setSource("field_text", "value_" + i, "field_number", (long) i) .get(); + assertEquals(RestStatus.CREATED, indexResponse.status()); + docIds.add(indexResponse.getId()); } client().admin().indices().prepareFlush(INDEX_NAME).setForce(true).get(); @@ -226,5 +231,6 @@ protected void createHotIndexAndTierToWarm(int replicaCount) throws Exception { .get(); client().admin().indices().prepareOpen(INDEX_NAME).get(); ensureGreen(INDEX_NAME); + return docIds; } } diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareReadonlyEngineWriteIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareReadonlyEngineWriteIT.java index 454f0c5aaf0ec..ad7dc02181d7a 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareReadonlyEngineWriteIT.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareReadonlyEngineWriteIT.java @@ -46,31 +46,12 @@ public void testWarmEngineFlipAndWriteRejection() throws Exception { // Index must be rejected try { - client().prepareIndex(INDEX_NAME).setId("new-doc").setSource("field_text", "fail").get(); + client().prepareIndex(INDEX_NAME).setSource("field_text", "fail").get(); fail("index should be rejected"); } catch (Exception e) { assertTrue("expected rejection, got: " + e.getMessage(), e.getMessage().contains("does not support")); } - // Delete must be rejected - try { - client().prepareDelete(INDEX_NAME, "0").get(); - fail("delete should be rejected"); - } catch (Exception e) { - assertTrue("expected rejection, got: " + e.getMessage(), e.getMessage().contains("does not support")); - } - - // Update must be rejected - try { - client().prepareUpdate(INDEX_NAME, "0").setDoc("field_text", "updated").get(); - fail("update should be rejected"); - } catch (Exception e) { - assertTrue( - "expected rejection, got: " + e.getMessage(), - e.getMessage().contains("does not support") || e.getMessage().contains("Cannot apply function on indexer") - ); - } - // Flush is no-op (should not throw) client().admin().indices().prepareFlush(INDEX_NAME).setForce(true).get(); diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareReadonlyGetByIdIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareReadonlyGetByIdIT.java new file mode 100644 index 0000000000000..88f91c072d4e4 --- /dev/null +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareReadonlyGetByIdIT.java @@ -0,0 +1,52 @@ +/* + * 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.composite; + +import org.apache.lucene.tests.util.LuceneTestCase.AwaitsFix; +import org.opensearch.action.get.GetResponse; +import org.opensearch.index.engine.DataFormatAwareReadOnlyEngine; +import org.opensearch.index.engine.exec.Indexer; +import org.opensearch.index.shard.IndexShard; +import org.opensearch.index.shard.IndexShardTestCase; + +import java.util.List; + +/** + * End-to-end get-by-id coverage for {@link DataFormatAwareReadOnlyEngine}: after an index is tiered to + * warm, a document is still resolvable by id via the read-only row path (the warm engine has no version map). + */ +@AwaitsFix(bugUrl = "https://github.com/opensearch-project/OpenSearch/pull/21803") +public class DataFormatAwareReadonlyGetByIdIT extends DataFormatAwareReadonlyEngineBaseIT { + + public void testGetByIdFromWarmReadOnlyEngine() throws Exception { + internalCluster().startClusterManagerOnlyNode(); + internalCluster().startDataAndWarmNodes(2); + List ids = createHotIndexAndTierToWarm(0); // indexes ids 0..49 (field_text="value_", field_number=), flush, flip to + // warm + + // Confirm the warm primary really runs the read-only engine. + IndexShard primaryShard = getIndexShard(primaryNodeName()); + Indexer indexer = IndexShardTestCase.getIndexer(primaryShard); + assertTrue( + "warm primary must use DataFormatAwareReadOnlyEngine, got: " + indexer.getClass().getSimpleName(), + indexer instanceof DataFormatAwareReadOnlyEngine + ); + + long cnt = 1; + for (String id : ids) { + GetResponse resp = client().prepareGet(INDEX_NAME, id).setRealtime(false).get(); + + assertTrue("replica get-by-id must find the replicated doc via rows", resp.isExists()); + assertEquals(1L, resp.getVersion()); + assertEquals(cnt++, ((Number) resp.getSourceAsMap().get("field_number")).longValue()); + assertNotNull(resp.getSourceAsMap().get("field_text")); + assertNotNull(resp.getSourceAsMap().get("field_keyword")); + } + } +} diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareRemoteStoreRecoveryIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareRemoteStoreRecoveryIT.java index 4f4407b1a1837..57ce8c0fe2cfc 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareRemoteStoreRecoveryIT.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareRemoteStoreRecoveryIT.java @@ -133,7 +133,6 @@ protected void assertLuceneIndexDirContents(IndexShard shard) throws java.io.IOE protected void indexDocs(int count) { for (int i = 0; i < count; i++) { client().prepareIndex(INDEX_NAME) - .setId(String.valueOf(i)) .setRefreshPolicy(org.opensearch.action.support.WriteRequest.RefreshPolicy.NONE) .setSource("field_text", randomAlphaOfLength(10), "field_keyword", randomAlphaOfLength(10), "field_number", (long) i) .get(); @@ -143,7 +142,6 @@ protected void indexDocs(int count) { protected void indexDocsWithOffset(int offset, int count) { for (int i = 0; i < count; i++) { client().prepareIndex(INDEX_NAME) - .setId(String.valueOf(offset + i)) .setRefreshPolicy(org.opensearch.action.support.WriteRequest.RefreshPolicy.NONE) .setSource( "field_text", diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareReplicaGetByIdIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareReplicaGetByIdIT.java new file mode 100644 index 0000000000000..695369c48d964 --- /dev/null +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareReplicaGetByIdIT.java @@ -0,0 +1,50 @@ +/* + * 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.composite; + +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; + +import org.opensearch.action.get.GetResponse; +import org.opensearch.test.OpenSearchIntegTestCase; + +import java.util.Collections; +import java.util.List; + +/** + * End-to-end get-by-id coverage for {@link org.opensearch.index.engine.DataFormatAwareNRTReplicationEngine}: + * a doc indexed on the primary is resolvable by id from a replica shard via the row path (the replica + * engine has no live version map) once segment replication has propagated it. + */ +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +@OpenSearchIntegTestCase.ClusterScope(scope = OpenSearchIntegTestCase.Scope.TEST, numDataNodes = 0) +public class DataFormatAwareReplicaGetByIdIT extends DataFormatAwareReplicationBaseIT { + + public void testGetByIdFromReplica() throws Exception { + // At least one doc: assertCatalogSnapshotsConverged asserts the lucene index/ has segment + // data files beyond segments_N, which an empty index never produces (randomInt(20) can be 0). + int maxDocs = randomIntBetween(1, 20); + createDfaIndex(1); // 1 replica, 2 data nodes (from base) + List ids = indexDocs(maxDocs); // ids 1..20, RefreshPolicy.NONE + client().admin().indices().prepareRefresh(INDEX_NAME).get(); + // Ensure the replica's catalog has converged with the primary (segments replicated). + assertCatalogSnapshotsConverged(INDEX_NAME); + + Collections.shuffle(ids, random()); + String replicaNode = replicaNodeNames().get(0); + + for (String id : ids) { + // Route the GET to the replica copy so DataFormatAwareNRTReplicationEngine#getById serves it. + GetResponse resp = client().prepareGet(INDEX_NAME, id).setPreference("_only_nodes:" + replicaNode).setRealtime(false).get(); + assertTrue("replica get-by-id must find the replicated doc via rows", resp.isExists()); + assertEquals(1L, resp.getVersion()); + assertNotNull(resp.getSourceAsMap().get("field_text")); + assertNotNull(resp.getSourceAsMap().get("field_keyword")); + } + } +} diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareReplicationBaseIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareReplicationBaseIT.java index 6cb17694e97d3..4ee596da70df0 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareReplicationBaseIT.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareReplicationBaseIT.java @@ -11,6 +11,7 @@ import com.carrotsearch.randomizedtesting.ThreadFilter; import com.carrotsearch.randomizedtesting.annotations.ThreadLeakFilters; +import org.opensearch.action.index.IndexResponse; import org.opensearch.arrow.allocator.ArrowBasePlugin; import org.opensearch.be.datafusion.DataFusionPlugin; import org.opensearch.be.lucene.LucenePlugin; @@ -41,6 +42,7 @@ import org.opensearch.test.InternalTestCluster; import org.opensearch.test.OpenSearchIntegTestCase; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; @@ -230,14 +232,16 @@ protected void createDfaIndex(int replicaCount) throws Exception { } /** Index N docs with RefreshPolicy.NONE. */ - protected void indexDocs(int count) { + protected List indexDocs(int count) { + List ids = new ArrayList<>(); for (int i = 0; i < count; i++) { - client().prepareIndex(INDEX_NAME) - .setId(String.valueOf(i)) + IndexResponse indexResponse = client().prepareIndex(INDEX_NAME) .setRefreshPolicy(org.opensearch.action.support.WriteRequest.RefreshPolicy.NONE) .setSource("field_text", randomAlphaOfLength(10), "field_keyword", randomAlphaOfLength(10), "field_number", (long) i) .get(); + ids.add(indexResponse.getId()); } + return ids; } /** Primary's node name. */ diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareReplicationIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareReplicationIT.java index f22f6d3536e4f..45b1d06432918 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareReplicationIT.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareReplicationIT.java @@ -127,7 +127,6 @@ protected void indexDocs(int count) { // refresh/flush at the end gives deterministic replication rounds. for (int i = 0; i < count; i++) { client().prepareIndex(INDEX_NAME) - .setId(String.valueOf(i)) .setRefreshPolicy(org.opensearch.action.support.WriteRequest.RefreshPolicy.NONE) .setSource("field_text", randomAlphaOfLength(10), "field_keyword", randomAlphaOfLength(10), "field_number", (long) i) .get(); diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareReplicationPromotionIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareReplicationPromotionIT.java index 7661993224880..5d156ba37f9c9 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareReplicationPromotionIT.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareReplicationPromotionIT.java @@ -251,6 +251,7 @@ private BackgroundIndexer newIndexer() { ); indexer.setIgnoreIndexingFailures(true); indexer.setFailureAssertion(e -> {}); + indexer.setUseAutoGeneratedIDs(true); return indexer; } } diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareRestoreShallowSnapshotV2IT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareRestoreShallowSnapshotV2IT.java index 24d7e97a05238..9014d7f6c8617 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareRestoreShallowSnapshotV2IT.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareRestoreShallowSnapshotV2IT.java @@ -195,7 +195,7 @@ private void indexDocuments(Client client, String indexName, int numOfDocs) { protected void indexDocuments(Client client, String indexName, int fromId, int toId) { for (int i = fromId; i < toId; i++) { String id = Integer.toString(i); - client.prepareIndex(indexName).setId(id).setSource("text", "sometext").get(); + client.prepareIndex(indexName).setSource("text", "sometext").get(); } } @@ -1935,4 +1935,111 @@ public void testV2InvalidRestoreRequestForDFAIndex() throws Exception { assertDocCountInIndex(client, indexName, numDocs); } + /** + * End-to-end V2 snapshot lifecycle for a hot DFA index, validating that indexing + refresh + + * catalog/segments invariants hold across two restore cycles: + *
      + *
    1. Create hot DFA index, ingest, snapshot {@code snap1}, delete, restore from {@code snap1}.
    2. + *
    3. After restore: validate engine, catalog, doc count, then ingest more, refresh, validate.
    4. + *
    5. Snapshot the now-larger index as {@code snap2}, delete, restore from {@code snap2}.
    6. + *
    7. After second restore: validate everything again, ingest more, refresh, validate.
    8. + *
    + */ + public void testV2HotDFASnapshotRestoreLifecycle() throws Exception { + internalCluster().startClusterManagerOnlyNode(); + internalCluster().startDataAndWarmNodes(1); + + final String indexName = "hot-v2-lifecycle"; + final String repoName = "test-snapshot-repo"; + final String snap1 = "snap1-initial"; + final String snap2 = "snap2-after-more-docs"; + + final int initialDocs = 30; + final int phase2Docs = 20; + final int phase3Docs = 15; + final int totalAtSnap2 = initialDocs + phase2Docs; + final int finalTotal = totalAtSnap2 + phase3Docs; + + Path repoPath = randomRepoPath().toAbsolutePath(); + createRepository(repoName, "fs", getRepositorySettings(repoPath, true)); + + // ── Phase 1: create hot DFA index, ingest, snapshot ──────────────── + Client client = client(); + createIndex(indexName, getIndexSettings(1, 0).build()); + ensureGreen(indexName); + indexDocuments(client, indexName, 0, initialDocs); + refresh(indexName); + flush(indexName); + assertDocCountInIndex(client, indexName, initialDocs); + + IndexShard shardPreSnap1 = getShardZero(indexName); + assertAllFormatDirsHaveFiles(shardPreSnap1); + DataFormatAwareITUtils.assertCatalogMatchesLocalAndRemote(shardPreSnap1); + + SnapshotInfo s1 = createSnapshot(repoName, snap1, new ArrayList<>()); + assertThat(s1.state(), equalTo(SnapshotState.SUCCESS)); + assertEquals(1, s1.successfulShards()); + + // ── Restore from snap1, validate, ingest more, validate, snap2 ───── + assertAcked(client().admin().indices().delete(new DeleteIndexRequest(indexName)).get()); + assertFalse(indexExists(indexName)); + + RestoreSnapshotResponse r1 = client.admin() + .cluster() + .prepareRestoreSnapshot(repoName, snap1) + .setWaitForCompletion(true) + .setIndices(indexName) + .get(); + assertEquals(RestStatus.OK, r1.status()); + ensureGreen(indexName); + + // Post-restore #1 validations + IndexShard shardR1 = getShardZero(indexName); + assertAllFormatDirsHaveFiles(shardR1); + DataFormatAwareITUtils.assertCatalogMatchesLocalAndRemote(shardR1); + assertDocCountInIndex(client, indexName, initialDocs); + + // Ingest more on the restored index — proves indexing works post-restore + indexDocuments(client, indexName, initialDocs, totalAtSnap2); + refresh(indexName); + flush(indexName); + assertDocCountInIndex(client, indexName, totalAtSnap2); + + IndexShard shardAfterPhase2 = getShardZero(indexName); + assertAllFormatDirsHaveFiles(shardAfterPhase2); + DataFormatAwareITUtils.assertCatalogMatchesLocalAndRemote(shardAfterPhase2); + + SnapshotInfo s2 = createSnapshot(repoName, snap2, new ArrayList<>()); + assertThat(s2.state(), equalTo(SnapshotState.SUCCESS)); + assertEquals(1, s2.successfulShards()); + + // ── Delete and restore from snap2 (the LATER snapshot, larger doc set) ─ + assertAcked(client().admin().indices().delete(new DeleteIndexRequest(indexName)).get()); + assertFalse(indexExists(indexName)); + + RestoreSnapshotResponse r2 = client.admin() + .cluster() + .prepareRestoreSnapshot(repoName, snap2) + .setWaitForCompletion(true) + .setIndices(indexName) + .get(); + assertEquals(RestStatus.OK, r2.status()); + ensureGreen(indexName); + + // Post-restore #2 validations — must reflect snap2 (totalAtSnap2 docs) + IndexShard shardR2 = getShardZero(indexName); + assertAllFormatDirsHaveFiles(shardR2); + DataFormatAwareITUtils.assertCatalogMatchesLocalAndRemote(shardR2); + assertDocCountInIndex(client, indexName, totalAtSnap2); + + // Ingest yet more, refresh, validate — proves indexing still works after second restore + indexDocuments(client, indexName, totalAtSnap2, finalTotal); + refresh(indexName); + assertDocCountInIndex(client, indexName, finalTotal); + + IndexShard shardFinal = getShardZero(indexName); + assertAllFormatDirsHaveFiles(shardFinal); + DataFormatAwareITUtils.assertCatalogMatchesLocalAndRemote(shardFinal); + } + } diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareUploadIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareUploadIT.java index 1a6fb281d7104..22ff75e0b21f5 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareUploadIT.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareUploadIT.java @@ -86,7 +86,6 @@ protected void createDfaIndex(int replicaCount) throws Exception { protected void indexDocs(int count) { for (int i = 0; i < count; i++) { client().prepareIndex(INDEX_NAME) - .setId(String.valueOf(i)) .setSource("field_text", randomAlphaOfLength(10), "field_keyword", randomAlphaOfLength(10), "field_number", (long) i) .get(); } diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/StatsFailureIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/StatsFailureIT.java index 58671a1aed1d1..41bd1cf932b93 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/StatsFailureIT.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/StatsFailureIT.java @@ -117,7 +117,7 @@ public void testTrackerLifecycleAroundEngineFailure() throws Exception { FailableLuceneDataFormatPlugin.failOnNthWrite(3); for (int i = 50; i < 70; i++) { try { - client().prepareIndex(idx).setId(String.valueOf(i)).setSource("name", "injection_" + i, "value", i).get(); + client().prepareIndex(idx).setSource("name", "injection_" + i, "value", i).get(); } catch (Exception expected) { // Engine failure causes indexing exceptions — expected. break; diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/StatsITHelpers.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/StatsITHelpers.java index a7146c1c90da5..b29af138a5e78 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/StatsITHelpers.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/StatsITHelpers.java @@ -41,6 +41,17 @@ static Map luceneIndexStats(RestClient rest, String index, Strin return fetchStats(rest, "/_plugins/lucene/" + index + "/_stats", queryParams); } + static Map compositeIndexStats(RestClient rest, String index, String... queryParams) throws IOException { + return fetchStats(rest, "/_plugins/composite/" + index + "/_stats", queryParams); + } + + static Map compositeNodeStats(RestClient rest, String nodeIdOrEmpty, String... queryParams) throws IOException { + String path = nodeIdOrEmpty.isEmpty() + ? "/_plugins/composite/_nodes/_stats" + : "/_plugins/composite/_nodes/" + nodeIdOrEmpty + "/_stats"; + return fetchStats(rest, path, queryParams); + } + static Map parquetNodeStats(RestClient rest, String nodeIdOrEmpty, String... queryParams) throws IOException { String path = nodeIdOrEmpty.isEmpty() ? "/_plugins/parquet/_nodes/_stats" : "/_plugins/parquet/_nodes/" + nodeIdOrEmpty + "/_stats"; return fetchStats(rest, path, queryParams); diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/StatsLifecycleIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/StatsLifecycleIT.java index 9a399a9ea8c34..1aab87f23c554 100644 --- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/StatsLifecycleIT.java +++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/StatsLifecycleIT.java @@ -367,7 +367,7 @@ public void testConcurrentIndexingAccuracy() throws Exception { start.await(); for (int i = 0; i < docsPerThread; i++) { int docId = threadId * docsPerThread + i; - client().prepareIndex(idx).setId(String.valueOf(docId)).setSource("name", "doc_" + docId, "value", docId).get(); + client().prepareIndex(idx).setSource("name", "doc_" + docId, "value", docId).get(); } } catch (Exception e) { throw new RuntimeException("thread " + threadId + " failed", e); @@ -453,7 +453,7 @@ public void testNonCompositeIndexReturnsEmpty() throws Exception { // Index some docs — these go to plain Lucene, not DFA. for (int i = 0; i < 5; i++) { - client().prepareIndex("plain-idx").setId(String.valueOf(i)).setSource("name", "doc_" + i).get(); + client().prepareIndex("plain-idx").setSource("name", "doc_" + i).get(); } refreshIndex("plain-idx"); diff --git a/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/CompositeDataFormat.java b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/CompositeDataFormat.java index b474121550ef7..9b47cf58090c4 100644 --- a/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/CompositeDataFormat.java +++ b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/CompositeDataFormat.java @@ -26,6 +26,9 @@ @ExperimentalApi public class CompositeDataFormat extends DataFormat { + /** Canonical format name for the composite engine. */ + public static final String COMPOSITE_FORMAT_NAME = "composite"; + private final DataFormat primaryDataFormat; private final List dataFormats; @@ -68,7 +71,7 @@ public DataFormat getPrimaryDataFormat() { @Override public String name() { - return "composite"; + return COMPOSITE_FORMAT_NAME; } @Override diff --git a/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/CompositeDataFormatPlugin.java b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/CompositeDataFormatPlugin.java index 19a38dcdaa454..d339da4abad8a 100644 --- a/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/CompositeDataFormatPlugin.java +++ b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/CompositeDataFormatPlugin.java @@ -10,13 +10,25 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.opensearch.action.ActionRequest; import org.opensearch.cluster.metadata.IndexNameExpressionResolver; +import org.opensearch.cluster.node.DiscoveryNodes; import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.ValidationException; import org.opensearch.common.annotation.ExperimentalApi; import org.opensearch.common.settings.ClusterSettings; +import org.opensearch.common.settings.IndexScopedSettings; import org.opensearch.common.settings.Setting; import org.opensearch.common.settings.Settings; +import org.opensearch.common.settings.SettingsFilter; +import org.opensearch.composite.stats.CompositeStatsProvider; +import org.opensearch.composite.stats.transport.CompositeNodeStatsActionType; +import org.opensearch.composite.stats.transport.CompositeNodeStatsRestAction; +import org.opensearch.composite.stats.transport.CompositeNodeStatsTransportAction; +import org.opensearch.composite.stats.transport.CompositeStatsActionType; +import org.opensearch.composite.stats.transport.CompositeStatsRestAction; +import org.opensearch.composite.stats.transport.CompositeStatsTransportAction; +import org.opensearch.core.action.ActionResponse; import org.opensearch.core.common.io.stream.NamedWriteableRegistry; import org.opensearch.core.common.unit.ByteSizeUnit; import org.opensearch.core.common.unit.ByteSizeValue; @@ -39,10 +51,13 @@ import org.opensearch.indices.IndexCreationException; import org.opensearch.indices.IndicesService; import org.opensearch.plugin.stats.DataFormatStatsProviderRegistry; +import org.opensearch.plugins.ActionPlugin; import org.opensearch.plugins.ExtensiblePlugin; import org.opensearch.plugins.MapperPlugin; import org.opensearch.plugins.Plugin; import org.opensearch.repositories.RepositoriesService; +import org.opensearch.rest.RestController; +import org.opensearch.rest.RestHandler; import org.opensearch.script.ScriptService; import org.opensearch.threadpool.ThreadPool; import org.opensearch.transport.client.Client; @@ -96,7 +111,7 @@ * @opensearch.experimental */ @ExperimentalApi -public class CompositeDataFormatPlugin extends Plugin implements DataFormatPlugin, ExtensiblePlugin, MapperPlugin { +public class CompositeDataFormatPlugin extends Plugin implements DataFormatPlugin, ExtensiblePlugin, MapperPlugin, ActionPlugin { private static final Logger logger = LogManager.getLogger(CompositeDataFormatPlugin.class); @@ -214,9 +229,34 @@ public Collection createComponents( Supplier repositoriesServiceSupplier ) { this.clusterService = clusterService; + // Eagerly construct the provider so the registry is populated before the engine and + // transport-action layers attempt lookups. The engine self-registers its per-shard + // tracker via CompositeStatsProvider.getInstance() on construction. + new CompositeStatsProvider(); return Collections.emptyList(); } + @Override + public List> getActions() { + return List.of( + new ActionPlugin.ActionHandler<>(CompositeStatsActionType.INSTANCE, CompositeStatsTransportAction.class), + new ActionPlugin.ActionHandler<>(CompositeNodeStatsActionType.INSTANCE, CompositeNodeStatsTransportAction.class) + ); + } + + @Override + public List getRestHandlers( + Settings settings, + RestController restController, + ClusterSettings clusterSettings, + IndexScopedSettings indexScopedSettings, + SettingsFilter settingsFilter, + IndexNameExpressionResolver indexNameExpressionResolver, + Supplier nodesInCluster + ) { + return List.of(new CompositeStatsRestAction(), new CompositeNodeStatsRestAction()); + } + /** * Stamps the cluster-scope defaults for {@link #PRIMARY_DATA_FORMAT} and * {@link #SECONDARY_DATA_FORMATS} into newly created indices when those index-level settings diff --git a/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/CompositeIndexingExecutionEngine.java b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/CompositeIndexingExecutionEngine.java index 47ece1f7d36ea..e34d99c42bb55 100644 --- a/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/CompositeIndexingExecutionEngine.java +++ b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/CompositeIndexingExecutionEngine.java @@ -14,6 +14,9 @@ import org.opensearch.common.settings.Settings; import org.opensearch.common.util.io.IOUtils; import org.opensearch.composite.merge.CompositeMerger; +import org.opensearch.composite.stats.CompositeShardStatsTracker; +import org.opensearch.composite.stats.CompositeStatsProvider; +import org.opensearch.core.index.shard.ShardId; import org.opensearch.index.IndexSettings; import org.opensearch.index.engine.dataformat.DataFormat; import org.opensearch.index.engine.dataformat.DataFormatPlugin; @@ -37,6 +40,7 @@ import org.opensearch.index.mapper.MapperService; import org.opensearch.index.store.FormatChecksumStrategy; import org.opensearch.index.store.Store; +import org.opensearch.plugin.stats.StatsRecorder; import java.io.IOException; import java.util.ArrayList; @@ -79,6 +83,8 @@ public class CompositeIndexingExecutionEngine implements IndexingExecutionEngine private final Committer committer; private final IndexSettings indexSettings; private final CompositeMerger merger; + private final CompositeShardStatsTracker statsTracker = new CompositeShardStatsTracker(); + private final ShardId shardId; private volatile Map> pendingDeletes = new ConcurrentHashMap<>(); /** @@ -151,6 +157,27 @@ public CompositeIndexingExecutionEngine( this.committer = committer; this.indexSettings = indexSettings; this.merger = new CompositeMerger(this, compositeDataFormat); + this.shardId = store != null ? store.shardId() : null; + + // Register the per-shard tracker so REST endpoints can read live counters; unregistered + // in close(). Rolls back the registration if anything below throws, to avoid leaking it. + CompositeStatsProvider provider = CompositeStatsProvider.getInstance(); + boolean registered = false; + try { + if (provider != null && shardId != null) { + provider.register(shardId, statsTracker); + registered = true; + } + } catch (Throwable t) { + if (registered) { + try { + provider.unregister(shardId); + } catch (Throwable rollbackErr) { + logger.warn("Failed to unregister composite stats tracker during constructor rollback", rollbackErr); + } + } + throw t; + } } /** @@ -231,6 +258,12 @@ public Exception getTragicException() { */ @Override public RefreshResult refresh(RefreshInput refreshInput) throws IOException { + // recordTimeMillis owns the whole-refresh timing; incRefreshTotal counts every refresh. + statsTracker.incRefreshTotal(); + return StatsRecorder.recordTimeMillis(() -> doRefresh(refreshInput), statsTracker::addRefreshTimeMillis); + } + + private RefreshResult doRefresh(RefreshInput refreshInput) throws IOException { tryDeletePendingFiles(); // All per-format engines refresh normally (primary passes through, secondary does addIndexes) @@ -269,8 +302,14 @@ public RefreshResult refresh(RefreshInput refreshInput) throws IOException { if (onlyNew.size() > 1) { try { final long mergeStartNanos = System.nanoTime(); - MergeResult mergeResult = merger.merge( - MergeInput.builder().segments(onlyNew).newWriterGeneration(refreshInput.nextAvailableGeneration()).build() + // Counts merge-on-refresh attempts; a subset overlay of merge_total (also + // incremented inside CompositeMerger.merge()). + statsTracker.incRefreshMergeTotal(); + MergeResult mergeResult = StatsRecorder.recordTimeMillis( + () -> merger.merge( + MergeInput.builder().segments(onlyNew).newWriterGeneration(refreshInput.nextAvailableGeneration()).build() + ), + statsTracker::addRefreshMergeTimeMillis ); if (mergeResult != null) { @@ -316,6 +355,7 @@ public RefreshResult refresh(RefreshInput refreshInput) throws IOException { } catch (Exception e) { // Merge-on-refresh is best-effort. On failure, fall back to normal per-writer // segments. Background merge will consolidate them later. + statsTracker.incRefreshMergeFailures(); logger.warn("merge-on-refresh failed, falling back to per-writer segments", e); } } @@ -478,11 +518,20 @@ public CompositeDocumentInput newDocumentInput() { */ @Override public void close() throws IOException { + CompositeStatsProvider provider = CompositeStatsProvider.getInstance(); + if (provider != null && shardId != null) { + provider.unregister(shardId); + } IOUtils.closeWhileHandlingException(primaryEngine); secondaryEngines.forEach(IOUtils::closeWhileHandlingException); IOUtils.closeWhileHandlingException(committer); } + /** Returns this shard's composite stats tracker, used by the writer and merger to count. */ + public CompositeShardStatsTracker statsTracker() { + return statsTracker; + } + /** * Returns the primary delegate engine. * @@ -556,4 +605,12 @@ private ReaderManagerConfig readerManagerConfig(ReaderManagerConfig config, Data return secondaryEngines; } + @Override + public long maxIndexableDocs() { + long maxAllowedDocs = primaryEngine.maxIndexableDocs(); + for (var engine : secondaryEngines) { + maxAllowedDocs = Math.min(maxAllowedDocs, engine.maxIndexableDocs()); + } + return maxAllowedDocs; + } } diff --git a/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/CompositeWriter.java b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/CompositeWriter.java index 8999d645bce81..fe627f5c6271a 100644 --- a/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/CompositeWriter.java +++ b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/CompositeWriter.java @@ -13,6 +13,7 @@ import org.apache.logging.log4j.message.ParameterizedMessage; import org.opensearch.common.annotation.ExperimentalApi; import org.opensearch.common.util.io.IOUtils; +import org.opensearch.composite.stats.CompositeShardStatsTracker; import org.opensearch.index.engine.dataformat.DataFormat; import org.opensearch.index.engine.dataformat.DocumentInput; import org.opensearch.index.engine.dataformat.FileInfos; @@ -55,6 +56,7 @@ class CompositeWriter implements Writer { private final Map>> secondaryWritersByFormat; private final long writerGeneration; private final FailureHandlerStrategy failureHandler; + private final CompositeShardStatsTracker statsTracker; private volatile boolean closed; private long mappingVersion; /** Successful addDoc count — every incoming rowId must equal this. */ @@ -103,6 +105,7 @@ class CompositeWriter implements Writer { } this.secondaryWritersByFormat = Collections.unmodifiableMap(secondaries); this.failureHandler = new FailureHandlerStrategy(); + this.statsTracker = engine.statsTracker(); } @Override @@ -116,11 +119,15 @@ public WriteResult addDoc(CompositeDocumentInput doc) throws IOException { throw new IllegalStateException("rowId [" + doc.getRowId() + "] does not match accepted row count [" + acceptedRows + "]"); } + // Count every write attempt so write_*_failures can be read as a rate. + statsTracker.incWriteTotal(); + // Roll back exactly the writers we've called addDoc on, in order. List>> touched = new ArrayList<>(); touched.add(primaryWriter); WriteResult primaryResult = primaryWriter.addDoc(doc.getPrimaryInput()); if (primaryResult instanceof WriteResult.Failure pf) { + statsTracker.incWritePrimaryFailures(); logger.warn( () -> new ParameterizedMessage("Failed to add document in primary format [{}], rolling back", primaryFormat.name()), pf.cause() @@ -136,6 +143,7 @@ public WriteResult addDoc(CompositeDocumentInput doc) throws IOException { touched.add(writer); WriteResult result = writer.addDoc(inputEntry.getValue()); if (result instanceof WriteResult.Failure sf) { + statsTracker.incWriteSecondaryFailures(); logger.warn( () -> new ParameterizedMessage("Failed to add document in secondary format [{}], rolling back", format.name()), sf.cause() @@ -212,6 +220,7 @@ public long mappingVersion() { @Override public void updateMappingVersion(long newVersion) { if (newVersion > this.mappingVersion) { + statsTracker.incMappingUpdateExecutedTotal(); this.mappingVersion = newVersion; primaryWriter.updateMappingVersion(newVersion); for (Writer w : secondaryWritersByFormat.values()) { diff --git a/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/merge/CompositeMergeExecutor.java b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/merge/CompositeMergeExecutor.java index caf75785175db..d7b84da2fef08 100644 --- a/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/merge/CompositeMergeExecutor.java +++ b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/merge/CompositeMergeExecutor.java @@ -58,7 +58,36 @@ public MergeResult execute(MergePlan plan) { : null; for (DataFormat secondary : plan.secondaryFormats()) { - completed.add(mergeFormat(plan, secondary, mapping)); + FormatMergeResult secondaryResult = mergeFormat(plan, secondary, mapping); + // Verify secondary produced output when primary did + if (primaryResult.mergedFiles() != null && secondaryResult.mergedFiles() == null) { + throw new IllegalStateException( + "Primary format [" + + plan.primaryFormat().name() + + "] produced merged output but secondary format [" + + secondary.name() + + "] returned null — possible concurrent merge consumed segments" + ); + } + // Verify secondary merged row count matches primary + if (primaryResult.mergedFiles() != null && secondaryResult.mergedFiles() != null) { + long primaryRows = primaryResult.mergedFiles().numRows(); + long secondaryRows = secondaryResult.mergedFiles().numRows(); + if (primaryRows != secondaryRows) { + throw new IllegalStateException( + "Row count mismatch after merge: primary format [" + + plan.primaryFormat().name() + + "] has " + + primaryRows + + " rows but secondary format [" + + secondary.name() + + "] has " + + secondaryRows + + " rows" + ); + } + } + completed.add(secondaryResult); } return toMergeResult(completed, mapping); diff --git a/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/merge/CompositeMerger.java b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/merge/CompositeMerger.java index b32d50a1368f1..5c10c282ff0ce 100644 --- a/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/merge/CompositeMerger.java +++ b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/merge/CompositeMerger.java @@ -11,6 +11,7 @@ import org.opensearch.common.annotation.ExperimentalApi; import org.opensearch.composite.CompositeDataFormat; import org.opensearch.composite.CompositeIndexingExecutionEngine; +import org.opensearch.composite.stats.CompositeShardStatsTracker; import org.opensearch.index.engine.dataformat.DataFormat; import org.opensearch.index.engine.dataformat.IndexingExecutionEngine; import org.opensearch.index.engine.dataformat.MergeInput; @@ -18,6 +19,7 @@ import org.opensearch.index.engine.dataformat.Merger; import org.opensearch.index.engine.exec.Segment; import org.opensearch.index.engine.exec.WriterFileSet; +import org.opensearch.plugin.stats.StatsRecorder; import java.io.IOException; import java.util.ArrayList; @@ -40,18 +42,23 @@ public class CompositeMerger implements Merger { private final DataFormat primaryFormat; private final List secondaryFormats; private final CompositeMergeExecutor executor; + private final CompositeShardStatsTracker statsTracker; public CompositeMerger(CompositeIndexingExecutionEngine engine, CompositeDataFormat compositeDataFormat) { this.primaryFormat = compositeDataFormat.getPrimaryDataFormat(); this.secondaryFormats = resolveSecondaryFormats(compositeDataFormat, primaryFormat); this.executor = new CompositeMergeExecutor(buildMergerMap(engine)); + this.statsTracker = engine.statsTracker(); } @Override public MergeResult merge(MergeInput mergeInput) throws IOException { - Map> filesByFormat = extractFilesByFormat(mergeInput.segments()); - MergePlan plan = new MergePlan(mergeInput.newWriterGeneration(), primaryFormat, secondaryFormats, filesByFormat); - return executor.execute(plan); + // recordOutcome: time always, merge_total on success, merge_failures on throw. + return StatsRecorder.recordOutcome(() -> { + Map> filesByFormat = extractFilesByFormat(mergeInput.segments()); + MergePlan plan = new MergePlan(mergeInput.newWriterGeneration(), primaryFormat, secondaryFormats, filesByFormat); + return executor.execute(plan); + }, statsTracker::addMergeTimeMillis, statsTracker::incMergeTotal, statsTracker::incMergeFailures); } private Map> extractFilesByFormat(List segments) { diff --git a/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/CompositeShardStats.java b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/CompositeShardStats.java new file mode 100644 index 0000000000000..3d520c752e5d3 --- /dev/null +++ b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/CompositeShardStats.java @@ -0,0 +1,167 @@ +/* + * 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.composite.stats; + +import org.opensearch.common.annotation.ExperimentalApi; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.plugin.stats.DataFormatShardStats; + +import java.io.IOException; + +/** + * Immutable point-in-time snapshot of shard-level composite-engine statistics. + * Produced by {@link CompositeShardStatsTracker#stats()}. + * + * @opensearch.experimental + */ +@ExperimentalApi +public class CompositeShardStats implements DataFormatShardStats { + + // Refresh (engine-level, orchestrating all formats) + the merge-on-refresh breakdown. + private final long refreshTotal; + private final long refreshTimeMillis; + private final long refreshMergeTotal; + private final long refreshMergeTimeMillis; + private final long refreshMergeFailures; + + // Merge (standalone CompositeMerger path). + private final long mergeTotal; + private final long mergeTimeMillis; + private final long mergeFailures; + + // Write attempts + failures, split by primary vs secondary format. + private final long writeTotal; + private final long writePrimaryFailures; + private final long writeSecondaryFailures; + + // Dynamic mapping updates that were actually applied (newVersion > current), not no-op calls. + private final long mappingUpdateExecutedTotal; + + /** + * Returns an empty snapshot with all zero counters. Used by transport actions when a + * shard has no composite engine. + */ + public static CompositeShardStats empty() { + return new CompositeShardStats(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + } + + /** Constructs a snapshot with all values. */ + public CompositeShardStats( + long refreshTotal, + long refreshTimeMillis, + long refreshMergeTotal, + long refreshMergeTimeMillis, + long refreshMergeFailures, + long mergeTotal, + long mergeTimeMillis, + long mergeFailures, + long writeTotal, + long writePrimaryFailures, + long writeSecondaryFailures, + long mappingUpdateExecutedTotal + ) { + this.refreshTotal = refreshTotal; + this.refreshTimeMillis = refreshTimeMillis; + this.refreshMergeTotal = refreshMergeTotal; + this.refreshMergeTimeMillis = refreshMergeTimeMillis; + this.refreshMergeFailures = refreshMergeFailures; + this.mergeTotal = mergeTotal; + this.mergeTimeMillis = mergeTimeMillis; + this.mergeFailures = mergeFailures; + this.writeTotal = writeTotal; + this.writePrimaryFailures = writePrimaryFailures; + this.writeSecondaryFailures = writeSecondaryFailures; + this.mappingUpdateExecutedTotal = mappingUpdateExecutedTotal; + } + + public CompositeShardStats(StreamInput in) throws IOException { + this.refreshTotal = in.readVLong(); + this.refreshTimeMillis = in.readVLong(); + this.refreshMergeTotal = in.readVLong(); + this.refreshMergeTimeMillis = in.readVLong(); + this.refreshMergeFailures = in.readVLong(); + this.mergeTotal = in.readVLong(); + this.mergeTimeMillis = in.readVLong(); + this.mergeFailures = in.readVLong(); + this.writeTotal = in.readVLong(); + this.writePrimaryFailures = in.readVLong(); + this.writeSecondaryFailures = in.readVLong(); + this.mappingUpdateExecutedTotal = in.readVLong(); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeVLong(refreshTotal); + out.writeVLong(refreshTimeMillis); + out.writeVLong(refreshMergeTotal); + out.writeVLong(refreshMergeTimeMillis); + out.writeVLong(refreshMergeFailures); + out.writeVLong(mergeTotal); + out.writeVLong(mergeTimeMillis); + out.writeVLong(mergeFailures); + out.writeVLong(writeTotal); + out.writeVLong(writePrimaryFailures); + out.writeVLong(writeSecondaryFailures); + out.writeVLong(mappingUpdateExecutedTotal); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + // Refresh — includes the merge-on-refresh breakdown. + builder.startObject("refresh"); + builder.field("refresh_total", refreshTotal); + builder.field("refresh_time_millis", refreshTimeMillis); + builder.field("refresh_merge_total", refreshMergeTotal); + builder.field("refresh_merge_time_millis", refreshMergeTimeMillis); + builder.field("refresh_merge_failures", refreshMergeFailures); + builder.endObject(); + + // Merge — standalone merger path. + builder.startObject("merge"); + builder.field("merge_total", mergeTotal); + builder.field("merge_time_millis", mergeTimeMillis); + builder.field("merge_failures", mergeFailures); + builder.endObject(); + + // Write attempts + failures by format role. + builder.startObject("write"); + builder.field("write_total", writeTotal); + builder.field("write_primary_failures", writePrimaryFailures); + builder.field("write_secondary_failures", writeSecondaryFailures); + builder.endObject(); + + // Dynamic mapping updates actually applied. + builder.startObject("mapping"); + builder.field("mapping_update_executed_total", mappingUpdateExecutedTotal); + builder.endObject(); + + return builder; + } + + /** Returns a new snapshot that is the element-wise sum of this and another. */ + @Override + public CompositeShardStats add(CompositeShardStats other) { + return new CompositeShardStats( + this.refreshTotal + other.refreshTotal, + this.refreshTimeMillis + other.refreshTimeMillis, + this.refreshMergeTotal + other.refreshMergeTotal, + this.refreshMergeTimeMillis + other.refreshMergeTimeMillis, + this.refreshMergeFailures + other.refreshMergeFailures, + this.mergeTotal + other.mergeTotal, + this.mergeTimeMillis + other.mergeTimeMillis, + this.mergeFailures + other.mergeFailures, + this.writeTotal + other.writeTotal, + this.writePrimaryFailures + other.writePrimaryFailures, + this.writeSecondaryFailures + other.writeSecondaryFailures, + this.mappingUpdateExecutedTotal + other.mappingUpdateExecutedTotal + ); + } +} diff --git a/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/CompositeShardStatsTracker.java b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/CompositeShardStatsTracker.java new file mode 100644 index 0000000000000..d8bcd8a447d2d --- /dev/null +++ b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/CompositeShardStatsTracker.java @@ -0,0 +1,118 @@ +/* + * 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.composite.stats; + +import org.opensearch.common.annotation.ExperimentalApi; + +import java.util.concurrent.atomic.LongAdder; + +/** + * Mutable, thread-safe shard-level statistics tracker for the composite engine. + * Uses {@link LongAdder} for high-throughput counters. Call {@link #stats()} for an + * immutable {@link CompositeShardStats} snapshot. + * + * @opensearch.experimental + */ +@ExperimentalApi +public class CompositeShardStatsTracker { + + // Refresh + merge-on-refresh breakdown. + private final LongAdder refreshTotal = new LongAdder(); + private final LongAdder refreshTimeMillis = new LongAdder(); + private final LongAdder refreshMergeTotal = new LongAdder(); + private final LongAdder refreshMergeTimeMillis = new LongAdder(); + private final LongAdder refreshMergeFailures = new LongAdder(); + + // Standalone merge. + private final LongAdder mergeTotal = new LongAdder(); + private final LongAdder mergeTimeMillis = new LongAdder(); + private final LongAdder mergeFailures = new LongAdder(); + + // Write attempts + failures by format role. + private final LongAdder writeTotal = new LongAdder(); + private final LongAdder writePrimaryFailures = new LongAdder(); + private final LongAdder writeSecondaryFailures = new LongAdder(); + + // Dynamic mapping updates actually applied. + private final LongAdder mappingUpdateExecutedTotal = new LongAdder(); + + /** Returns an immutable point-in-time snapshot of all tracked statistics. */ + public CompositeShardStats stats() { + return new CompositeShardStats( + refreshTotal.sum(), + refreshTimeMillis.sum(), + refreshMergeTotal.sum(), + refreshMergeTimeMillis.sum(), + refreshMergeFailures.sum(), + mergeTotal.sum(), + mergeTimeMillis.sum(), + mergeFailures.sum(), + writeTotal.sum(), + writePrimaryFailures.sum(), + writeSecondaryFailures.sum(), + mappingUpdateExecutedTotal.sum() + ); + } + + // --- Refresh --- + + public void incRefreshTotal() { + refreshTotal.increment(); + } + + public void addRefreshTimeMillis(long ms) { + refreshTimeMillis.add(ms); + } + + public void incRefreshMergeTotal() { + refreshMergeTotal.increment(); + } + + public void addRefreshMergeTimeMillis(long ms) { + refreshMergeTimeMillis.add(ms); + } + + public void incRefreshMergeFailures() { + refreshMergeFailures.increment(); + } + + // --- Merge --- + + public void incMergeTotal() { + mergeTotal.increment(); + } + + public void addMergeTimeMillis(long ms) { + mergeTimeMillis.add(ms); + } + + public void incMergeFailures() { + mergeFailures.increment(); + } + + // --- Write --- + + public void incWriteTotal() { + writeTotal.increment(); + } + + public void incWritePrimaryFailures() { + writePrimaryFailures.increment(); + } + + public void incWriteSecondaryFailures() { + writeSecondaryFailures.increment(); + } + + // --- Mapping --- + + public void incMappingUpdateExecutedTotal() { + mappingUpdateExecutedTotal.increment(); + } +} diff --git a/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/CompositeStatsProvider.java b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/CompositeStatsProvider.java new file mode 100644 index 0000000000000..2f34992549663 --- /dev/null +++ b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/CompositeStatsProvider.java @@ -0,0 +1,100 @@ +/* + * 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.composite.stats; + +import org.opensearch.common.annotation.ExperimentalApi; +import org.opensearch.composite.CompositeDataFormat; +import org.opensearch.core.common.io.stream.Writeable; +import org.opensearch.core.index.shard.ShardId; +import org.opensearch.plugin.stats.DataFormatStatsProvider; +import org.opensearch.plugin.stats.DataFormatStatsProviderRegistry; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Composite-engine implementation of {@link DataFormatStatsProvider}. + * + *

    Maintains a per-shard registry of {@link CompositeShardStatsTracker} instances. + * {@code CompositeIndexingExecutionEngine} self-registers on construction and unregisters + * on close. The plugin's REST + transport classes read stats from this provider. + * + *

    Singleton pattern: a static {@code INSTANCE} is set during plugin construction so the + * engine/writer/merger layers can reach the tracker without DI plumbing. + * + * @opensearch.experimental + */ +@ExperimentalApi +public final class CompositeStatsProvider implements DataFormatStatsProvider { + + public static final String FORMAT_NAME = CompositeDataFormat.COMPOSITE_FORMAT_NAME; + + private static volatile CompositeStatsProvider INSTANCE; + + private final Map trackers = new ConcurrentHashMap<>(); + + public CompositeStatsProvider() { + // First instance wins. Subsequent constructions are no-ops on the singleton slot. + if (INSTANCE == null) { + INSTANCE = this; + } + DataFormatStatsProviderRegistry.INSTANCE.register(this); + } + + /** Returns the singleton, or {@code null} if the plugin has not been constructed yet. */ + public static CompositeStatsProvider getInstance() { + return INSTANCE; + } + + /** Registers a tracker for a shard. Called from the composite engine's constructor. */ + public void register(ShardId shardId, CompositeShardStatsTracker tracker) { + trackers.put(shardId, tracker); + } + + /** Unregisters a tracker. Called from the engine's {@code close()}. */ + public void unregister(ShardId shardId) { + trackers.remove(shardId); + } + + /** Returns the tracker for a shard, or {@code null} if none — used by writer/merger to count. */ + public CompositeShardStatsTracker getTracker(ShardId shardId) { + return trackers.get(shardId); + } + + // --- DataFormatStatsProvider --- + + @Override + public String formatName() { + return FORMAT_NAME; + } + + @Override + public Optional shardStats(ShardId shardId) { + CompositeShardStatsTracker tracker = trackers.get(shardId); + return tracker == null ? Optional.empty() : Optional.of(tracker.stats()); + } + + @Override + public Optional aggregateNodeStats() { + if (trackers.isEmpty()) { + return Optional.empty(); + } + CompositeShardStats agg = CompositeShardStats.empty(); + for (CompositeShardStatsTracker t : trackers.values()) { + agg = agg.add(t.stats()); + } + return Optional.of(agg); + } + + @Override + public Writeable.Reader shardStatsReader() { + return CompositeShardStats::new; + } +} diff --git a/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/package-info.java b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/package-info.java new file mode 100644 index 0000000000000..9fc811468697e --- /dev/null +++ b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/package-info.java @@ -0,0 +1,14 @@ +/* + * 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. + */ + +/** + * Statistics collection for the composite engine plugin. + * + * @opensearch.experimental + */ +package org.opensearch.composite.stats; diff --git a/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/transport/CompositeNodeStatsActionType.java b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/transport/CompositeNodeStatsActionType.java new file mode 100644 index 0000000000000..b4aa957ea1b34 --- /dev/null +++ b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/transport/CompositeNodeStatsActionType.java @@ -0,0 +1,30 @@ +/* + * 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.composite.stats.transport; + +import org.opensearch.common.annotation.ExperimentalApi; +import org.opensearch.composite.stats.CompositeShardStats; +import org.opensearch.composite.stats.CompositeStatsProvider; +import org.opensearch.plugin.stats.transport.FormatNodeStatsActionType; + +/** + * Action type for the composite per-node stats endpoint + * ({@code GET /_plugins/composite/_nodes/[{nodeId}/]_stats}). + * + * @opensearch.experimental + */ +@ExperimentalApi +public final class CompositeNodeStatsActionType extends FormatNodeStatsActionType { + + public static final CompositeNodeStatsActionType INSTANCE = new CompositeNodeStatsActionType(); + + private CompositeNodeStatsActionType() { + super(CompositeStatsProvider.FORMAT_NAME, CompositeShardStats::new); + } +} diff --git a/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/transport/CompositeNodeStatsRestAction.java b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/transport/CompositeNodeStatsRestAction.java new file mode 100644 index 0000000000000..bc1b395c8de93 --- /dev/null +++ b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/transport/CompositeNodeStatsRestAction.java @@ -0,0 +1,34 @@ +/* + * 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.composite.stats.transport; + +import org.opensearch.action.ActionType; +import org.opensearch.common.annotation.ExperimentalApi; +import org.opensearch.composite.stats.CompositeStatsProvider; +import org.opensearch.plugin.stats.transport.BaseFormatNodeStatsRestAction; +import org.opensearch.plugin.stats.transport.FormatNodeStatsResponse; + +/** + * REST handler for {@code GET /_plugins/composite/_nodes/[{nodeId}/]_stats}. + * + * @opensearch.experimental + */ +@ExperimentalApi +public final class CompositeNodeStatsRestAction extends BaseFormatNodeStatsRestAction { + + @Override + protected String formatName() { + return CompositeStatsProvider.FORMAT_NAME; + } + + @Override + protected ActionType> actionType() { + return CompositeNodeStatsActionType.INSTANCE; + } +} diff --git a/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/transport/CompositeNodeStatsTransportAction.java b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/transport/CompositeNodeStatsTransportAction.java new file mode 100644 index 0000000000000..6016cbe508804 --- /dev/null +++ b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/transport/CompositeNodeStatsTransportAction.java @@ -0,0 +1,46 @@ +/* + * 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.composite.stats.transport; + +import org.opensearch.action.support.ActionFilters; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.annotation.ExperimentalApi; +import org.opensearch.common.inject.Inject; +import org.opensearch.composite.stats.CompositeShardStats; +import org.opensearch.composite.stats.CompositeStatsProvider; +import org.opensearch.plugin.stats.transport.BaseTransportFormatNodeStatsAction; +import org.opensearch.threadpool.ThreadPool; +import org.opensearch.transport.TransportService; + +/** + * Per-node stats transport action for the composite engine. + * + * @opensearch.experimental + */ +@ExperimentalApi +public final class CompositeNodeStatsTransportAction extends BaseTransportFormatNodeStatsAction { + + @Inject + public CompositeNodeStatsTransportAction( + ThreadPool threadPool, + ClusterService clusterService, + TransportService transportService, + ActionFilters actionFilters + ) { + super( + CompositeNodeStatsActionType.INSTANCE.name(), + CompositeStatsProvider.FORMAT_NAME, + CompositeShardStats::new, + threadPool, + clusterService, + transportService, + actionFilters + ); + } +} diff --git a/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/transport/CompositeStatsActionType.java b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/transport/CompositeStatsActionType.java new file mode 100644 index 0000000000000..ea3dd7b99df0a --- /dev/null +++ b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/transport/CompositeStatsActionType.java @@ -0,0 +1,30 @@ +/* + * 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.composite.stats.transport; + +import org.opensearch.common.annotation.ExperimentalApi; +import org.opensearch.composite.stats.CompositeShardStats; +import org.opensearch.composite.stats.CompositeStatsProvider; +import org.opensearch.plugin.stats.transport.FormatStatsActionType; + +/** + * Action type for the composite per-index stats endpoint + * ({@code GET /_plugins/composite/{index}/_stats}). + * + * @opensearch.experimental + */ +@ExperimentalApi +public final class CompositeStatsActionType extends FormatStatsActionType { + + public static final CompositeStatsActionType INSTANCE = new CompositeStatsActionType(); + + private CompositeStatsActionType() { + super(CompositeStatsProvider.FORMAT_NAME, CompositeShardStats::new); + } +} diff --git a/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/transport/CompositeStatsRestAction.java b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/transport/CompositeStatsRestAction.java new file mode 100644 index 0000000000000..a9bd710d70baa --- /dev/null +++ b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/transport/CompositeStatsRestAction.java @@ -0,0 +1,34 @@ +/* + * 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.composite.stats.transport; + +import org.opensearch.action.ActionType; +import org.opensearch.common.annotation.ExperimentalApi; +import org.opensearch.composite.stats.CompositeStatsProvider; +import org.opensearch.plugin.stats.transport.BaseFormatStatsRestAction; +import org.opensearch.plugin.stats.transport.FormatStatsResponse; + +/** + * REST handler for {@code GET /_plugins/composite/{index}/_stats}. + * + * @opensearch.experimental + */ +@ExperimentalApi +public final class CompositeStatsRestAction extends BaseFormatStatsRestAction { + + @Override + protected String formatName() { + return CompositeStatsProvider.FORMAT_NAME; + } + + @Override + protected ActionType> actionType() { + return CompositeStatsActionType.INSTANCE; + } +} diff --git a/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/transport/CompositeStatsTransportAction.java b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/transport/CompositeStatsTransportAction.java new file mode 100644 index 0000000000000..aee0871c3c339 --- /dev/null +++ b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/transport/CompositeStatsTransportAction.java @@ -0,0 +1,49 @@ +/* + * 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.composite.stats.transport; + +import org.opensearch.action.support.ActionFilters; +import org.opensearch.cluster.metadata.IndexNameExpressionResolver; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.annotation.ExperimentalApi; +import org.opensearch.common.inject.Inject; +import org.opensearch.composite.stats.CompositeShardStats; +import org.opensearch.composite.stats.CompositeStatsProvider; +import org.opensearch.plugin.stats.transport.BaseTransportFormatStatsAction; +import org.opensearch.transport.TransportService; + +/** + * Per-index stats transport action for the composite engine. + * + *

    All broadcast/aggregation logic lives in {@link BaseTransportFormatStatsAction}; this + * class supplies the action name, format name, and the {@link CompositeShardStats} reader. + * + * @opensearch.experimental + */ +@ExperimentalApi +public final class CompositeStatsTransportAction extends BaseTransportFormatStatsAction { + + @Inject + public CompositeStatsTransportAction( + ClusterService clusterService, + TransportService transportService, + ActionFilters actionFilters, + IndexNameExpressionResolver indexNameExpressionResolver + ) { + super( + CompositeStatsActionType.INSTANCE.name(), + CompositeStatsProvider.FORMAT_NAME, + CompositeShardStats::new, + clusterService, + transportService, + actionFilters, + indexNameExpressionResolver + ); + } +} diff --git a/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/transport/package-info.java b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/transport/package-info.java new file mode 100644 index 0000000000000..5ae87b4bdec61 --- /dev/null +++ b/sandbox/plugins/composite-engine/src/main/java/org/opensearch/composite/stats/transport/package-info.java @@ -0,0 +1,22 @@ +/* + * 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. + */ + +/** + * Transport and REST actions for the composite per-format statistics endpoints. + * + *

    Wires {@link org.opensearch.composite.stats.CompositeStatsProvider} into the + * {@code /_plugins/composite/{index}/_stats} and {@code /_plugins/composite/_nodes/_stats} + * REST + transport surfaces by extending the abstract bases in + * {@link org.opensearch.plugin.stats.transport}. + * + * @opensearch.experimental + */ +@ExperimentalApi +package org.opensearch.composite.stats.transport; + +import org.opensearch.common.annotation.ExperimentalApi; diff --git a/sandbox/plugins/composite-engine/src/test/java/org/opensearch/composite/merge/CompositeMergerTests.java b/sandbox/plugins/composite-engine/src/test/java/org/opensearch/composite/merge/CompositeMergerTests.java index d9ed61be931fa..fee013931652e 100644 --- a/sandbox/plugins/composite-engine/src/test/java/org/opensearch/composite/merge/CompositeMergerTests.java +++ b/sandbox/plugins/composite-engine/src/test/java/org/opensearch/composite/merge/CompositeMergerTests.java @@ -14,12 +14,14 @@ import org.opensearch.common.settings.Settings; import org.opensearch.composite.CompositeDataFormat; import org.opensearch.composite.CompositeIndexingExecutionEngine; +import org.opensearch.composite.stats.CompositeShardStatsTracker; import org.opensearch.core.index.Index; import org.opensearch.core.index.shard.ShardId; import org.opensearch.index.IndexSettings; import org.opensearch.index.engine.dataformat.DataFormat; import org.opensearch.index.engine.dataformat.FieldTypeCapabilities; import org.opensearch.index.engine.dataformat.IndexingExecutionEngine; +import org.opensearch.index.engine.dataformat.MergeInput; import org.opensearch.index.engine.dataformat.MergeResult; import org.opensearch.index.engine.dataformat.Merger; import org.opensearch.index.engine.dataformat.PackedRowIdMapping; @@ -79,6 +81,7 @@ public void setUp() throws Exception { IndexingExecutionEngine secondaryEngine = mockEngine(secondaryFormat, secondaryMerger); compositeEngine = mock(CompositeIndexingExecutionEngine.class); + when(compositeEngine.statsTracker()).thenReturn(new CompositeShardStatsTracker()); doReturn(primaryEngine).when(compositeEngine).getPrimaryDelegate(); doReturn(Set.of(secondaryEngine)).when(compositeEngine).getSecondaryDelegates(); when(compositeEngine.getNextWriterGeneration()).thenReturn(99L); @@ -118,6 +121,7 @@ public void testDoMergeSuccessWithPrimaryAndSecondary() throws IOException { public void testDoMergePrimaryOnlyNoSecondaries() throws IOException { CompositeIndexingExecutionEngine engineNoSecondary = mock(CompositeIndexingExecutionEngine.class); + when(engineNoSecondary.statsTracker()).thenReturn(new CompositeShardStatsTracker()); IndexingExecutionEngine primaryEngine = mockEngine(primaryFormat, primaryMerger); doReturn(primaryEngine).when(engineNoSecondary).getPrimaryDelegate(); doReturn(Set.of()).when(engineNoSecondary).getSecondaryDelegates(); @@ -193,6 +197,7 @@ public void testDoMergeMultipleSecondariesFailsFastOnFirstError() throws IOExcep Merger secondaryMerger2 = mock(Merger.class); CompositeIndexingExecutionEngine multiEngine = mock(CompositeIndexingExecutionEngine.class); + when(multiEngine.statsTracker()).thenReturn(new CompositeShardStatsTracker()); IndexingExecutionEngine primaryEngine = mockEngine(primaryFormat, primaryMerger); doReturn(primaryEngine).when(multiEngine).getPrimaryDelegate(); doReturn(Set.of(mockEngine(secondaryFormat, secondaryMerger), mockEngine(secondaryFormat2, secondaryMerger2))).when(multiEngine) @@ -354,6 +359,7 @@ public void testDoMergeSkipsSecondaryThatEqualsPrimary() throws IOException { IndexingExecutionEngine duplicateEngine = mockEngine(primaryFormat, primaryMerger); CompositeIndexingExecutionEngine dupEngine = mock(CompositeIndexingExecutionEngine.class); + when(dupEngine.statsTracker()).thenReturn(new CompositeShardStatsTracker()); doReturn(primaryEngine).when(dupEngine).getPrimaryDelegate(); doReturn(Set.of(duplicateEngine)).when(dupEngine).getSecondaryDelegates(); when(dupEngine.getNextWriterGeneration()).thenReturn(99L); @@ -638,4 +644,79 @@ private static CatalogSnapshot mockCatalogSnapshot(List segments) { when(snapshot.getSegments()).thenReturn(segments); return snapshot; } + + // ── Cross-format merge verification tests ── + + public void testExecutorThrowsWhenSecondaryReturnsNullButPrimaryHasOutput() throws IOException { + Merger primaryMerger = mock(Merger.class); + Merger secondaryMerger = mock(Merger.class); + + DataFormat primary = stubFormat("parquet", 0); + DataFormat secondary = stubFormat("lucene", 50); + + String dir = createTempDir().toString(); + WriterFileSet primaryFiles = new WriterFileSet(dir, 10L, Set.of("file.parquet"), 100, 1L); + + RowIdMapping mapping = mock(RowIdMapping.class); + when(mapping.size()).thenReturn(100); + + when(primaryMerger.merge(any(MergeInput.class))).thenReturn(new MergeResult(Map.of(primary, primaryFiles), mapping)); + when(secondaryMerger.merge(any(MergeInput.class))).thenReturn(new MergeResult(Map.of())); + + CompositeMergeExecutor executor = new CompositeMergeExecutor(Map.of(primary, primaryMerger, secondary, secondaryMerger)); + + WriterFileSet inputP = new WriterFileSet(createTempDir().toString(), 1L, Set.of("in.parquet"), 50, 1L); + WriterFileSet inputS = new WriterFileSet(createTempDir().toString(), 1L, Set.of("in.si"), 50, 1L); + + MergePlan plan = new MergePlan(10L, primary, List.of(secondary), Map.of(primary, List.of(inputP), secondary, List.of(inputS))); + + IllegalStateException ex = expectThrows(IllegalStateException.class, () -> executor.execute(plan)); + assertTrue(ex.getMessage().contains("returned null")); + } + + public void testExecutorThrowsOnRowCountMismatch() throws IOException { + Merger primaryMerger = mock(Merger.class); + Merger secondaryMerger = mock(Merger.class); + + DataFormat primary = stubFormat("parquet", 0); + DataFormat secondary = stubFormat("lucene", 50); + + WriterFileSet primaryFiles = new WriterFileSet(createTempDir().toString(), 10L, Set.of("file.parquet"), 100, 1L); + WriterFileSet secondaryFiles = new WriterFileSet(createTempDir().toString(), 10L, Set.of("file.si"), 90, 1L); + + RowIdMapping mapping = mock(RowIdMapping.class); + when(mapping.size()).thenReturn(100); + + when(primaryMerger.merge(any(MergeInput.class))).thenReturn(new MergeResult(Map.of(primary, primaryFiles), mapping)); + when(secondaryMerger.merge(any(MergeInput.class))).thenReturn(new MergeResult(Map.of(secondary, secondaryFiles))); + + CompositeMergeExecutor executor = new CompositeMergeExecutor(Map.of(primary, primaryMerger, secondary, secondaryMerger)); + + WriterFileSet inputP = new WriterFileSet(createTempDir().toString(), 1L, Set.of("in.parquet"), 50, 1L); + WriterFileSet inputS = new WriterFileSet(createTempDir().toString(), 1L, Set.of("in.si"), 50, 1L); + + MergePlan plan = new MergePlan(10L, primary, List.of(secondary), Map.of(primary, List.of(inputP), secondary, List.of(inputS))); + + IllegalStateException ex = expectThrows(IllegalStateException.class, () -> executor.execute(plan)); + assertTrue(ex.getMessage().contains("Row count mismatch")); + } + + private static DataFormat stubFormat(String name, long priority) { + return new DataFormat() { + @Override + public String name() { + return name; + } + + @Override + public long priority() { + return priority; + } + + @Override + public Set supportedFields() { + return Set.of(); + } + }; + } } diff --git a/sandbox/plugins/composite-engine/src/test/java/org/opensearch/composite/stats/CompositeShardStatsTests.java b/sandbox/plugins/composite-engine/src/test/java/org/opensearch/composite/stats/CompositeShardStatsTests.java new file mode 100644 index 0000000000000..d4eda10758ad9 --- /dev/null +++ b/sandbox/plugins/composite-engine/src/test/java/org/opensearch/composite/stats/CompositeShardStatsTests.java @@ -0,0 +1,112 @@ +/* + * 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.composite.stats; + +import org.opensearch.common.io.stream.BytesStreamOutput; +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.common.xcontent.XContentHelper; +import org.opensearch.common.xcontent.json.JsonXContent; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.test.OpenSearchTestCase; + +import java.util.Map; + +/** + * Unit tests for {@link CompositeShardStats} and {@link CompositeShardStatsTracker}: + * counter increments, snapshot fidelity, aggregation, and wire/XContent round-trips. + */ +public class CompositeShardStatsTests extends OpenSearchTestCase { + + public void testTrackerCountersFlowIntoSnapshot() { + CompositeShardStatsTracker tracker = new CompositeShardStatsTracker(); + tracker.incRefreshTotal(); + tracker.addRefreshTimeMillis(10); + tracker.incRefreshMergeTotal(); + tracker.addRefreshMergeTimeMillis(4); + tracker.incRefreshMergeFailures(); + tracker.incMergeTotal(); + tracker.addMergeTimeMillis(7); + tracker.incMergeFailures(); + tracker.incWriteTotal(); + tracker.incWritePrimaryFailures(); + tracker.incWriteSecondaryFailures(); + tracker.incMappingUpdateExecutedTotal(); + + CompositeShardStats s = tracker.stats(); + Map json = toMap(s); + + assertEquals(1L, get(json, "refresh.refresh_total")); + assertEquals(10L, get(json, "refresh.refresh_time_millis")); + assertEquals(1L, get(json, "refresh.refresh_merge_total")); + assertEquals(4L, get(json, "refresh.refresh_merge_time_millis")); + assertEquals(1L, get(json, "refresh.refresh_merge_failures")); + assertEquals(1L, get(json, "merge.merge_total")); + assertEquals(7L, get(json, "merge.merge_time_millis")); + assertEquals(1L, get(json, "merge.merge_failures")); + assertEquals(1L, get(json, "write.write_total")); + assertEquals(1L, get(json, "write.write_primary_failures")); + assertEquals(1L, get(json, "write.write_secondary_failures")); + assertEquals(1L, get(json, "mapping.mapping_update_executed_total")); + } + + public void testEmptyIsAllZero() { + Map json = toMap(CompositeShardStats.empty()); + assertEquals(0L, get(json, "refresh.refresh_total")); + assertEquals(0L, get(json, "merge.merge_failures")); + assertEquals(0L, get(json, "write.write_primary_failures")); + assertEquals(0L, get(json, "mapping.mapping_update_executed_total")); + } + + public void testAddSumsAllCounters() { + CompositeShardStats a = new CompositeShardStats(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12); + CompositeShardStats b = new CompositeShardStats(10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120); + Map json = toMap(a.add(b)); + + assertEquals(11L, get(json, "refresh.refresh_total")); + assertEquals(33L, get(json, "refresh.refresh_merge_total")); + assertEquals(55L, get(json, "refresh.refresh_merge_failures")); + assertEquals(66L, get(json, "merge.merge_total")); + assertEquals(88L, get(json, "merge.merge_failures")); + assertEquals(99L, get(json, "write.write_total")); + assertEquals(110L, get(json, "write.write_primary_failures")); + assertEquals(132L, get(json, "mapping.mapping_update_executed_total")); + } + + public void testStreamRoundTrip() throws Exception { + CompositeShardStats original = new CompositeShardStats(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12); + BytesStreamOutput out = new BytesStreamOutput(); + original.writeTo(out); + try (StreamInput in = out.bytes().streamInput()) { + CompositeShardStats restored = new CompositeShardStats(in); + assertEquals(toMap(original), toMap(restored)); + } + } + + private static Map toMap(CompositeShardStats stats) { + try { + var builder = XContentFactory.jsonBuilder().startObject(); + stats.toXContent(builder, ToXContent.EMPTY_PARAMS); + builder.endObject(); + return XContentHelper.convertToMap(JsonXContent.jsonXContent, builder.toString(), true); + } catch (Exception e) { + throw new AssertionError(e); + } + } + + @SuppressWarnings("unchecked") + private static long get(Map json, String dotted) { + String[] parts = dotted.split("\\."); + Object cur = json; + for (String p : parts) { + cur = ((Map) cur).get(p); + } + return ((Number) cur).longValue(); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslIntegTestBase.java b/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslIntegTestBase.java index f502fcb5f5635..ea56dda24f678 100644 --- a/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslIntegTestBase.java +++ b/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslIntegTestBase.java @@ -39,7 +39,6 @@ protected void createTestIndex() { createIndex(INDEX); ensureGreen(); client().prepareIndex(INDEX) - .setId("1") .setSource("{\"name\":\"laptop\",\"price\":1200,\"brand\":\"brandX\",\"rating\":4.5}", XContentType.JSON) .get(); refresh(INDEX); diff --git a/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslQueryExecutorIT.java b/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslQueryExecutorIT.java index b449bf22f2768..11a00cb1fc0d1 100644 --- a/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslQueryExecutorIT.java +++ b/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslQueryExecutorIT.java @@ -68,7 +68,7 @@ public void testSearchFailsForMultipleIndices() { private void createTestIndex() { createIndex(INDEX); ensureGreen(); - client().prepareIndex(INDEX).setId("1").setSource("{\"name\":\"laptop\",\"price\":1200}", XContentType.JSON).get(); + client().prepareIndex(INDEX).setSource("{\"name\":\"laptop\",\"price\":1200}", XContentType.JSON).get(); refresh(INDEX); } } diff --git a/sandbox/plugins/native-repository-azure/src/main/rust/src/azure.rs b/sandbox/plugins/native-repository-azure/src/main/rust/src/azure.rs index 06cb27e1cfd47..49f73747d6188 100644 --- a/sandbox/plugins/native-repository-azure/src/main/rust/src/azure.rs +++ b/sandbox/plugins/native-repository-azure/src/main/rust/src/azure.rs @@ -55,11 +55,25 @@ pub fn build( if config.max_retries.is_some() || config.retry_timeout_ms.is_some() { let mut retry = RetryConfig::default(); - if let Some(m) = config.max_retries { retry.max_retries = m; } - if let Some(ms) = config.retry_timeout_ms { retry.retry_timeout = Duration::from_millis(ms); } + if let Some(m) = config.max_retries { + retry.max_retries = m; + } + if let Some(ms) = config.retry_timeout_ms { + retry.retry_timeout = Duration::from_millis(ms); + } builder = builder.with_retry(retry); } + // If the analytics engine has installed a dedicated IO runtime, route HTTP + // requests (and response-body streaming) onto it via SpawnedReqwestConnector + // — DataFusion's `thread_pools` example mechanism. This keeps network IO and + // its completion work (TLS, body assembly) off the CPU runtime that drives + // query decode. No-op when no IO runtime is installed (e.g. unit tests). + if let Some(io) = native_bridge_common::io_runtime::io_handle() { + builder = + builder.with_http_connector(object_store::client::SpawnedReqwestConnector::new(io)); + } + Ok(Arc::new(builder.build()?)) } diff --git a/sandbox/plugins/native-repository-azure/src/main/rust/src/lib.rs b/sandbox/plugins/native-repository-azure/src/main/rust/src/lib.rs index e66ea544a310a..96c2614721b2c 100644 --- a/sandbox/plugins/native-repository-azure/src/main/rust/src/lib.rs +++ b/sandbox/plugins/native-repository-azure/src/main/rust/src/lib.rs @@ -16,8 +16,8 @@ pub mod azure; pub use azure::build; pub use azure::AzureConfig; -use std::sync::Arc; use object_store::ObjectStore; +use std::sync::Arc; /// Create an Azure [`ObjectStore`] from JSON config and return a boxed Arc pointer. /// @@ -40,7 +40,12 @@ pub unsafe extern "C" fn azure_create_store( let config_json = std::str::from_utf8(bytes).map_err(|e| format!("invalid UTF-8: {}", e))?; let credentials = if cred_provider_ptr != 0 { let provider = Box::from_raw( - cred_provider_ptr as *mut Arc>, + cred_provider_ptr + as *mut Arc< + dyn object_store::CredentialProvider< + Credential = object_store::azure::AzureCredential, + >, + >, ); Some(*provider) } else { @@ -56,7 +61,9 @@ pub unsafe extern "C" fn azure_create_store( #[native_bridge_common::ffm_safe] #[no_mangle] pub unsafe extern "C" fn azure_destroy_store(ptr: i64) -> i64 { - if ptr == 0 { return Err("azure_destroy_store: null pointer".to_string()); } + if ptr == 0 { + return Err("azure_destroy_store: null pointer".to_string()); + } let _: Box> = Box::from_raw(ptr as *mut Arc); Ok(0) } diff --git a/sandbox/plugins/native-repository-fs/src/main/rust/src/fs.rs b/sandbox/plugins/native-repository-fs/src/main/rust/src/fs.rs index 3d943e678c165..890a7e5e22d69 100644 --- a/sandbox/plugins/native-repository-fs/src/main/rust/src/fs.rs +++ b/sandbox/plugins/native-repository-fs/src/main/rust/src/fs.rs @@ -33,9 +33,9 @@ pub fn build( #[cfg(test)] mod tests { use super::*; + use futures::TryStreamExt; use object_store::path::Path; use object_store::{ObjectStoreExt, PutPayload}; - use futures::TryStreamExt; #[test] fn test_build_with_valid_path() { @@ -64,7 +64,10 @@ mod tests { let data = b"hello parquet world"; // Write - store.put(&path, PutPayload::from_static(data)).await.unwrap(); + store + .put(&path, PutPayload::from_static(data)) + .await + .unwrap(); // Read let result = store.get(&path).await.unwrap(); @@ -81,7 +84,10 @@ mod tests { let path = Path::from("sized_file.dat"); let data = b"exactly 26 bytes of data!!"; - store.put(&path, PutPayload::from_static(data)).await.unwrap(); + store + .put(&path, PutPayload::from_static(data)) + .await + .unwrap(); let meta = store.head(&path).await.unwrap(); assert_eq!(meta.size as usize, data.len()); @@ -93,8 +99,14 @@ mod tests { let config = format!(r#"{{"base_path":"{}"}}"#, dir.path().display()); let store = build(&config).unwrap(); - store.put(&Path::from("a.parquet"), PutPayload::from_static(b"aaa")).await.unwrap(); - store.put(&Path::from("b.parquet"), PutPayload::from_static(b"bbb")).await.unwrap(); + store + .put(&Path::from("a.parquet"), PutPayload::from_static(b"aaa")) + .await + .unwrap(); + store + .put(&Path::from("b.parquet"), PutPayload::from_static(b"bbb")) + .await + .unwrap(); let list: Vec<_> = store.list(None).try_collect().await.unwrap(); let names: Vec = list.iter().map(|m| m.location.to_string()).collect(); @@ -109,7 +121,10 @@ mod tests { let store = build(&config).unwrap(); let path = Path::from("to_delete.dat"); - store.put(&path, PutPayload::from_static(b"delete me")).await.unwrap(); + store + .put(&path, PutPayload::from_static(b"delete me")) + .await + .unwrap(); assert!(store.head(&path).await.is_ok()); store.delete(&path).await.unwrap(); @@ -123,7 +138,10 @@ mod tests { let store = build(&config).unwrap(); let path = Path::from("range_file.dat"); - store.put(&path, PutPayload::from_static(b"0123456789")).await.unwrap(); + store + .put(&path, PutPayload::from_static(b"0123456789")) + .await + .unwrap(); let bytes = store.get_range(&path, 3..7).await.unwrap(); assert_eq!(bytes.as_ref(), b"3456"); diff --git a/sandbox/plugins/native-repository-fs/src/main/rust/src/lib.rs b/sandbox/plugins/native-repository-fs/src/main/rust/src/lib.rs index 7f0e45650ce2f..9caca4c5250b8 100644 --- a/sandbox/plugins/native-repository-fs/src/main/rust/src/lib.rs +++ b/sandbox/plugins/native-repository-fs/src/main/rust/src/lib.rs @@ -16,18 +16,15 @@ pub mod fs; pub use fs::build; pub use fs::FsConfig; -use std::sync::Arc; use object_store::ObjectStore; +use std::sync::Arc; /// Create a local filesystem [`ObjectStore`] from JSON config. /// /// FS backend does not use credentials. #[native_bridge_common::ffm_safe] #[no_mangle] -pub unsafe extern "C" fn fs_create_store( - config_ptr: *const u8, - config_len: i64, -) -> i64 { +pub unsafe extern "C" fn fs_create_store(config_ptr: *const u8, config_len: i64) -> i64 { if config_ptr.is_null() { return Err("fs_create_store: null config pointer".to_string()); } @@ -43,7 +40,9 @@ pub unsafe extern "C" fn fs_create_store( #[native_bridge_common::ffm_safe] #[no_mangle] pub unsafe extern "C" fn fs_destroy_store(ptr: i64) -> i64 { - if ptr == 0 { return Err("fs_destroy_store: null pointer".to_string()); } + if ptr == 0 { + return Err("fs_destroy_store: null pointer".to_string()); + } let _: Box> = Box::from_raw(ptr as *mut Arc); Ok(0) } diff --git a/sandbox/plugins/native-repository-gcs/src/main/rust/src/gcs.rs b/sandbox/plugins/native-repository-gcs/src/main/rust/src/gcs.rs index 0b6c25a68828a..5df5355e4144f 100644 --- a/sandbox/plugins/native-repository-gcs/src/main/rust/src/gcs.rs +++ b/sandbox/plugins/native-repository-gcs/src/main/rust/src/gcs.rs @@ -50,11 +50,25 @@ pub fn build( if config.max_retries.is_some() || config.retry_timeout_ms.is_some() { let mut retry = RetryConfig::default(); - if let Some(m) = config.max_retries { retry.max_retries = m; } - if let Some(ms) = config.retry_timeout_ms { retry.retry_timeout = Duration::from_millis(ms); } + if let Some(m) = config.max_retries { + retry.max_retries = m; + } + if let Some(ms) = config.retry_timeout_ms { + retry.retry_timeout = Duration::from_millis(ms); + } builder = builder.with_retry(retry); } + // If the analytics engine has installed a dedicated IO runtime, route HTTP + // requests (and response-body streaming) onto it via SpawnedReqwestConnector + // — DataFusion's `thread_pools` example mechanism. This keeps network IO and + // its completion work (TLS, body assembly) off the CPU runtime that drives + // query decode. No-op when no IO runtime is installed (e.g. unit tests). + if let Some(io) = native_bridge_common::io_runtime::io_handle() { + builder = + builder.with_http_connector(object_store::client::SpawnedReqwestConnector::new(io)); + } + Ok(Arc::new(builder.build()?)) } @@ -77,7 +91,11 @@ mod tests { let config = r#"{"bucket":"b","max_retries":3,"retry_timeout_ms":10000}"#; let result = build(config, None); if let Err(e) = &result { - assert!(!e.to_string().contains("parse"), "should not be a parse error: {}", e); + assert!( + !e.to_string().contains("parse"), + "should not be a parse error: {}", + e + ); } } } diff --git a/sandbox/plugins/native-repository-gcs/src/main/rust/src/lib.rs b/sandbox/plugins/native-repository-gcs/src/main/rust/src/lib.rs index 60d4705dcd1ef..b6f3dfebe3f50 100644 --- a/sandbox/plugins/native-repository-gcs/src/main/rust/src/lib.rs +++ b/sandbox/plugins/native-repository-gcs/src/main/rust/src/lib.rs @@ -16,8 +16,8 @@ pub mod gcs; pub use gcs::build; pub use gcs::GcsConfig; -use std::sync::Arc; use object_store::ObjectStore; +use std::sync::Arc; /// Create a GCS [`ObjectStore`] from JSON config and return a boxed Arc pointer. /// @@ -40,7 +40,12 @@ pub unsafe extern "C" fn gcs_create_store( let config_json = std::str::from_utf8(bytes).map_err(|e| format!("invalid UTF-8: {}", e))?; let credentials = if cred_provider_ptr != 0 { let provider = Box::from_raw( - cred_provider_ptr as *mut Arc>, + cred_provider_ptr + as *mut Arc< + dyn object_store::CredentialProvider< + Credential = object_store::gcp::GcpCredential, + >, + >, ); Some(*provider) } else { @@ -56,7 +61,9 @@ pub unsafe extern "C" fn gcs_create_store( #[native_bridge_common::ffm_safe] #[no_mangle] pub unsafe extern "C" fn gcs_destroy_store(ptr: i64) -> i64 { - if ptr == 0 { return Err("gcs_destroy_store: null pointer".to_string()); } + if ptr == 0 { + return Err("gcs_destroy_store: null pointer".to_string()); + } let _: Box> = Box::from_raw(ptr as *mut Arc); Ok(0) } diff --git a/sandbox/plugins/native-repository-s3/src/main/rust/Cargo.toml b/sandbox/plugins/native-repository-s3/src/main/rust/Cargo.toml index 6003c9e35c86c..920f924d38a60 100644 --- a/sandbox/plugins/native-repository-s3/src/main/rust/Cargo.toml +++ b/sandbox/plugins/native-repository-s3/src/main/rust/Cargo.toml @@ -14,3 +14,6 @@ object_store = { workspace = true, features = ["aws"] } serde = { workspace = true } serde_json = { workspace = true } native-bridge-common = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } diff --git a/sandbox/plugins/native-repository-s3/src/main/rust/src/lib.rs b/sandbox/plugins/native-repository-s3/src/main/rust/src/lib.rs index c1b4c045335ae..7088ef18a72ca 100644 --- a/sandbox/plugins/native-repository-s3/src/main/rust/src/lib.rs +++ b/sandbox/plugins/native-repository-s3/src/main/rust/src/lib.rs @@ -16,8 +16,8 @@ pub mod s3; pub use s3::build; pub use s3::S3Config; -use std::sync::Arc; use object_store::ObjectStore; +use std::sync::Arc; // --------------------------------------------------------------------------- // FFM entry points @@ -49,7 +49,12 @@ pub unsafe extern "C" fn s3_create_store( let credentials = if cred_provider_ptr != 0 { // SAFETY: ptr was produced by Box::into_raw(Box::new(Arc>)) let provider = Box::from_raw( - cred_provider_ptr as *mut Arc>, + cred_provider_ptr + as *mut Arc< + dyn object_store::CredentialProvider< + Credential = object_store::aws::AwsCredential, + >, + >, ); Some(*provider) } else { diff --git a/sandbox/plugins/native-repository-s3/src/main/rust/src/s3.rs b/sandbox/plugins/native-repository-s3/src/main/rust/src/s3.rs index 81e28a743ca81..95d64368183af 100644 --- a/sandbox/plugins/native-repository-s3/src/main/rust/src/s3.rs +++ b/sandbox/plugins/native-repository-s3/src/main/rust/src/s3.rs @@ -78,35 +78,74 @@ pub fn build( let config: S3Config = serde_json::from_str(config_json)?; let mut builder = AmazonS3Builder::new().with_bucket_name(&config.bucket); - if let Some(ref r) = config.region { builder = builder.with_region(r); } - if let Some(ref e) = config.endpoint { builder = builder.with_endpoint(e); } + if let Some(ref r) = config.region { + builder = builder.with_region(r); + } + if let Some(ref e) = config.endpoint { + builder = builder.with_endpoint(e); + } if let Some(creds) = credentials { builder = builder.with_credentials(creds); } - if config.virtual_hosted_style == Some(true) { builder = builder.with_virtual_hosted_style_request(true); } - if config.unsigned_payload == Some(true) { builder = builder.with_unsigned_payload(true); } - if config.allow_http == Some(true) { builder = builder.with_allow_http(true); } - if let Some(ref p) = config.proxy_url { builder = builder.with_proxy_url(p); } - if let Some(ref c) = config.proxy_ca_certificate { builder = builder.with_proxy_ca_certificate(c); } - if config.imdsv1_fallback == Some(true) { builder = builder.with_imdsv1_fallback(); } - if config.s3_express == Some(true) { builder = builder.with_s3_express(true); } - if let Some(ref k) = config.sse_kms_key_id { builder = builder.with_sse_kms_encryption(k); } - if let Some(ref d) = config.dsse_kms_key_id { builder = builder.with_dsse_kms_encryption(d); } - if config.bucket_key == Some(true) { builder = builder.with_bucket_key(true); } + if config.virtual_hosted_style == Some(true) { + builder = builder.with_virtual_hosted_style_request(true); + } + if config.unsigned_payload == Some(true) { + builder = builder.with_unsigned_payload(true); + } + if config.allow_http == Some(true) { + builder = builder.with_allow_http(true); + } + if let Some(ref p) = config.proxy_url { + builder = builder.with_proxy_url(p); + } + if let Some(ref c) = config.proxy_ca_certificate { + builder = builder.with_proxy_ca_certificate(c); + } + if config.imdsv1_fallback == Some(true) { + builder = builder.with_imdsv1_fallback(); + } + if config.s3_express == Some(true) { + builder = builder.with_s3_express(true); + } + if let Some(ref k) = config.sse_kms_key_id { + builder = builder.with_sse_kms_encryption(k); + } + if let Some(ref d) = config.dsse_kms_key_id { + builder = builder.with_dsse_kms_encryption(d); + } + if config.bucket_key == Some(true) { + builder = builder.with_bucket_key(true); + } if let Some(ref a) = config.checksum_algorithm { builder = builder.with_checksum_algorithm( - a.parse().map_err(|_| format!("unknown checksum algorithm: '{}'", a))? + a.parse() + .map_err(|_| format!("unknown checksum algorithm: '{}'", a))?, ); } if config.max_retries.is_some() || config.retry_timeout_ms.is_some() { let mut retry = RetryConfig::default(); - if let Some(m) = config.max_retries { retry.max_retries = m; } - if let Some(ms) = config.retry_timeout_ms { retry.retry_timeout = Duration::from_millis(ms); } + if let Some(m) = config.max_retries { + retry.max_retries = m; + } + if let Some(ms) = config.retry_timeout_ms { + retry.retry_timeout = Duration::from_millis(ms); + } builder = builder.with_retry(retry); } + // If the analytics engine has installed a dedicated IO runtime, route HTTP + // requests (and response-body streaming) onto it via SpawnedReqwestConnector + // — DataFusion's `thread_pools` example mechanism. This keeps network IO and + // its completion work (TLS, body assembly) off the CPU runtime that drives + // query decode. No-op when no IO runtime is installed (e.g. unit tests). + if let Some(io) = native_bridge_common::io_runtime::io_handle() { + builder = + builder.with_http_connector(object_store::client::SpawnedReqwestConnector::new(io)); + } + Ok(Arc::new(builder.build()?)) } @@ -172,4 +211,143 @@ mod tests { let config = r#"{"bucket":"b","region":"us-east-1","allow_http":true,"endpoint":"http://localhost:9000","unknown_field":"value"}"#; assert!(build(config, None).is_ok()); } + + // ── IO-runtime dispatch (SpawnedReqwestConnector) ──────────────────────── + // + // These assert the warm path: an S3 store built while an IO runtime handle is + // installed routes its HTTP requests onto that runtime (the + // `SpawnedReqwestConnector` wiring in `build`), so the IO runtime's worker + // poll counter advances. With no handle installed, the store falls back to the + // default connector and the IO runtime stays idle. `worker_poll_count` is the + // exact counter the DataFusion `_stats` API exports as `total_polls_count`. + + use std::io::Write as _; + use std::net::TcpListener as StdTcpListener; + use std::sync::atomic::{AtomicBool, Ordering}; + + /// Minimal localhost HTTP server: accepts connections and replies with a fixed + /// `200 OK` + tiny body to ANY request, until `stop` is set. Lets us exercise + /// the real `AmazonS3` HTTP client end-to-end without a container or real S3. + /// Runs on its own std thread so it is independent of any tokio runtime. + fn spawn_mock_http() -> (String, Arc, std::thread::JoinHandle<()>) { + let listener = StdTcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + listener.set_nonblocking(true).unwrap(); + let stop = Arc::new(AtomicBool::new(false)); + let stop_c = Arc::clone(&stop); + let handle = std::thread::spawn(move || { + use std::io::Read as _; + while !stop_c.load(Ordering::Relaxed) { + match listener.accept() { + Ok((mut sock, _)) => { + // Drain the request headers (best-effort) then reply. + let mut buf = [0u8; 1024]; + let _ = sock.read(&mut buf); + let body = b"hello-from-mock-s3"; + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nAccept-Ranges: bytes\r\n\r\n", + body.len() + ); + let _ = sock.write_all(resp.as_bytes()); + let _ = sock.write_all(body); + let _ = sock.flush(); + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + } + Err(_) => break, + } + } + }); + (format!("http://{}", addr), stop, handle) + } + + /// Sum of per-worker poll counts for a runtime — the same metric the + /// production `_stats` API reports as `io_runtime.total_polls_count`. + fn worker_polls(handle: &tokio::runtime::Handle) -> u64 { + let m = handle.metrics(); + (0..m.num_workers()).map(|i| m.worker_poll_count(i)).sum() + } + + fn s3_config(endpoint: &str) -> String { + format!( + r#"{{"bucket":"b","region":"us-east-1","allow_http":true,"endpoint":"{}"}}"#, + endpoint + ) + } + + /// Drive a single `get` against `endpoint` on a throwaway driver runtime + /// (separate from `io_rt`, so any polls observed on `io_rt` can only come from + /// the connector), returning the IO runtime's worker-poll delta across it. + fn poll_delta_for_get(io_rt: &tokio::runtime::Runtime, endpoint: &str) -> u64 { + let driver_rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + .unwrap(); + let store = build(&s3_config(endpoint), None).expect("build s3 store"); + let before = worker_polls(io_rt.handle()); + let _ = driver_rt.block_on(async move { + store + .get_opts( + &object_store::path::Path::from("some-object"), + object_store::GetOptions::default(), + ) + .await + }); + worker_polls(io_rt.handle()) - before + } + + /// Warm-path assertion + its control, in ONE test because the IO handle is a + /// process-global that parallel tests would race on. + /// + /// * WITH a handle installed → the S3 store's HTTP request is polled on that + /// IO runtime (the `SpawnedReqwestConnector` wiring), so its + /// `worker_poll_count` advances. + /// * WITHOUT a handle (a runtime never installed) → that runtime sees zero + /// polls, proving the warm-case polls come from the connector, not ambient + /// activity. + #[test] + fn io_runtime_services_s3_reads_only_when_handle_installed() { + let (endpoint, stop, server) = spawn_mock_http(); + + let io_rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .thread_name("test-io") + .enable_all() + .build() + .unwrap(); + // A second runtime that we observe but NEVER install as the IO handle. + let uninstalled_rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .thread_name("test-uninstalled") + .enable_all() + .build() + .unwrap(); + + // WARM: handle installed → io_rt must poll the request. + native_bridge_common::io_runtime::set_io_handle(io_rt.handle().clone()); + let installed_delta = poll_delta_for_get(&io_rt, &endpoint); + + // CONTROL: a runtime that was never installed must see nothing from the read. + let uninstalled_delta = poll_delta_for_get(&uninstalled_rt, &endpoint); + + native_bridge_common::io_runtime::clear_io_handle(); + stop.store(true, Ordering::Relaxed); + let _ = server.join(); + + assert!( + installed_delta > 0, + "IO runtime worker_poll_count must advance across an S3 read \ + (delta={}) — SpawnedReqwestConnector is not dispatching HTTP onto the \ + installed IO runtime", + installed_delta + ); + assert_eq!( + uninstalled_delta, 0, + "a runtime never installed as the IO handle must see no polls from an \ + S3 read (delta={}) — the warm-case polls would then be ambient noise", + uninstalled_delta + ); + } } diff --git a/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/ParquetDataFormatPlugin.java b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/ParquetDataFormatPlugin.java index 5831078a12bd2..52a8eb90add27 100644 --- a/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/ParquetDataFormatPlugin.java +++ b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/ParquetDataFormatPlugin.java @@ -94,6 +94,8 @@ public class ParquetDataFormatPlugin extends Plugin implements DataFormatPlugin, /** Thread pool name for background native Parquet writes during VSR rotation. */ public static final String PARQUET_THREAD_POOL_NAME = "parquet_native_write"; + + public static final int PARQUET_THREAD_POOL_QUEUE_SIZE = 10_000; private static final StoreStrategy storeStrategy = new ParquetStoreStrategy(); public static final ParquetDataFormat PARQUET_DATA_FORMAT = new ParquetDataFormat(); /** Initialized to EMPTY to avoid NPE if indexingEngine() is called before createComponents(). */ @@ -121,6 +123,11 @@ public Collection createComponents( ) { this.settings = clusterService.getSettings(); this.threadPool = threadPool; + // Hand the node thread pool to the stats provider so per-node stats can read the live + // parquet_native_write pool (queue depth / active / rejected). + if (ParquetStatsProvider.getInstance() != null) { + ParquetStatsProvider.getInstance().setThreadPool(threadPool); + } this.nativeAllocator = pluginComponentRegistry.getComponent(ArrowNativeAllocator.class).orElse(null); // Initialize native write/merge memory pools @@ -169,6 +176,12 @@ public Collection createComponents( writePool.updateStats(s[1], s[2]); mergePool.updateStats(s[4], s[5]); }); + + // Wire the over-commit decision to the allocator. When a native pool is full, the Rust + // reservation consults this decider (an FFM upcall); the decision runs in + // ArrowNativeAllocator.tryOverCommitToken based on node-level native memory pressure; the + // returned token is echoed back on release so the exact grant is freed. + RustBridge.registerOverCommitCallbacks(nativeAllocator::tryOverCommitToken, nativeAllocator::releaseOverCommitToken); } else { // No allocator — wire dynamic consumers directly to Rust pools ClusterSettings cs = clusterService.getClusterSettings(); @@ -233,7 +246,7 @@ public List> getExecutorBuilders(Settings settings) { settings, PARQUET_THREAD_POOL_NAME, OpenSearchExecutors.allocatedProcessors(settings), - -1, + PARQUET_THREAD_POOL_QUEUE_SIZE, "thread_pool." + PARQUET_THREAD_POOL_NAME ) ); diff --git a/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/ParquetSettings.java b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/ParquetSettings.java index 245e65bf35edb..b0af1de91701a 100644 --- a/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/ParquetSettings.java +++ b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/ParquetSettings.java @@ -14,10 +14,12 @@ import org.apache.arrow.vector.types.pojo.ArrowType; import org.apache.arrow.vector.types.pojo.Field; import org.apache.arrow.vector.types.pojo.Schema; +import org.opensearch.cluster.node.DiscoveryNode; import org.opensearch.common.settings.Setting; import org.opensearch.common.settings.Settings; import org.opensearch.core.common.unit.ByteSizeUnit; import org.opensearch.core.common.unit.ByteSizeValue; +import org.opensearch.monitor.os.OsProbe; import org.opensearch.node.resource.tracker.ResourceTrackerSettings; import java.util.Collections; @@ -38,8 +40,57 @@ private ParquetSettings() {} public static final String POOL_WRITE = "write"; public static final String POOL_MERGE = "merge"; - public static final String DEFAULT_MAX_NATIVE_ALLOCATION = "10%"; - public static final int DEFAULT_MAX_ROWS_PER_VSR = 65536; + /** + * Anchor used to scale the batch/sort defaults with machine memory. The tuned base values + * (VSR rows, merge rows, and sort-threshold MiB) were measured on a 64 GiB machine; defaults + * scale linearly with total RAM relative to this anchor. + */ + static final long BATCH_SIZE_ANCHOR_RAM_BYTES = 64L * 1024 * 1024 * 1024; + + /** Fixed bounds for the RAM-scaled {@code parquet.max_rows_per_vsr} default (user-set values are unbounded). */ + public static final int DEFAULT_MAX_ROWS_PER_VSR = 65_536; + static final int MAX_ROWS_PER_VSR_FLOOR = 8_192; + static final int MAX_ROWS_PER_VSR_CEIL = 131_072; + + /** Tuned base (64 GiB anchor) and fixed bounds for the RAM-scaled {@code index.parquet.merge_batch_size} default. */ + public static final int DEFAULT_MERGE_BATCH_SIZE = 100_000; + static final int MERGE_BATCH_SIZE_FLOOR = 12_500; + static final int MERGE_BATCH_SIZE_CEIL = 100_000; + + /** + * Tuned base (64 GiB anchor) and fixed bounds, in MiB, for the RAM-scaled + * {@code index.parquet.sort_in_memory_threshold} default. Governs the sorted-write chunk size; + * each chunk flush transiently reserves ~2× this on the write pool. + */ + static final int SORT_IN_MEMORY_THRESHOLD_BASE_MB = 32; + static final int SORT_IN_MEMORY_THRESHOLD_FLOOR_MB = 16; + static final int SORT_IN_MEMORY_THRESHOLD_CEIL_MB = 64; + + /** + * RAM-scaled defaults, computed once at class load (total RAM is constant per node). + * Any failure reading RAM falls back to the tuned base value (see {@link #safeTotalRamBytes()}). + */ + private static final int MAX_ROWS_PER_VSR_DEFAULT = deriveBatchSizeDefault( + safeTotalRamBytes(), + DEFAULT_MAX_ROWS_PER_VSR, + MAX_ROWS_PER_VSR_FLOOR, + MAX_ROWS_PER_VSR_CEIL + ); + private static final int MERGE_BATCH_SIZE_DEFAULT = deriveBatchSizeDefault( + safeTotalRamBytes(), + DEFAULT_MERGE_BATCH_SIZE, + MERGE_BATCH_SIZE_FLOOR, + MERGE_BATCH_SIZE_CEIL + ); + private static final ByteSizeValue SORT_IN_MEMORY_THRESHOLD_DEFAULT = new ByteSizeValue( + deriveBatchSizeDefault( + safeTotalRamBytes(), + SORT_IN_MEMORY_THRESHOLD_BASE_MB, + SORT_IN_MEMORY_THRESHOLD_FLOOR_MB, + SORT_IN_MEMORY_THRESHOLD_CEIL_MB + ), + ByteSizeUnit.MB + ); /** Data page size limit in bytes (default 1MB). */ public static final Setting PAGE_SIZE_BYTES = Setting.byteSizeSetting( @@ -103,33 +154,23 @@ private ParquetSettings() {} Setting.Property.IndexScope ); - /** Maximum native memory allocation for Arrow buffers, as a percentage of non-heap memory (default 10%). */ - public static final Setting MAX_NATIVE_ALLOCATION = Setting.simpleString( - "parquet.max_native_allocation", - DEFAULT_MAX_NATIVE_ALLOCATION, - Setting.Property.NodeScope - ); - - /** Maximum rows per VectorSchemaRoot before rotation is triggered (default 50000). */ + /** + * Maximum rows per VectorSchemaRoot before rotation is triggered. Default scales with total RAM + * (linearly, anchored at 64 GiB → {@value #DEFAULT_MAX_ROWS_PER_VSR}), clamped to + * [{@value #MAX_ROWS_PER_VSR_FLOOR}, {@value #MAX_ROWS_PER_VSR_CEIL}]. + */ public static final Setting MAX_ROWS_PER_VSR = Setting.intSetting( "parquet.max_rows_per_vsr", - DEFAULT_MAX_ROWS_PER_VSR, + MAX_ROWS_PER_VSR_DEFAULT, 1, Setting.Property.NodeScope ); - /** File size threshold for in-memory sort vs streaming merge sort (default 32MB). */ + /** File size threshold for in-memory sort vs streaming merge sort. Default scales with total RAM + * (anchor 32 MiB @ 64 GiB), clamped to [16 MiB, 64 MiB]. */ public static final Setting SORT_IN_MEMORY_THRESHOLD = Setting.byteSizeSetting( "index.parquet.sort_in_memory_threshold", - new ByteSizeValue(32, ByteSizeUnit.MB), - Setting.Property.IndexScope - ); - - /** Batch size for streaming merge sort (default 8192 rows). */ - public static final Setting SORT_BATCH_SIZE = Setting.intSetting( - "index.parquet.sort_batch_size", - 8192, - 1, + SORT_IN_MEMORY_THRESHOLD_DEFAULT, Setting.Property.IndexScope ); @@ -148,10 +189,14 @@ private ParquetSettings() {} Setting.Property.IndexScope ); - /** Batch size for reading records during merge (default 100000 rows). */ + /** + * Batch size for reading records during merge. Default scales with total RAM + * (linearly, anchored at 64 GiB → {@value #DEFAULT_MERGE_BATCH_SIZE}), clamped to + * [{@value #MERGE_BATCH_SIZE_FLOOR}, {@value #MERGE_BATCH_SIZE_CEIL}]. + */ public static final Setting MERGE_BATCH_SIZE = Setting.intSetting( "index.parquet.merge_batch_size", - 100_000, + MERGE_BATCH_SIZE_DEFAULT, 1, Setting.Property.IndexScope ); @@ -185,10 +230,10 @@ private ParquetSettings() {} Setting.Property.Dynamic ); - /** Minimum guaranteed bytes for the native write pool. Default is half of write max (2% of budget). */ + /** Minimum guaranteed bytes for the native write pool. Default is 2% of budget on warm nodes, 4% otherwise. */ public static final Setting WRITE_POOL_MIN = new Setting<>( "parquet.native.pool.write.min", - s -> derivePoolMinDefault(s, 2), + s -> derivePoolMinDefault(s, DiscoveryNode.isWarmNode(s) ? 2 : 4), s -> { long v = Long.parseLong(s); if (v < 0) { @@ -200,10 +245,10 @@ private ParquetSettings() {} Setting.Property.Dynamic ); - /** Maximum bytes the native write pool can burst to. Default is 5% of node.native_memory.limit. */ + /** Maximum bytes the native write pool can burst to. Default is 4% of budget on warm nodes, 8% otherwise. */ public static final Setting WRITE_POOL_MAX = new Setting<>( "parquet.native.pool.write.max", - s -> derivePoolMaxDefault(s, 5), + s -> derivePoolMaxDefault(s, DiscoveryNode.isWarmNode(s) ? 4 : 8), s -> { long v = Long.parseLong(s); if (v < 0) { @@ -272,6 +317,42 @@ static String derivePoolMinDefault(Settings settings, int percent) { return Long.toString(Math.max(0L, nativeLimit.getBytes() * percent / 100)); } + /** + * Scales a batch-size default linearly with total physical RAM, anchored at the 64 GiB benchmark + * machine ({@link #BATCH_SIZE_ANCHOR_RAM_BYTES}), and clamps the result to [{@code floor}, {@code ceil}]. + * The base value is the default tuned on the anchor machine, and {@code factor = totalRamBytes / anchor}. + * If RAM is unavailable ({@code totalRamBytes <= 0}), falls back to the tuned default {@code base} + * with no scaling/clamping applied. + * + * @param totalRamBytes total physical RAM in bytes (e.g. {@code OsProbe.getTotalPhysicalMemorySize()}) + * @param base tuned default at the 64 GiB anchor (also the fallback value) + * @param floor fixed lower bound + * @param ceil fixed upper bound + */ + static int deriveBatchSizeDefault(long totalRamBytes, int base, int floor, int ceil) { + if (totalRamBytes <= 0) { + // Memory unavailable — fall back to the tuned default, no calculation. + return base; + } + double factor = (double) totalRamBytes / BATCH_SIZE_ANCHOR_RAM_BYTES; + long scaled = Math.round((double) base * factor); + return (int) Math.max(floor, Math.min(ceil, scaled)); + } + + /** + * Returns total physical RAM in bytes, or {@code -1} if it cannot be read for any reason. + * A negative result drives {@link #deriveBatchSizeDefault} to the tuned base default, ensuring + * settings always resolve to a sane value (and class initialization never fails) even if the + * OS probe throws. + */ + private static long safeTotalRamBytes() { + try { + return OsProbe.getInstance().getTotalPhysicalMemorySize(); + } catch (Exception e) { + return -1L; + } + } + public static final Set VALID_ENCODINGS = Set.of( "PLAIN", "RLE", @@ -761,10 +842,8 @@ public static List> getSettings() { BLOOM_FILTER_ENABLED, BLOOM_FILTER_FPP, BLOOM_FILTER_NDV, - MAX_NATIVE_ALLOCATION, MAX_ROWS_PER_VSR, SORT_IN_MEMORY_THRESHOLD, - SORT_BATCH_SIZE, ROW_GROUP_MAX_ROWS, ROW_GROUP_MAX_BYTES, MERGE_BATCH_SIZE, diff --git a/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/bridge/NativeSettings.java b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/bridge/NativeSettings.java index d815ef3b6b999..5f5d0bec2e378 100644 --- a/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/bridge/NativeSettings.java +++ b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/bridge/NativeSettings.java @@ -28,7 +28,6 @@ public class NativeSettings { private final Double bloomFilterFpp; private final Long bloomFilterNdv; private final Long sortInMemoryThresholdBytes; - private final Integer sortBatchSize; private final Integer rowGroupMaxRows; private final Long rowGroupMaxBytes; private final Integer mergeBatchSize; @@ -55,7 +54,6 @@ private NativeSettings(Builder builder) { this.bloomFilterFpp = builder.bloomFilterFpp; this.bloomFilterNdv = builder.bloomFilterNdv; this.sortInMemoryThresholdBytes = builder.sortInMemoryThresholdBytes; - this.sortBatchSize = builder.sortBatchSize; this.rowGroupMaxRows = builder.rowGroupMaxRows; this.rowGroupMaxBytes = builder.rowGroupMaxBytes; this.mergeBatchSize = builder.mergeBatchSize; @@ -124,10 +122,6 @@ public Long getSortInMemoryThresholdBytes() { return sortInMemoryThresholdBytes; } - public Integer getSortBatchSize() { - return sortBatchSize; - } - public Integer getRowGroupMaxRows() { return rowGroupMaxRows; } @@ -199,7 +193,6 @@ public static class Builder { private Double bloomFilterFpp; private Long bloomFilterNdv; private Long sortInMemoryThresholdBytes; - private Integer sortBatchSize; private Integer rowGroupMaxRows; private Long rowGroupMaxBytes; private Integer mergeBatchSize; @@ -265,11 +258,6 @@ public Builder sortInMemoryThresholdBytes(Long v) { return this; } - public Builder sortBatchSize(Integer v) { - this.sortBatchSize = v; - return this; - } - public Builder rowGroupMaxRows(Integer v) { this.rowGroupMaxRows = v; return this; diff --git a/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/bridge/RustBridge.java b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/bridge/RustBridge.java index 9f0cb3893d5d6..a60d0e758c63e 100644 --- a/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/bridge/RustBridge.java +++ b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/bridge/RustBridge.java @@ -16,17 +16,22 @@ import java.io.IOException; import java.io.UncheckedIOException; +import java.lang.foreign.Arena; import java.lang.foreign.FunctionDescriptor; import java.lang.foreign.Linker; import java.lang.foreign.MemorySegment; import java.lang.foreign.SymbolLookup; import java.lang.foreign.ValueLayout; import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.function.LongConsumer; +import java.util.function.LongSupplier; /** * FFM bridge to the native Rust parquet writer library. @@ -50,6 +55,7 @@ public class RustBridge { private static final MethodHandle SET_WRITE_POOL_LIMIT; private static final MethodHandle SET_MERGE_POOL_LIMIT; private static final MethodHandle GET_POOL_STATS; + private static final MethodHandle REGISTER_OVERCOMMIT_CALLBACKS; static { SymbolLookup lib = NativeLibraryLoader.symbolLookup(); @@ -145,7 +151,6 @@ public class RustBridge { ValueLayout.JAVA_DOUBLE, // bloom_filter_fpp ValueLayout.JAVA_LONG, // bloom_filter_ndv ValueLayout.JAVA_LONG, // sort_in_memory_threshold_bytes - ValueLayout.JAVA_LONG, // sort_batch_size ValueLayout.JAVA_LONG, // row_group_max_rows ValueLayout.JAVA_LONG, // row_group_max_bytes ValueLayout.JAVA_LONG, // merge_batch_size @@ -279,6 +284,10 @@ public class RustBridge { lib.find("parquet_get_pool_stats").orElseThrow(), FunctionDescriptor.ofVoid(ValueLayout.ADDRESS) ); + REGISTER_OVERCOMMIT_CALLBACKS = linker.downcallHandle( + lib.find("parquet_register_overcommit_callbacks").orElseThrow(), + FunctionDescriptor.ofVoid(ValueLayout.ADDRESS, ValueLayout.ADDRESS) + ); } public static void initLogger() {} @@ -461,7 +470,6 @@ public static void onSettingsUpdate(NativeSettings nativeSettings) throws IOExce nativeSettings.getBloomFilterFpp() != null ? nativeSettings.getBloomFilterFpp() : -1.0, nativeSettings.getBloomFilterNdv() != null ? nativeSettings.getBloomFilterNdv() : -1L, nativeSettings.getSortInMemoryThresholdBytes() != null ? nativeSettings.getSortInMemoryThresholdBytes() : -1L, - nativeSettings.getSortBatchSize() != null ? (long) nativeSettings.getSortBatchSize() : -1L, nativeSettings.getRowGroupMaxRows() != null ? (long) nativeSettings.getRowGroupMaxRows() : -1L, nativeSettings.getRowGroupMaxBytes() != null ? nativeSettings.getRowGroupMaxBytes() : -1L, nativeSettings.getMergeBatchSize() != null ? (long) nativeSettings.getMergeBatchSize() : -1L, @@ -759,5 +767,64 @@ public static long[] getPoolStats() { } } + // ─── Over-commit decision upcall (decision executes in the Java allocator) ─────────────────── + + /** Delegate that decides whether a full native pool may over-commit; set by the owning plugin. */ + private static volatile LongSupplier overCommitDecider; + /** Delegate that releases a previously granted over-commit; set by the owning plugin. */ + private static volatile LongConsumer overCommitReleaser; + + /** C-ABI trampoline invoked from Rust: returns a nonzero grant token, or 0 to reject. */ + private static long overCommitDecide() { + LongSupplier d = overCommitDecider; + try { + return d != null ? d.getAsLong() : 0L; + } catch (Throwable t) { + return 0L; // never let an exception cross the native boundary + } + } + + /** C-ABI trampoline invoked from Rust: releases the over-commit permit identified by {@code token}. */ + private static void overCommitRelease(long token) { + LongConsumer r = overCommitReleaser; + try { + if (r != null) { + r.accept(token); + } + } catch (Throwable ignore) { + // best-effort + } + } + + /** + * Registers the over-commit decision/release delegates and installs FFM upcall stubs into the + * native library so a full native pool can consult the (Java allocator-owned) decision. The + * decision itself runs in {@code decide}/{@code release}; this method only wires the plumbing. + * Stubs are bound to the global arena (JVM lifetime). + * + * @param decide returns a nonzero grant token to over-commit, or 0 to reject + * @param release invoked with the grant token to release a previously granted over-commit + */ + public static void registerOverCommitCallbacks(LongSupplier decide, LongConsumer release) { + overCommitDecider = decide; + overCommitReleaser = release; + try { + Linker linker = Linker.nativeLinker(); + Arena arena = Arena.global(); + MethodHandles.Lookup lookup = MethodHandles.lookup(); + MethodHandle decideHandle = lookup.findStatic(RustBridge.class, "overCommitDecide", MethodType.methodType(long.class)); + MethodHandle releaseHandle = lookup.findStatic( + RustBridge.class, + "overCommitRelease", + MethodType.methodType(void.class, long.class) + ); + MemorySegment decideStub = linker.upcallStub(decideHandle, FunctionDescriptor.of(ValueLayout.JAVA_LONG), arena); + MemorySegment releaseStub = linker.upcallStub(releaseHandle, FunctionDescriptor.ofVoid(ValueLayout.JAVA_LONG), arena); + NativeCall.invokeVoid(REGISTER_OVERCOMMIT_CALLBACKS, decideStub, releaseStub); + } catch (Throwable t) { + throw new IllegalStateException("Failed to register over-commit callbacks", t); + } + } + private RustBridge() {} } diff --git a/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/engine/ParquetIndexingEngine.java b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/engine/ParquetIndexingEngine.java index 32aeb27df9cf6..3216a6a5bfee5 100644 --- a/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/engine/ParquetIndexingEngine.java +++ b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/engine/ParquetIndexingEngine.java @@ -218,7 +218,6 @@ private void pushSettingsToRust() { .bloomFilterFpp(ParquetSettings.BLOOM_FILTER_FPP.get(settings)) .bloomFilterNdv(ParquetSettings.BLOOM_FILTER_NDV.get(settings)) .sortInMemoryThresholdBytes(ParquetSettings.SORT_IN_MEMORY_THRESHOLD.get(settings).getBytes()) - .sortBatchSize(ParquetSettings.SORT_BATCH_SIZE.get(settings)) .rowGroupMaxRows(ParquetSettings.ROW_GROUP_MAX_ROWS.get(settings)) .rowGroupMaxBytes(ParquetSettings.ROW_GROUP_MAX_BYTES.get(settings).getBytes()) .mergeBatchSize(ParquetSettings.MERGE_BATCH_SIZE.get(settings)) diff --git a/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/memory/package-info.java b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/memory/package-info.java index 6271dc81824ce..1986b8aa6ffbc 100644 --- a/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/memory/package-info.java +++ b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/memory/package-info.java @@ -9,12 +9,10 @@ /** * Arrow memory management for the Parquet plugin. * - *

    This package provides a managed wrapper around Apache Arrow's {@code RootAllocator}, - * with allocation limits derived from the {@code parquet.max_native_allocation} setting. - * The pool computes the maximum allocation as a percentage of available non-heap system memory - * and provides child allocators for individual VSR instances. + *

    This package provides a managed wrapper around the unified native allocator's ingest pool + * (see {@link org.opensearch.arrow.allocator.ArrowNativeAllocator}). The pool's dynamic limit is the + * enforced constraint, and this package provides child allocators for individual VSR instances. * * @see org.opensearch.parquet.memory.ArrowBufferPool - * @see org.opensearch.parquet.ParquetSettings#MAX_NATIVE_ALLOCATION */ package org.opensearch.parquet.memory; diff --git a/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/stats/ParquetIngestPoolStats.java b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/stats/ParquetIngestPoolStats.java new file mode 100644 index 0000000000000..febb61684892b --- /dev/null +++ b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/stats/ParquetIngestPoolStats.java @@ -0,0 +1,92 @@ +/* + * 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.parquet.stats; + +import org.opensearch.common.annotation.ExperimentalApi; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.common.io.stream.Writeable; +import org.opensearch.core.xcontent.ToXContentFragment; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.threadpool.ThreadPoolStats; + +import java.io.IOException; + +/** + * Live, node-level snapshot of the {@code parquet_native_write} thread pool that backs Parquet + * background ingestion writes. Surfaces queue saturation and rejection pressure. + * + *

    Node-level only: rendered under the {@code native_ingest_pool} block of the per-node stats + * aggregate, never on a per-shard payload. + * + * @opensearch.experimental + */ +@ExperimentalApi +public final class ParquetIngestPoolStats implements Writeable, ToXContentFragment { + + private final int threads; + private final int queueDepth; + private final int active; + private final long rejected; + private final long completed; + + public ParquetIngestPoolStats(int threads, int queueDepth, int active, long rejected, long completed) { + this.threads = threads; + this.queueDepth = queueDepth; + this.active = active; + this.rejected = rejected; + this.completed = completed; + } + + public ParquetIngestPoolStats(StreamInput in) throws IOException { + this.threads = in.readVInt(); + this.queueDepth = in.readVInt(); + this.active = in.readVInt(); + this.rejected = in.readVLong(); + this.completed = in.readVLong(); + } + + /** + * Builds a snapshot from a {@link ThreadPoolStats.Stats} reading of the {@code parquet_native_write} + * pool, or {@code null} if {@code stats} is {@code null} (pool not found). + */ + public static ParquetIngestPoolStats from(ThreadPoolStats.Stats stats) { + if (stats == null) { + return null; + } + return new ParquetIngestPoolStats( + stats.getThreads(), + stats.getQueue(), + stats.getActive(), + stats.getRejected(), + stats.getCompleted() + ); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeVInt(threads); + out.writeVInt(queueDepth); + out.writeVInt(active); + out.writeVLong(rejected); + out.writeVLong(completed); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject("native_ingest_pool"); + builder.field("threads", threads); + builder.field("queue_depth", queueDepth); + builder.field("active", active); + builder.field("rejected", rejected); + builder.field("completed", completed); + builder.endObject(); + return builder; + } +} diff --git a/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/stats/ParquetShardStats.java b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/stats/ParquetShardStats.java index dc0ca757ec6d7..2e643340d0d97 100644 --- a/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/stats/ParquetShardStats.java +++ b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/stats/ParquetShardStats.java @@ -40,6 +40,8 @@ public class ParquetShardStats implements DataFormatShardStats trackers = new ConcurrentHashMap<>(); + // Node-level thread pool backing parquet ingestion writes. Set post-construction (the pool + // isn't available when the provider is built in createGuiceModules). May be null in tests. + private volatile org.opensearch.threadpool.ThreadPool threadPool; + public ParquetStatsProvider() { // First instance wins. Subsequent constructions are no-ops on the singleton slot. if (INSTANCE == null) { @@ -61,6 +65,11 @@ public static ParquetStatsProvider getInstance() { return INSTANCE; } + /** Sets the node thread pool used to read live {@code parquet_native_write} pool stats. */ + public void setThreadPool(org.opensearch.threadpool.ThreadPool threadPool) { + this.threadPool = threadPool; + } + /** Registers a tracker for a shard. Called from {@code ParquetIndexingEngine}'s constructor. */ public void register(ShardId shardId, ParquetShardStatsTracker tracker) { trackers.put(shardId, tracker); @@ -102,19 +111,38 @@ public Optional aggregateNodeStats() { for (ParquetShardStatsTracker t : trackers.values()) { agg = agg.add(t.stats()); } - // Attach native runtime metrics — only at node level. If the FFM/JNI bridge is - // unavailable (e.g., test infra without lib loaded) we still return the per-shard - // aggregate; missing runtime metrics shouldn't break the whole stats request. - // Catching Exception (not Throwable) so we still propagate JVM-fatal Errors. + // Node-level decoration: the live parquet_native_write pool snapshot + the Rust runtime + // metrics. Both are best-effort — a null pool or an unavailable native bridge simply + // leaves that block off rather than failing the whole stats request. + ParquetIngestPoolStats ingestPool = collectIngestPoolStats(); + ParquetNativeRuntimeStats runtime = null; + try { + runtime = ParquetNativeRuntimeStats.fromArray(org.opensearch.parquet.bridge.RustBridge.collectRuntimeMetrics()); + } catch (Exception e) { + logger.warn("Failed to collect native runtime metrics; node stats will omit the native_runtime block", e); + } + return Optional.of(agg.withNodeStats(runtime, ingestPool)); + } + + /** + * Reads the live {@code parquet_native_write} pool stats from the node thread pool, or returns + * {@code null} if the thread pool is unavailable or the pool is not present. + */ + private ParquetIngestPoolStats collectIngestPoolStats() { + org.opensearch.threadpool.ThreadPool tp = threadPool; + if (tp == null) { + return null; + } try { - ParquetNativeRuntimeStats runtime = ParquetNativeRuntimeStats.fromArray( - org.opensearch.parquet.bridge.RustBridge.collectRuntimeMetrics() - ); - return Optional.of(agg.withNativeRuntime(runtime)); + for (org.opensearch.threadpool.ThreadPoolStats.Stats s : tp.stats()) { + if (org.opensearch.parquet.ParquetDataFormatPlugin.PARQUET_THREAD_POOL_NAME.equals(s.getName())) { + return ParquetIngestPoolStats.from(s); + } + } } catch (Exception e) { - logger.warn("Failed to collect native runtime metrics; returning per-shard aggregate without runtime block", e); - return Optional.of(agg); + logger.warn("Failed to collect parquet ingest pool stats", e); } + return null; } @Override diff --git a/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/store/TieredStorageBridge.java b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/store/TieredStorageBridge.java index bc5cac90b9266..4a0d56261f6d8 100644 --- a/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/store/TieredStorageBridge.java +++ b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/store/TieredStorageBridge.java @@ -150,7 +150,9 @@ public static void registerFile(long storePtr, String file, String path, int loc registerFiles(storePtr, java.util.Map.of(file, path != null ? path : ""), location, size); } - /** Remove a file from the registry. */ + /** + * Remove a file from the registry. + */ public static void removeFile(long storePtr, String path) { try (Arena arena = Arena.ofConfined()) { MemorySegment seg = arena.allocateFrom(path); diff --git a/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/vsr/VSRManager.java b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/vsr/VSRManager.java index 9e1c994cb1c71..35204ae596c58 100644 --- a/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/vsr/VSRManager.java +++ b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/vsr/VSRManager.java @@ -14,11 +14,11 @@ import org.apache.arrow.vector.types.pojo.Schema; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.opensearch.core.concurrency.OpenSearchRejectedExecutionException; import org.opensearch.index.IndexSettings; import org.opensearch.index.engine.dataformat.DocumentInput; import org.opensearch.index.engine.dataformat.RowIdMapping; import org.opensearch.index.mapper.MappedFieldType; -import org.opensearch.index.mapper.MapperParsingException; import org.opensearch.nativebridge.spi.ArrowExport; import org.opensearch.parquet.ParquetDataFormatPlugin; import org.opensearch.parquet.bridge.NativeParquetWriter; @@ -34,9 +34,6 @@ import org.opensearch.threadpool.ThreadPool; import java.io.IOException; -import java.util.Collections; -import java.util.IdentityHashMap; -import java.util.Set; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -188,14 +185,11 @@ public VSRManager( * Transfers collected fields from the document input into the active VSR * using the ArrowFieldRegistry to resolve typed vector writes. *

    - * Enforces single-value semantics: if the same {@link MappedFieldType} instance - * appears more than once in the document's field list, a {@link MapperParsingException} - * is thrown. Identity equality (reference {@code ==}) is used because the mapper - * service reuses field type instances — the same instance appearing twice indicates - * a multi-value field, which columnar formats do not support. + * Single-value semantics are enforced at the {@link ParquetDocumentInput} layer: + * if an array field produces multiple values for the same field type, only the + * last value is retained (last-value-wins). * * @param doc the document input containing field-value pairs - * @throws MapperParsingException if a field appears more than once in the document */ public void addDocument(ParquetDocumentInput doc) throws IOException { if (pendingWrite != null && pendingWrite.isDone()) { @@ -215,14 +209,8 @@ public void addDocument(ParquetDocumentInput doc) throws IOException { ); } ManagedVSR activeVSR = managedVSR.get(); - Set dedup = Collections.newSetFromMap(new IdentityHashMap<>()); for (FieldValuePair pair : doc.getFinalInput()) { MappedFieldType fieldType = pair.getFieldType(); - if (dedup.add(fieldType) == false) { - throw new MapperParsingException( - "Cannot accept multiple values for field: [" + fieldType.name() + "] of type: [" + fieldType.typeName() + "]." - ); - } ParquetField parquetField = ArrowFieldRegistry.getParquetField(fieldType.typeName()); if (parquetField == null) { // Defense-in-depth: schema reconciliation is supposed to happen in @@ -320,7 +308,13 @@ public void maybeRotateActiveVSR() throws IOException { vsrPool.completeVSR(frozenVSR); vsrPool.unsetFrozenVSR(); }; - pendingWrite = threadPool.executor(vsrRotationThread).submit(writeTask); + try { + pendingWrite = threadPool.executor(vsrRotationThread).submit(writeTask); + } catch (OpenSearchRejectedExecutionException e) { + // Pool saturated — count the rejection and re-throw (surfaces as HTTP 429). + stats.incNativeWriteRejections(); + throw e; + } } ManagedVSR newVSR = vsrPool.getActiveVSR(); if (newVSR == null) { diff --git a/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/writer/ParquetDocumentInput.java b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/writer/ParquetDocumentInput.java index 7832b4f990f90..3429c030266fc 100644 --- a/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/writer/ParquetDocumentInput.java +++ b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/writer/ParquetDocumentInput.java @@ -15,11 +15,14 @@ import org.opensearch.index.engine.exec.PrimaryTermFieldType; import org.opensearch.index.mapper.IdFieldMapper; import org.opensearch.index.mapper.MappedFieldType; +import org.opensearch.index.mapper.MapperParsingException; import org.opensearch.index.mapper.SeqNoFieldMapper; import org.opensearch.index.mapper.VersionFieldMapper; import org.opensearch.parquet.ParquetDataFormatPlugin; import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; import java.util.List; import java.util.Set; @@ -37,6 +40,7 @@ public class ParquetDocumentInput implements DocumentInput> private static final Logger logger = LogManager.getLogger(ParquetDocumentInput.class); private final List collectedFields = new ArrayList<>(); + private final Set dedup = Collections.newSetFromMap(new IdentityHashMap<>()); private long rowId = -1; private boolean isClosed = false; @@ -50,6 +54,11 @@ public void addField(MappedFieldType fieldType, Object value) { logger.trace("Ignored to add field: {} {}", fieldType.name(), fieldType.getCapabilityMap()); return; } + if (dedup.add(fieldType) == false) { + throw new MapperParsingException( + "Cannot accept multiple values for field: [" + fieldType.name() + "] of type: [" + fieldType.typeName() + "]." + ); + } collectedFields.add(new FieldValuePair(fieldType, value)); } diff --git a/sandbox/plugins/parquet-data-format/src/main/rust/benches/merge_projection_bench.rs b/sandbox/plugins/parquet-data-format/src/main/rust/benches/merge_projection_bench.rs index 31d488ae8b8a6..8911ffbe682d1 100644 --- a/sandbox/plugins/parquet-data-format/src/main/rust/benches/merge_projection_bench.rs +++ b/sandbox/plugins/parquet-data-format/src/main/rust/benches/merge_projection_bench.rs @@ -25,15 +25,8 @@ const WIDE_STRING_COLUMNS: usize = 20; const NARROW_STRING_COLUMNS: usize = 2; const STRING_VALUE_LEN: usize = 100; -fn generate_parquet_file( - path: &str, - num_rows: usize, - num_string_cols: usize, - file_id: usize, -) { - let mut fields = vec![ - Field::new("@timestamp", DataType::Int64, false), - ]; +fn generate_parquet_file(path: &str, num_rows: usize, num_string_cols: usize, file_id: usize) { + let mut fields = vec![Field::new("@timestamp", DataType::Int64, false)]; for i in 0..num_string_cols { fields.push(Field::new(format!("field_{}", i), DataType::Utf8, true)); } @@ -86,7 +79,12 @@ fn measure_resident_bytes() -> usize { #[cfg(target_os = "linux")] { let statm = fs::read_to_string("/proc/self/statm").unwrap_or_default(); - let pages: usize = statm.split_whitespace().nth(1).unwrap_or("0").parse().unwrap_or(0); + let pages: usize = statm + .split_whitespace() + .nth(1) + .unwrap_or("0") + .parse() + .unwrap_or(0); pages * 4096 // RSS in bytes (page size = 4KB) } #[cfg(not(target_os = "linux"))] @@ -140,7 +138,8 @@ fn run_merge_bench(label: &str, num_string_cols: usize) { let total_rows = ROWS_PER_FILE * NUM_FILES; let rows_per_sec = total_rows as f64 / elapsed.as_secs_f64(); let mb_per_sec = { - let input_size: u64 = input_paths.iter() + let input_size: u64 = input_paths + .iter() .map(|p| fs::metadata(p).map(|m| m.len()).unwrap_or(0)) .sum(); (input_size as f64 / 1024.0 / 1024.0) / elapsed.as_secs_f64() @@ -153,15 +152,34 @@ fn run_merge_bench(label: &str, num_string_cols: usize) { println!("┌─────────────────────────────────────────────────────────────"); println!("│ {} ", label); println!("├─────────────────────────────────────────────────────────────"); - println!("│ Files: {} × {} rows = {} total rows", NUM_FILES, ROWS_PER_FILE, total_rows); - println!("│ Columns: 1 sort (Int64) + {} string ({}B each)", num_string_cols, STRING_VALUE_LEN); + println!( + "│ Files: {} × {} rows = {} total rows", + NUM_FILES, ROWS_PER_FILE, total_rows + ); + println!( + "│ Columns: 1 sort (Int64) + {} string ({}B each)", + num_string_cols, STRING_VALUE_LEN + ); println!("│ Batch size: {}", BATCH_SIZE); println!("│ Elapsed: {:.2?}", elapsed); - println!("│ Throughput: {:.0} rows/sec, {:.1} MB/sec (input)", rows_per_sec, mb_per_sec); + println!( + "│ Throughput: {:.0} rows/sec, {:.1} MB/sec (input)", + rows_per_sec, mb_per_sec + ); println!("│ RSS delta: {:.1} MB", mem_delta_mb); - println!("│ Output rows: {}", output.metadata.file_metadata().num_rows()); + println!( + "│ Output rows: {}", + output.metadata.file_metadata().num_rows() + ); println!("│ Output RGs: {}", output.metadata.num_row_groups()); - println!("│ Deferred mode: {}", if num_string_cols >= 3 { "YES (expected)" } else { "NO (eager)" }); + println!( + "│ Deferred mode: {}", + if num_string_cols >= 3 { + "YES (expected)" + } else { + "NO (eager)" + } + ); println!("└─────────────────────────────────────────────────────────────"); println!(); } diff --git a/sandbox/plugins/parquet-data-format/src/main/rust/src/crc_writer.rs b/sandbox/plugins/parquet-data-format/src/main/rust/src/crc_writer.rs index 7ae7c436e9477..0be16dd89377e 100644 --- a/sandbox/plugins/parquet-data-format/src/main/rust/src/crc_writer.rs +++ b/sandbox/plugins/parquet-data-format/src/main/rust/src/crc_writer.rs @@ -31,7 +31,9 @@ pub struct CrcWriter { impl CrcWriter { pub fn new(inner: W) -> (Self, CrcHandle) { let hasher = Arc::new(Mutex::new(crc32fast::Hasher::new())); - let handle = CrcHandle { hasher: hasher.clone() }; + let handle = CrcHandle { + hasher: hasher.clone(), + }; (Self { inner, hasher }, handle) } } diff --git a/sandbox/plugins/parquet-data-format/src/main/rust/src/ffm.rs b/sandbox/plugins/parquet-data-format/src/main/rust/src/ffm.rs index 63d671c9788b1..36f94820a37ff 100644 --- a/sandbox/plugins/parquet-data-format/src/main/rust/src/ffm.rs +++ b/sandbox/plugins/parquet-data-format/src/main/rust/src/ffm.rs @@ -16,9 +16,9 @@ use std::str; use native_bridge_common::{ffm_safe, log_debug}; -use crate::native_settings::NativeSettings; use crate::field_config::FieldConfig; use crate::merge; +use crate::native_settings::NativeSettings; use crate::writer::{NativeParquetWriter, SETTINGS_STORE}; unsafe fn str_from_raw<'a>(ptr: *const u8, len: i64) -> Result<&'a str, String> { @@ -55,10 +55,7 @@ unsafe fn str_array_from_raw( } /// Decode a parallel (pointers, count) array of i64 values interpreted as booleans (0 = false). -unsafe fn bool_array_from_raw( - vals: *const i64, - count: i64, -) -> Vec { +unsafe fn bool_array_from_raw(vals: *const i64, count: i64) -> Vec { if count == 0 || vals.is_null() { return vec![]; } @@ -88,17 +85,27 @@ pub unsafe extern "C" fn parquet_create_writer( writer_generation: i64, ) -> i64 { let filename = str_from_raw(file_ptr, file_len) - .map_err(|e| format!("parquet_create_writer file: {}", e))?.to_string(); + .map_err(|e| format!("parquet_create_writer file: {}", e))? + .to_string(); let index_name = str_from_raw(index_name_ptr, index_name_len) - .map_err(|e| format!("parquet_create_writer index_name: {}", e))?.to_string(); + .map_err(|e| format!("parquet_create_writer index_name: {}", e))? + .to_string(); let sort_columns = str_array_from_raw(sort_ptrs, sort_lens, sort_count) .map_err(|e| format!("parquet_create_writer sort_columns: {}", e))?; let reverse_sorts = bool_array_from_raw(reverse_vals, reverse_count); let nulls_first = bool_array_from_raw(nulls_first_vals, nulls_first_count); - NativeParquetWriter::create_writer(filename, index_name, schema_address, sort_columns, reverse_sorts, nulls_first, writer_generation) - .map(|_| 0) - .map_err(|e| e.to_string()) + NativeParquetWriter::create_writer( + filename, + index_name, + schema_address, + sort_columns, + reverse_sorts, + nulls_first, + writer_generation, + ) + .map(|_| 0) + .map_err(|e| e.to_string()) } #[ffm_safe] @@ -109,7 +116,9 @@ pub unsafe extern "C" fn parquet_write( array_address: i64, schema_address: i64, ) -> i64 { - let filename = str_from_raw(file_ptr, file_len).map_err(|e| format!("parquet_write: {}", e))?.to_string(); + let filename = str_from_raw(file_ptr, file_len) + .map_err(|e| format!("parquet_write: {}", e))? + .to_string(); NativeParquetWriter::write_data(filename, array_address, schema_address) .map(|_| 0) .map_err(|e| e.to_string()) @@ -131,29 +140,44 @@ pub unsafe extern "C" fn parquet_finalize_writer( sort_perm_ptr_out: *mut i64, sort_perm_len_out: *mut i64, ) -> i64 { - let filename = str_from_raw(file_ptr, file_len).map_err(|e| format!("parquet_finalize_writer: {}", e))?.to_string(); + let filename = str_from_raw(file_ptr, file_len) + .map_err(|e| format!("parquet_finalize_writer: {}", e))? + .to_string(); match NativeParquetWriter::finalize_writer(filename) { Ok(Some(result)) => { let fm = result.metadata.file_metadata(); - if !version_out.is_null() { *version_out = fm.version(); } - if !num_rows_out.is_null() { *num_rows_out = fm.num_rows(); } + if !version_out.is_null() { + *version_out = fm.version(); + } + if !num_rows_out.is_null() { + *num_rows_out = fm.num_rows(); + } if let Some(cb) = fm.created_by() { if !created_by_buf.is_null() && created_by_buf_len > 0 { let bytes = cb.as_bytes(); let n = bytes.len().min(created_by_buf_len as usize); std::ptr::copy_nonoverlapping(bytes.as_ptr(), created_by_buf, n); - if !created_by_len_out.is_null() { *created_by_len_out = n as i64; } + if !created_by_len_out.is_null() { + *created_by_len_out = n as i64; + } } } else if !created_by_len_out.is_null() { *created_by_len_out = -1; } - if !crc32_out.is_null() { *crc32_out = result.crc32 as i64; } - if !num_row_groups_out.is_null() { *num_row_groups_out = result.metadata.num_row_groups() as i64; } + if !crc32_out.is_null() { + *crc32_out = result.crc32 as i64; + } + if !num_row_groups_out.is_null() { + *num_row_groups_out = result.metadata.num_row_groups() as i64; + } // Return sort permutation if present if !sort_perm_ptr_out.is_null() && !sort_perm_len_out.is_null() { if let Some(perm) = result.row_id_mapping { let len = perm.len(); + let mapping_bytes = len * std::mem::size_of::(); + // Track mapping handoff to Java — Java holds until parquet_free_row_id_mapping + crate::memory::write_pool().grow(mapping_bytes); let boxed = perm.into_boxed_slice(); *sort_perm_len_out = len as i64; *sort_perm_ptr_out = Box::into_raw(boxed) as *mut i64 as i64; @@ -169,7 +193,6 @@ pub unsafe extern "C" fn parquet_finalize_writer( } } - #[ffm_safe] #[no_mangle] pub unsafe extern "C" fn parquet_get_file_metadata( @@ -182,18 +205,28 @@ pub unsafe extern "C" fn parquet_get_file_metadata( created_by_len_out: *mut i64, num_row_groups_out: *mut i64, ) -> i64 { - let filename = str_from_raw(file_ptr, file_len).map_err(|e| format!("parquet_get_file_metadata: {}", e))?.to_string(); + let filename = str_from_raw(file_ptr, file_len) + .map_err(|e| format!("parquet_get_file_metadata: {}", e))? + .to_string(); let metadata = NativeParquetWriter::get_file_metadata(filename).map_err(|e| e.to_string())?; let fm = metadata.file_metadata(); - if !version_out.is_null() { *version_out = fm.version(); } - if !num_rows_out.is_null() { *num_rows_out = fm.num_rows(); } - if !num_row_groups_out.is_null() { *num_row_groups_out = metadata.num_row_groups() as i64; } + if !version_out.is_null() { + *version_out = fm.version(); + } + if !num_rows_out.is_null() { + *num_rows_out = fm.num_rows(); + } + if !num_row_groups_out.is_null() { + *num_row_groups_out = metadata.num_row_groups() as i64; + } if let Some(cb) = fm.created_by() { if !created_by_buf.is_null() && created_by_buf_len > 0 { let bytes = cb.as_bytes(); let n = bytes.len().min(created_by_buf_len as usize); std::ptr::copy_nonoverlapping(bytes.as_ptr(), created_by_buf, n); - if !created_by_len_out.is_null() { *created_by_len_out = n as i64; } + if !created_by_len_out.is_null() { + *created_by_len_out = n as i64; + } } } else if !created_by_len_out.is_null() { *created_by_len_out = -1; @@ -216,9 +249,12 @@ pub unsafe extern "C" fn parquet_get_column_metadata( use parquet::file::reader::{FileReader, SerializedFileReader}; use std::fs::File; - let filename = str_from_raw(file_ptr, file_len).map_err(|e| format!("parquet_get_column_metadata: {}", e))?.to_string(); + let filename = str_from_raw(file_ptr, file_len) + .map_err(|e| format!("parquet_get_column_metadata: {}", e))? + .to_string(); let file = File::open(&filename).map_err(|e| format!("Failed to open file: {}", e))?; - let reader = SerializedFileReader::new(file).map_err(|e| format!("Failed to read parquet: {}", e))?; + let reader = + SerializedFileReader::new(file).map_err(|e| format!("Failed to read parquet: {}", e))?; let metadata = reader.metadata(); if metadata.num_row_groups() == 0 { @@ -226,7 +262,9 @@ pub unsafe extern "C" fn parquet_get_column_metadata( let bytes = json.as_bytes(); let n = bytes.len().min(out_buf_len as usize); std::ptr::copy_nonoverlapping(bytes.as_ptr(), out_buf, n); - if !out_len.is_null() { *out_len = n as i64; } + if !out_len.is_null() { + *out_len = n as i64; + } return Ok(0); } @@ -238,11 +276,17 @@ pub unsafe extern "C" fn parquet_get_column_metadata( let encodings: Vec = col.encodings().map(|e| format!("{:?}", e)).collect(); let compression = format!("{:?}", col.compression()); let has_bloom_filter = col.bloom_filter_offset().is_some(); - if i > 0 { json.push(','); } + if i > 0 { + json.push(','); + } json.push_str(&format!( "\"{}\":{{\"encodings\":[{}],\"compression\":\"{}\",\"bloom_filter\":{}}}", col_name, - encodings.iter().map(|e| format!("\"{}\"" , e)).collect::>().join(","), + encodings + .iter() + .map(|e| format!("\"{}\"", e)) + .collect::>() + .join(","), compression, has_bloom_filter )); @@ -252,7 +296,9 @@ pub unsafe extern "C" fn parquet_get_column_metadata( let bytes = json.as_bytes(); let n = bytes.len().min(out_buf_len as usize); std::ptr::copy_nonoverlapping(bytes.as_ptr(), out_buf, n); - if !out_len.is_null() { *out_len = n as i64; } + if !out_len.is_null() { + *out_len = n as i64; + } Ok(0) } @@ -261,7 +307,9 @@ pub unsafe extern "C" fn parquet_get_filtered_native_bytes_used( prefix_ptr: *const u8, prefix_len: i64, ) -> i64 { - let prefix = str_from_raw(prefix_ptr, prefix_len).unwrap_or("").to_string(); + let prefix = str_from_raw(prefix_ptr, prefix_len) + .unwrap_or("") + .to_string(); NativeParquetWriter::get_filtered_writer_memory_usage(prefix).unwrap_or(0) as i64 } @@ -285,7 +333,6 @@ pub unsafe extern "C" fn parquet_on_settings_update( bloom_filter_fpp: f64, bloom_filter_ndv: i64, sort_in_memory_threshold_bytes: i64, - sort_batch_size: i64, row_group_max_rows: i64, row_group_max_bytes: i64, merge_batch_size: i64, @@ -329,93 +376,218 @@ pub unsafe extern "C" fn parquet_on_settings_update( type_bf_ndv_count: i64, ) -> i64 { let index_name = str_from_raw(index_name_ptr, index_name_len) - .map_err(|e| format!("parquet_on_settings_update index_name: {}", e))?.to_string(); + .map_err(|e| format!("parquet_on_settings_update index_name: {}", e))? + .to_string(); let compression_type = if compression_type_ptr.is_null() || compression_type_len < 0 { None } else { - Some(str_from_raw(compression_type_ptr, compression_type_len) - .map_err(|e| format!("parquet_on_settings_update compression_type: {}", e))?.to_string()) + Some( + str_from_raw(compression_type_ptr, compression_type_len) + .map_err(|e| format!("parquet_on_settings_update compression_type: {}", e))? + .to_string(), + ) }; - fn opt_i32(v: i64) -> Option { if v < 0 { None } else { Some(v as i32) } } - fn opt_usize(v: i64) -> Option { if v < 0 { None } else { Some(v as usize) } } - fn opt_bool(v: i64) -> Option { if v < 0 { None } else { Some(v != 0) } } - fn opt_f64(v: f64) -> Option { if v < 0.0 { None } else { Some(v) } } - fn opt_u64(v: i64) -> Option { if v < 0 { None } else { Some(v as u64) } } + fn opt_i32(v: i64) -> Option { + if v < 0 { + None + } else { + Some(v as i32) + } + } + fn opt_usize(v: i64) -> Option { + if v < 0 { + None + } else { + Some(v as usize) + } + } + fn opt_bool(v: i64) -> Option { + if v < 0 { + None + } else { + Some(v != 0) + } + } + fn opt_f64(v: f64) -> Option { + if v < 0.0 { + None + } else { + Some(v) + } + } + fn opt_u64(v: i64) -> Option { + if v < 0 { + None + } else { + Some(v as u64) + } + } let field_names = str_array_from_raw(field_name_ptrs, field_name_lens, field_count) .map_err(|e| format!("parquet_on_settings_update field_names: {}", e))?; let field_encodings = str_array_from_raw(field_encoding_ptrs, field_encoding_lens, field_count) .map_err(|e| format!("parquet_on_settings_update field_encodings: {}", e))?; - let field_compression_names = str_array_from_raw(field_compression_name_ptrs, field_compression_name_lens, field_compression_count) - .map_err(|e| format!("parquet_on_settings_update field_compression_names: {}", e))?; - let field_compressions = str_array_from_raw(field_compression_value_ptrs, field_compression_value_lens, field_compression_count) - .map_err(|e| format!("parquet_on_settings_update field_compressions: {}", e))?; - - let type_encoding_names = str_array_from_raw(type_encoding_name_ptrs, type_encoding_name_lens, type_encoding_count) - .map_err(|e| format!("parquet_on_settings_update type_encoding_names: {}", e))?; - let type_encodings = str_array_from_raw(type_encoding_value_ptrs, type_encoding_value_lens, type_encoding_count) - .map_err(|e| format!("parquet_on_settings_update type_encodings: {}", e))?; - let type_compression_names = str_array_from_raw(type_compression_name_ptrs, type_compression_name_lens, type_compression_count) - .map_err(|e| format!("parquet_on_settings_update type_compression_names: {}", e))?; - let type_compressions = str_array_from_raw(type_compression_value_ptrs, type_compression_value_lens, type_compression_count) - .map_err(|e| format!("parquet_on_settings_update type_compressions: {}", e))?; + let field_compression_names = str_array_from_raw( + field_compression_name_ptrs, + field_compression_name_lens, + field_compression_count, + ) + .map_err(|e| format!("parquet_on_settings_update field_compression_names: {}", e))?; + let field_compressions = str_array_from_raw( + field_compression_value_ptrs, + field_compression_value_lens, + field_compression_count, + ) + .map_err(|e| format!("parquet_on_settings_update field_compressions: {}", e))?; + + let type_encoding_names = str_array_from_raw( + type_encoding_name_ptrs, + type_encoding_name_lens, + type_encoding_count, + ) + .map_err(|e| format!("parquet_on_settings_update type_encoding_names: {}", e))?; + let type_encodings = str_array_from_raw( + type_encoding_value_ptrs, + type_encoding_value_lens, + type_encoding_count, + ) + .map_err(|e| format!("parquet_on_settings_update type_encodings: {}", e))?; + let type_compression_names = str_array_from_raw( + type_compression_name_ptrs, + type_compression_name_lens, + type_compression_count, + ) + .map_err(|e| format!("parquet_on_settings_update type_compression_names: {}", e))?; + let type_compressions = str_array_from_raw( + type_compression_value_ptrs, + type_compression_value_lens, + type_compression_count, + ) + .map_err(|e| format!("parquet_on_settings_update type_compressions: {}", e))?; // Parse per-field bloom filter arrays - let bf_enabled_names = str_array_from_raw(bf_enabled_name_ptrs, bf_enabled_name_lens, bf_enabled_count) - .map_err(|e| format!("parquet_on_settings_update bf_enabled_names: {}", e))?; + let bf_enabled_names = + str_array_from_raw(bf_enabled_name_ptrs, bf_enabled_name_lens, bf_enabled_count) + .map_err(|e| format!("parquet_on_settings_update bf_enabled_names: {}", e))?; let field_configs = { let mut map = std::collections::HashMap::new(); for (name, encoding) in field_names.into_iter().zip(field_encodings.into_iter()) { - map.insert(name, FieldConfig { encoding_type: Some(encoding), ..Default::default() }); + map.insert( + name, + FieldConfig { + encoding_type: Some(encoding), + ..Default::default() + }, + ); } - for (name, compression) in field_compression_names.into_iter().zip(field_compressions.into_iter()) { + for (name, compression) in field_compression_names + .into_iter() + .zip(field_compressions.into_iter()) + { map.entry(name) - .and_modify(|fc| fc.compression_type = Some(compression.clone())) - .or_insert(FieldConfig { compression_type: Some(compression), ..Default::default() }); + .and_modify(|fc| fc.compression_type = Some(compression.clone())) + .or_insert(FieldConfig { + compression_type: Some(compression), + ..Default::default() + }); } for (i, name) in bf_enabled_names.into_iter().enumerate() { let val = *bf_enabled_vals.add(i) != 0; map.entry(name) - .and_modify(|fc| fc.bloom_filter_enabled = Some(val)) - .or_insert(FieldConfig { bloom_filter_enabled: Some(val), ..Default::default() }); + .and_modify(|fc| fc.bloom_filter_enabled = Some(val)) + .or_insert(FieldConfig { + bloom_filter_enabled: Some(val), + ..Default::default() + }); + } + if map.is_empty() { + None + } else { + Some(map) } - if map.is_empty() { None } else { Some(map) } }; let type_encoding_configs: Option> = { - let map: std::collections::HashMap<_, _> = type_encoding_names.into_iter().zip(type_encodings.into_iter()).collect(); - if map.is_empty() { None } else { Some(map) } + let map: std::collections::HashMap<_, _> = type_encoding_names + .into_iter() + .zip(type_encodings.into_iter()) + .collect(); + if map.is_empty() { + None + } else { + Some(map) + } }; let type_compression_configs: Option> = { - let map: std::collections::HashMap<_, _> = type_compression_names.into_iter().zip(type_compressions.into_iter()).collect(); - if map.is_empty() { None } else { Some(map) } + let map: std::collections::HashMap<_, _> = type_compression_names + .into_iter() + .zip(type_compressions.into_iter()) + .collect(); + if map.is_empty() { + None + } else { + Some(map) + } }; // Parse type-level bloom filter arrays - let type_bf_enabled_names = str_array_from_raw(type_bf_enabled_name_ptrs, type_bf_enabled_name_lens, type_bf_enabled_count) - .map_err(|e| format!("parquet_on_settings_update type_bf_enabled_names: {}", e))?; - let type_bf_fpp_names = str_array_from_raw(type_bf_fpp_name_ptrs, type_bf_fpp_name_lens, type_bf_fpp_count) - .map_err(|e| format!("parquet_on_settings_update type_bf_fpp_names: {}", e))?; - let type_bf_ndv_names = str_array_from_raw(type_bf_ndv_name_ptrs, type_bf_ndv_name_lens, type_bf_ndv_count) - .map_err(|e| format!("parquet_on_settings_update type_bf_ndv_names: {}", e))?; + let type_bf_enabled_names = str_array_from_raw( + type_bf_enabled_name_ptrs, + type_bf_enabled_name_lens, + type_bf_enabled_count, + ) + .map_err(|e| format!("parquet_on_settings_update type_bf_enabled_names: {}", e))?; + let type_bf_fpp_names = str_array_from_raw( + type_bf_fpp_name_ptrs, + type_bf_fpp_name_lens, + type_bf_fpp_count, + ) + .map_err(|e| format!("parquet_on_settings_update type_bf_fpp_names: {}", e))?; + let type_bf_ndv_names = str_array_from_raw( + type_bf_ndv_name_ptrs, + type_bf_ndv_name_lens, + type_bf_ndv_count, + ) + .map_err(|e| format!("parquet_on_settings_update type_bf_ndv_names: {}", e))?; let type_bloom_filter_enabled: Option> = { - let map: std::collections::HashMap<_, _> = type_bf_enabled_names.into_iter().enumerate() - .map(|(i, name)| (name, *type_bf_enabled_vals.add(i) != 0)).collect(); - if map.is_empty() { None } else { Some(map) } + let map: std::collections::HashMap<_, _> = type_bf_enabled_names + .into_iter() + .enumerate() + .map(|(i, name)| (name, *type_bf_enabled_vals.add(i) != 0)) + .collect(); + if map.is_empty() { + None + } else { + Some(map) + } }; let type_bloom_filter_fpp: Option> = { - let map: std::collections::HashMap<_, _> = type_bf_fpp_names.into_iter().enumerate() - .map(|(i, name)| (name, *type_bf_fpp_vals.add(i))).collect(); - if map.is_empty() { None } else { Some(map) } + let map: std::collections::HashMap<_, _> = type_bf_fpp_names + .into_iter() + .enumerate() + .map(|(i, name)| (name, *type_bf_fpp_vals.add(i))) + .collect(); + if map.is_empty() { + None + } else { + Some(map) + } }; let type_bloom_filter_ndv: Option> = { - let map: std::collections::HashMap<_, _> = type_bf_ndv_names.into_iter().enumerate() - .map(|(i, name)| (name, *type_bf_ndv_vals.add(i) as u64)).collect(); - if map.is_empty() { None } else { Some(map) } + let map: std::collections::HashMap<_, _> = type_bf_ndv_names + .into_iter() + .enumerate() + .map(|(i, name)| (name, *type_bf_ndv_vals.add(i) as u64)) + .collect(); + if map.is_empty() { + None + } else { + Some(map) + } }; let config = NativeSettings { @@ -429,7 +601,6 @@ pub unsafe extern "C" fn parquet_on_settings_update( bloom_filter_fpp: opt_f64(bloom_filter_fpp), bloom_filter_ndv: opt_u64(bloom_filter_ndv), sort_in_memory_threshold_bytes: opt_u64(sort_in_memory_threshold_bytes), - sort_batch_size: opt_usize(sort_batch_size), row_group_max_rows: opt_usize(row_group_max_rows), row_group_max_bytes: opt_usize(row_group_max_bytes), merge_batch_size: opt_usize(merge_batch_size), @@ -455,7 +626,8 @@ pub unsafe extern "C" fn parquet_remove_settings( index_name_len: i64, ) -> i64 { let index_name = str_from_raw(index_name_ptr, index_name_len) - .map_err(|e| format!("parquet_remove_settings: {}", e))?.to_string(); + .map_err(|e| format!("parquet_remove_settings: {}", e))? + .to_string(); SETTINGS_STORE.remove(&index_name); Ok(0) } @@ -519,7 +691,12 @@ pub unsafe extern "C" fn parquet_merge_files( }; let result = if sort_cols.is_empty() { - merge::merge_unsorted(&input_files, output_path, index_name, output_writer_generation) + merge::merge_unsorted( + &input_files, + output_path, + index_name, + output_writer_generation, + ) } else { merge::merge_sorted( &input_files, @@ -535,23 +712,34 @@ pub unsafe extern "C" fn parquet_merge_files( // Write Parquet file metadata to out-pointers. let fm = result.metadata.file_metadata(); - if !version_out.is_null() { *version_out = fm.version(); } - if !num_rows_out.is_null() { *num_rows_out = fm.num_rows(); } + if !version_out.is_null() { + *version_out = fm.version(); + } + if !num_rows_out.is_null() { + *num_rows_out = fm.num_rows(); + } if let Some(cb) = fm.created_by() { if !created_by_buf.is_null() && created_by_buf_len > 0 { let bytes = cb.as_bytes(); let n = bytes.len().min(created_by_buf_len as usize); std::ptr::copy_nonoverlapping(bytes.as_ptr(), created_by_buf, n); - if !created_by_len_out.is_null() { *created_by_len_out = n as i64; } + if !created_by_len_out.is_null() { + *created_by_len_out = n as i64; + } } } else if !created_by_len_out.is_null() { *created_by_len_out = -1; } - if !crc32_out.is_null() { *crc32_out = result.crc32 as i64; } + if !crc32_out.is_null() { + *crc32_out = result.crc32 as i64; + } // Write row-ID mapping into out-pointers as heap-allocated arrays. // Java reads them and then calls parquet_free_merge_result to deallocate. let mapping = result.mapping.into_boxed_slice(); + let mapping_bytes = mapping.len() * std::mem::size_of::(); + // Track merge mapping handoff to Java — Java holds until parquet_free_merge_result + crate::memory::merge_pool().grow(mapping_bytes); *out_mapping_len = mapping.len() as i64; *out_mapping_ptr = Box::into_raw(mapping) as *mut i64 as i64; @@ -583,7 +771,13 @@ pub unsafe extern "C" fn parquet_free_merge_result( gen_count: i64, ) { if mapping_ptr != 0 && mapping_len > 0 { - let _ = Box::from_raw(slice::from_raw_parts_mut(mapping_ptr as *mut i64, mapping_len as usize)); + let mapping_bytes = mapping_len as usize * std::mem::size_of::(); + // Java released merge mapping — free from pool + crate::memory::merge_pool().shrink(mapping_bytes); + let _ = Box::from_raw(slice::from_raw_parts_mut( + mapping_ptr as *mut i64, + mapping_len as usize, + )); } let n = gen_count as usize; if gen_keys_ptr != 0 && n > 0 { @@ -617,13 +811,16 @@ pub unsafe extern "C" fn parquet_read_as_json( use arrow::array::Array; let filename = str_from_raw(file_ptr, file_len) - .map_err(|e| format!("parquet_read_as_json: {}", e))?.to_string(); + .map_err(|e| format!("parquet_read_as_json: {}", e))? + .to_string(); let file = std::fs::File::open(&filename) .map_err(|e| format!("Failed to open {}: {}", filename, e))?; let builder = parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder::try_new(file) .map_err(|e| format!("Failed to read parquet: {}", e))?; - let reader = builder.with_batch_size(8192).build() + let reader = builder + .with_batch_size(8192) + .build() .map_err(|e| format!("Failed to build reader: {}", e))?; let mut rows: Vec = Vec::new(); @@ -639,26 +836,43 @@ pub unsafe extern "C" fn parquet_read_as_json( } else { match col.data_type() { arrow::datatypes::DataType::Int32 => { - let arr = col.as_any().downcast_ref::().unwrap(); + let arr = col + .as_any() + .downcast_ref::() + .unwrap(); serde_json::Value::Number(arr.value(row_idx).into()) } arrow::datatypes::DataType::Int64 => { - let arr = col.as_any().downcast_ref::().unwrap(); + let arr = col + .as_any() + .downcast_ref::() + .unwrap(); serde_json::Value::Number(arr.value(row_idx).into()) } arrow::datatypes::DataType::Utf8 => { - let arr = col.as_any().downcast_ref::().unwrap(); + let arr = col + .as_any() + .downcast_ref::() + .unwrap(); serde_json::Value::String(arr.value(row_idx).to_string()) } arrow::datatypes::DataType::Boolean => { - let arr = col.as_any().downcast_ref::().unwrap(); + let arr = col + .as_any() + .downcast_ref::() + .unwrap(); serde_json::Value::Bool(arr.value(row_idx)) } arrow::datatypes::DataType::Float64 => { - let arr = col.as_any().downcast_ref::().unwrap(); + let arr = col + .as_any() + .downcast_ref::() + .unwrap(); serde_json::json!(arr.value(row_idx)) } - _ => serde_json::Value::String(format!("", col.data_type())), + _ => { + serde_json::Value::String(format!("", col.data_type())) + } } }; obj.insert(field.name().clone(), val); @@ -667,11 +881,15 @@ pub unsafe extern "C" fn parquet_read_as_json( } } - let json_str = serde_json::to_string(&rows) - .map_err(|e| format!("JSON serialization failed: {}", e))?; + let json_str = + serde_json::to_string(&rows).map_err(|e| format!("JSON serialization failed: {}", e))?; let bytes = json_str.as_bytes(); if bytes.len() > buf_capacity as usize { - return Err(format!("JSON output ({} bytes) exceeds buffer capacity ({})", bytes.len(), buf_capacity)); + return Err(format!( + "JSON output ({} bytes) exceeds buffer capacity ({})", + bytes.len(), + buf_capacity + )); } std::ptr::copy_nonoverlapping(bytes.as_ptr(), out_buf, bytes.len()); *out_len = bytes.len() as i64; @@ -684,12 +902,15 @@ pub unsafe extern "C" fn parquet_read_as_json( /// Frees the heap-allocated row ID mapping array returned as part of `parquet_finalize_writer`. #[no_mangle] -pub unsafe extern "C" fn parquet_free_row_id_mapping( - mapping_ptr: i64, - mapping_len: i64, -) { +pub unsafe extern "C" fn parquet_free_row_id_mapping(mapping_ptr: i64, mapping_len: i64) { if mapping_ptr != 0 && mapping_len > 0 { - let _ = Box::from_raw(slice::from_raw_parts_mut(mapping_ptr as *mut i64, mapping_len as usize)); + let mapping_bytes = mapping_len as usize * std::mem::size_of::(); + // Java released write mapping — free from pool + crate::memory::write_pool().shrink(mapping_bytes); + let _ = Box::from_raw(slice::from_raw_parts_mut( + mapping_ptr as *mut i64, + mapping_len as usize, + )); } } @@ -705,10 +926,7 @@ pub unsafe extern "C" fn parquet_free_row_id_mapping( /// Returns 0 on success, negative error pointer on failure (per FFM convention). #[ffm_safe] #[no_mangle] -pub unsafe extern "C" fn parquet_collect_runtime_metrics( - out_buf: *mut i64, - out_len: i64, -) -> i64 { +pub unsafe extern "C" fn parquet_collect_runtime_metrics(out_buf: *mut i64, out_len: i64) -> i64 { if out_buf.is_null() { return Err("parquet_collect_runtime_metrics: null out_buf".to_string()); } @@ -764,6 +982,20 @@ pub extern "C" fn parquet_set_merge_pool_limit(new_limit: i64) { crate::memory::set_merge_limit(new_limit as usize); } +/// Register the over-commit decision callbacks (FFM upcall stubs from the Java allocator). +/// +/// `decider(requested_bytes) -> 1|0` decides whether a full pool may over-commit; `releaser(bytes)` +/// is called with the granted byte count when the reservation is released. Because all native +/// modules share one cdylib (and thus one `native-bridge-common` instance), this single +/// registration covers every pool that uses `Reject`. +#[no_mangle] +pub extern "C" fn parquet_register_overcommit_callbacks( + decider: native_bridge_common::memory_pool::OverCommitDecider, + releaser: native_bridge_common::memory_pool::OverCommitReleaser, +) { + native_bridge_common::memory_pool::set_overcommit_callbacks(decider, releaser); +} + /// Get pool stats: writes 6 i64s to out_buf. /// Layout: [write_limit, write_used, write_peak, merge_limit, merge_used, merge_peak] #[no_mangle] diff --git a/sandbox/plugins/parquet-data-format/src/main/rust/src/field_config.rs b/sandbox/plugins/parquet-data-format/src/main/rust/src/field_config.rs index be9c8d990447e..6dab75658b190 100644 --- a/sandbox/plugins/parquet-data-format/src/main/rust/src/field_config.rs +++ b/sandbox/plugins/parquet-data-format/src/main/rust/src/field_config.rs @@ -60,7 +60,10 @@ mod tests { encoding_type: Some("DELTA_BINARY_PACKED".to_string()), ..Default::default() }; - assert_eq!(config.encoding_type, Some("DELTA_BINARY_PACKED".to_string())); + assert_eq!( + config.encoding_type, + Some("DELTA_BINARY_PACKED".to_string()) + ); assert!(!config.is_empty()); } } diff --git a/sandbox/plugins/parquet-data-format/src/main/rust/src/lib.rs b/sandbox/plugins/parquet-data-format/src/main/rust/src/lib.rs index 9a2fac354e97c..effface637f64 100644 --- a/sandbox/plugins/parquet-data-format/src/main/rust/src/lib.rs +++ b/sandbox/plugins/parquet-data-format/src/main/rust/src/lib.rs @@ -12,18 +12,18 @@ pub mod test_utils; #[cfg(test)] mod tests; -pub mod writer; +pub mod crc_writer; pub mod ffm; +pub mod field_config; pub mod memory; +pub mod merge; pub mod native_settings; -pub mod field_config; -pub mod writer_properties_builder; pub mod rate_limited_writer; -pub mod crc_writer; -pub mod merge; +pub mod writer; +pub mod writer_properties_builder; -pub use native_settings::NativeSettings; pub use field_config::FieldConfig; -pub use writer_properties_builder::WriterPropertiesBuilder; +pub use native_bridge_common::{log_debug, log_error, log_info}; +pub use native_settings::NativeSettings; pub use writer::SETTINGS_STORE; -pub use native_bridge_common::{log_info, log_error, log_debug}; +pub use writer_properties_builder::WriterPropertiesBuilder; diff --git a/sandbox/plugins/parquet-data-format/src/main/rust/src/memory.rs b/sandbox/plugins/parquet-data-format/src/main/rust/src/memory.rs index d88ee47f8e831..5132adbef8652 100644 --- a/sandbox/plugins/parquet-data-format/src/main/rust/src/memory.rs +++ b/sandbox/plugins/parquet-data-format/src/main/rust/src/memory.rs @@ -21,14 +21,14 @@ pub fn init_pools(write_limit: usize, merge_limit: usize) { MERGE_POOL.get_or_init(|| Arc::new(MemoryPool::new("merge", merge_limit))); } -/// Returns the write pool, or panics if not initialized. +/// Returns the write pool, initializing with a large default if not yet set. pub fn write_pool() -> &'static Arc { - WRITE_POOL.get().expect("write pool not initialized") + WRITE_POOL.get_or_init(|| Arc::new(MemoryPool::new("write", usize::MAX))) } -/// Returns the merge pool, or panics if not initialized. +/// Returns the merge pool, initializing with a large default if not yet set. pub fn merge_pool() -> &'static Arc { - MERGE_POOL.get().expect("merge pool not initialized") + MERGE_POOL.get_or_init(|| Arc::new(MemoryPool::new("merge", usize::MAX))) } pub fn set_write_limit(v: usize) { diff --git a/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/context.rs b/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/context.rs index 1715678f340c3..03d9dec407be4 100644 --- a/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/context.rs +++ b/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/context.rs @@ -12,7 +12,7 @@ use std::sync::Arc; use arrow::array::RecordBatch; use arrow::datatypes::{DataType as ArrowDataType, Field as ArrowField, Schema as ArrowSchema}; -use parquet::arrow::arrow_writer::{ArrowRowGroupWriterFactory, compute_leaves}; +use parquet::arrow::arrow_writer::{compute_leaves, ArrowRowGroupWriterFactory}; use parquet::file::writer::SerializedFileWriter; use parquet::schema::types::SchemaDescriptor; use rayon::prelude::*; @@ -23,10 +23,10 @@ use crate::rate_limited_writer::RateLimitedWriter; use crate::writer_properties_builder::WriterPropertiesBuilder; use crate::{log_debug, log_error, SETTINGS_STORE}; +use native_bridge_common::memory_pool::MemoryReservation; + use super::error::{MergeError, MergeResult}; -use super::io_task::{ - get_merge_pool, spawn_io_task, IoCommand, RATE_LIMIT_MB_PER_SEC, -}; +use super::io_task::{get_merge_pool, spawn_io_task, IoCommand, RATE_LIMIT_MB_PER_SEC}; use super::schema::{append_row_id, build_parquet_root_schema, ROW_ID_COLUMN_NAME}; /// Owns all shared state for a merge operation: schemas, writer factory, @@ -48,6 +48,8 @@ pub struct MergeContext { // Java side (see NativeParquetMergeStrategy + ParquetShardStatsTracker). flush_and_sort_chunk_count: i64, flush_and_sort_chunk_time_millis: i64, + reservation: MemoryReservation, + tracked_writer_bytes: usize, } impl MergeContext { @@ -62,6 +64,7 @@ impl MergeContext { rayon_threads: Option, io_threads: Option, output_writer_generation: i64, + reservation: MemoryReservation, ) -> MergeResult { if let Some(parent) = Path::new(output_path).parent() { if !parent.exists() { @@ -104,9 +107,16 @@ impl MergeContext { .get(index_name) .map(|r| r.clone()) .unwrap_or_default(); - let writer_props = Arc::new(WriterPropertiesBuilder::build_with_generation(&config, Some(output_writer_generation), &output_schema) - .map_err(|e| MergeError::Logic(format!("Invalid encoding/compression config: {}", e)))?); - + let writer_props = Arc::new( + WriterPropertiesBuilder::build_with_generation( + &config, + Some(output_writer_generation), + &output_schema, + ) + .map_err(|e| { + MergeError::Logic(format!("Invalid encoding/compression config: {}", e)) + })?, + ); let writer = SerializedFileWriter::new(crc_writer, parquet_root, writer_props)?; let rg_writer_factory = ArrowRowGroupWriterFactory::new(&writer, output_schema.clone()); @@ -128,6 +138,8 @@ impl MergeContext { rayon_threads, flush_and_sort_chunk_count: 0, flush_and_sort_chunk_time_millis: 0, + reservation, + tracked_writer_bytes: 0, }) } @@ -142,9 +154,15 @@ impl MergeContext { pub fn push_batch(&mut self, batch: RecordBatch) -> MergeResult<()> { let num_rows = batch.num_rows(); let with_id = append_row_id(&batch, self.next_row_id, &self.output_schema)?; + let with_id_bytes = with_id.get_array_memory_size(); drop(batch); - let col_writers = self.col_writers.as_mut() + // Track with_id batch (transient — alive during column write) + self.reservation.grow(with_id_bytes); + + let col_writers = self + .col_writers + .as_mut() .ok_or_else(|| MergeError::Logic("Column writers not initialized".into()))?; // Compute leaf columns (O(columns) pointer math), then parallel-write @@ -157,7 +175,8 @@ impl MergeContext { } let write_errors: Vec<_> = get_merge_pool(self.rayon_threads).install(|| { - col_writers.par_iter_mut() + col_writers + .par_iter_mut() .zip(all_leaves.into_par_iter()) .filter_map(|(writer, leaf)| writer.write(&leaf).err()) .collect() @@ -165,9 +184,24 @@ impl MergeContext { if let Some(e) = write_errors.into_iter().next() { log_error!("[RUST] Column write failed during push_batch: {}", e); + self.reservation.shrink(with_id_bytes); return Err(e.into()); } + // with_id dropped here — release its tracking + self.reservation.shrink(with_id_bytes); + + // Track actual column writer memory using memory_size() API + let actual_writer_bytes: usize = col_writers.iter().map(|w| w.memory_size()).sum(); + if actual_writer_bytes > self.tracked_writer_bytes { + self.reservation + .grow(actual_writer_bytes - self.tracked_writer_bytes); + } else if actual_writer_bytes < self.tracked_writer_bytes { + self.reservation + .shrink(self.tracked_writer_bytes - actual_writer_bytes); + } + self.tracked_writer_bytes = actual_writer_bytes; + self.next_row_id += num_rows as i64; self.output_row_count += num_rows; self.total_rows_written += num_rows; @@ -199,7 +233,9 @@ impl MergeContext { } fn do_flush(&mut self) -> MergeResult<()> { - let col_writers = self.col_writers.take() + let col_writers = self + .col_writers + .take() .ok_or_else(|| MergeError::Logic("Column writers not initialized".into()))?; let n = self.output_row_count; @@ -229,9 +265,14 @@ impl MergeContext { self.row_group_index += 1; self.output_row_count = 0; + // Column writers closed via close() — release tracked writer memory + self.reservation.shrink(self.tracked_writer_bytes); + self.tracked_writer_bytes = 0; + // Open writers for the next row group. self.col_writers = Some( - self.rg_writer_factory.create_column_writers(self.row_group_index)? + self.rg_writer_factory + .create_column_writers(self.row_group_index)?, ); log_debug!( diff --git a/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/cursor.rs b/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/cursor.rs index 97c5fa3e05930..bcb881b534378 100644 --- a/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/cursor.rs +++ b/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/cursor.rs @@ -19,6 +19,8 @@ use super::heap::{get_sort_values, SortKey}; use super::io_task::get_merge_pool; use super::schema::projection_indices_excluding_row_id; +use native_bridge_common::memory_pool::MemoryReservation; + /// A cursor over a single sorted Parquet input file. /// /// When deferred mode is active (controlled by the dynamic index setting @@ -43,6 +45,8 @@ pub struct FileCursor { pub sort_col_indices: Vec, pub sort_col_types: Vec, pub nulls_first: Vec, + current_sort_batch_bytes: usize, + current_data_batch_bytes: usize, } impl FileCursor { @@ -53,38 +57,56 @@ impl FileCursor { nulls_first: &[bool], batch_size: usize, deferred_threshold: usize, + reservation: &mut MemoryReservation, ) -> MergeResult<(Self, Arc, SchemaDescriptor, i64, usize)> { // Open file and read metadata let file = File::open(path)?; let builder = ParquetRecordBatchReaderBuilder::try_new(file)?; let schema = builder.schema().clone(); let writer_generation = crate::writer_properties_builder::read_writer_generation( - builder.metadata().file_metadata(), file_id, + builder.metadata().file_metadata(), + file_id, ); let total_row_count = builder.metadata().file_metadata().num_rows() as usize; let parquet_schema_descr = builder.parquet_schema().clone(); // Resolve sort column types - let sort_col_types: Vec = sort_columns.iter() - .map(|col| schema.fields().iter() - .find(|f| f.name() == col.as_str()) - .map(|f| f.data_type().clone()) - .ok_or_else(|| MergeError::Logic(format!( - "Sort column '{}' not found in file '{}' (cursor {})", col, path, file_id - ))) - ) + let sort_col_types: Vec = sort_columns + .iter() + .map(|col| { + schema + .fields() + .iter() + .find(|f| f.name() == col.as_str()) + .map(|f| f.data_type().clone()) + .ok_or_else(|| { + MergeError::Logic(format!( + "Sort column '{}' not found in file '{}' (cursor {})", + col, path, file_id + )) + }) + }) .collect::>()?; // Decide mode based on schema width let sort_col_set: std::collections::HashSet<&str> = sort_columns.iter().map(|s| s.as_str()).collect(); - let deferred = schema.fields().iter() + let deferred = schema + .fields() + .iter() .filter(|f| !sort_col_set.contains(f.name().as_str())) .filter(|f| f.name() != super::schema::ROW_ID_COLUMN_NAME) - .filter(|f| matches!(f.data_type(), - ArrowDataType::Utf8 | ArrowDataType::LargeUtf8 - | ArrowDataType::Binary | ArrowDataType::LargeBinary)) - .count() >= deferred_threshold; + .filter(|f| { + matches!( + f.data_type(), + ArrowDataType::Utf8 + | ArrowDataType::LargeUtf8 + | ArrowDataType::Binary + | ArrowDataType::LargeBinary + ) + }) + .count() + >= deferred_threshold; // Data projection: all columns except __row_id__ let data_projection_indices = projection_indices_excluding_row_id(&schema); @@ -93,50 +115,76 @@ impl FileCursor { let file1 = File::open(path)?; let builder1 = ParquetRecordBatchReaderBuilder::try_new(file1)?; let sort_projection = if deferred { - let sort_indices: Vec = sort_columns.iter() + let sort_indices: Vec = sort_columns + .iter() .filter_map(|c| schema.fields().iter().position(|f| f.name() == c.as_str())) .collect(); parquet::arrow::ProjectionMask::roots(builder1.parquet_schema(), sort_indices) } else { - parquet::arrow::ProjectionMask::roots(builder1.parquet_schema(), data_projection_indices.clone()) + parquet::arrow::ProjectionMask::roots( + builder1.parquet_schema(), + data_projection_indices.clone(), + ) }; - let mut sort_reader = builder1.with_batch_size(batch_size).with_projection(sort_projection).build()?; + let mut sort_reader = builder1 + .with_batch_size(batch_size) + .with_projection(sort_projection) + .build()?; // Build data reader (only in deferred mode) let data_reader = if deferred { let file2 = File::open(path)?; let builder2 = ParquetRecordBatchReaderBuilder::try_new(file2)?; let data_proj = parquet::arrow::ProjectionMask::roots( - builder2.parquet_schema(), data_projection_indices.clone(), + builder2.parquet_schema(), + data_projection_indices.clone(), ); - Some(builder2.with_batch_size(batch_size).with_projection(data_proj).build()?) + Some( + builder2 + .with_batch_size(batch_size) + .with_projection(data_proj) + .build()?, + ) } else { None }; // Projected schema from file metadata let projected_schema = Arc::new(ArrowSchema::new( - data_projection_indices.iter().map(|&i| schema.field(i).clone()).collect::>() + data_projection_indices + .iter() + .map(|&i| schema.field(i).clone()) + .collect::>(), )); // Read first sort batch let first_sort_batch = match sort_reader.next() { Some(Ok(b)) if b.num_rows() > 0 => b, Some(Err(e)) => return Err(e.into()), - _ => return Err(MergeError::Logic(format!( - "File '{}' (cursor {}) yielded no rows", path, file_id - ))), + _ => { + return Err(MergeError::Logic(format!( + "File '{}' (cursor {}) yielded no rows", + path, file_id + ))) + } }; // Resolve sort column indices within the sort batch schema let sort_batch_schema = first_sort_batch.schema(); - let sort_col_indices: Vec = sort_columns.iter() - .map(|col| sort_batch_schema.fields().iter() - .position(|f| f.name() == col.as_str()) - .ok_or_else(|| MergeError::Logic(format!( - "Sort column '{}' not found in projected batch for file '{}'", col, path - ))) - ) + let sort_col_indices: Vec = sort_columns + .iter() + .map(|col| { + sort_batch_schema + .fields() + .iter() + .position(|f| f.name() == col.as_str()) + .ok_or_else(|| { + MergeError::Logic(format!( + "Sort column '{}' not found in projected batch for file '{}'", + col, path + )) + }) + }) .collect::>()?; let (sort_prefetch_tx, sort_prefetch_rx) = @@ -159,9 +207,23 @@ impl FileCursor { sort_col_indices, sort_col_types, nulls_first: nulls_first.to_vec(), + current_sort_batch_bytes: 0, + current_data_batch_bytes: 0, }; + + // Track sort batch + prefetch (estimate 2x first batch) + let batch_bytes = cursor.sort_batch.as_ref().unwrap().get_array_memory_size(); + reservation.grow(batch_bytes * 2); + cursor.current_sort_batch_bytes = batch_bytes; + cursor.start_sort_prefetch(); - Ok((cursor, projected_schema, parquet_schema_descr, writer_generation, total_row_count)) + Ok(( + cursor, + projected_schema, + parquet_schema_descr, + writer_generation, + total_row_count, + )) } fn start_sort_prefetch(&mut self) { @@ -182,60 +244,91 @@ impl FileCursor { }); } - pub fn load_next_batch(&mut self) -> MergeResult { + pub fn load_next_batch(&mut self, reservation: &mut MemoryReservation) -> MergeResult { + let old_sort_bytes = self.current_sort_batch_bytes; self.sort_batch = None; + + // Release data batch tracking — previous data_batch is dropped + if self.current_data_batch_bytes > 0 { + reservation.shrink(self.current_data_batch_bytes); + self.current_data_batch_bytes = 0; + } self.data_batch = None; let sort_result = match self.sort_prefetch_rx.recv() { Ok(Some(Ok(batch))) => Some(batch), - Ok(Some(Err(e))) => { self.sort_prefetch_pending = false; return Err(e); } + Ok(Some(Err(e))) => { + self.sort_prefetch_pending = false; + // Error: release sort batch tracking since cursor is now exhausted + reservation.shrink(old_sort_bytes); + self.current_sort_batch_bytes = 0; + return Err(e); + } Ok(None) | Err(_) => None, }; self.sort_prefetch_pending = false; match sort_result { Some(batch) => { + let new_bytes = batch.get_array_memory_size(); self.sort_batch = Some(batch); self.row_idx = 0; self.sort_batch_index += 1; self.start_sort_prefetch(); + // Delta-adjust: new sort batch may differ in size from previous + if new_bytes > old_sort_bytes { + reservation.grow(new_bytes - old_sort_bytes); + } else if new_bytes < old_sort_bytes { + reservation.shrink(old_sort_bytes - new_bytes); + } + self.current_sort_batch_bytes = new_bytes; Ok(true) } None => { - self.data_reader = None; // Release file descriptor + // Cursor exhausted — release all sort batch tracking + self.data_reader = None; + reservation.shrink(old_sort_bytes); + self.current_sort_batch_bytes = 0; Ok(false) } } } - fn ensure_data_loaded(&mut self) -> MergeResult<()> { + fn ensure_data_loaded(&mut self, reservation: &mut MemoryReservation) -> MergeResult<()> { if !self.deferred { return Ok(()); } - // data_batch_index tracks "next batch to read" — after successfully loading - // sort_batch_index, it equals sort_batch_index + 1. if self.data_batch.is_some() && self.data_batch_index == self.sort_batch_index + 1 { return Ok(()); } - match self.try_load_data() { + match self.try_load_data(reservation) { Ok(()) => Ok(()), Err(e) => { - // Close the data reader on failure — the cursor is irrecoverable since - // the reader's internal position is unknown after a partial advance. + // Error path: close reader and release any data_batch memory self.data_reader = None; + if self.current_data_batch_bytes > 0 { + reservation.shrink(self.current_data_batch_bytes); + self.current_data_batch_bytes = 0; + } Err(e) } } } - fn try_load_data(&mut self) -> MergeResult<()> { - let reader = self.data_reader.as_mut() + fn try_load_data(&mut self, reservation: &mut MemoryReservation) -> MergeResult<()> { + let reader = self + .data_reader + .as_mut() .ok_or_else(|| MergeError::Logic("Data reader already closed".into()))?; - // Read batches from data reader until we have the one at sort_batch_index. - // data_batch_index tracks how many batches have been consumed from the reader - // (i.e., the next call to reader.next() will return batch number data_batch_index). + // Release previous data_batch — about to load a new one + if self.current_data_batch_bytes > 0 { + reservation.shrink(self.current_data_batch_bytes); + self.current_data_batch_bytes = 0; + } + self.data_batch = None; + while self.data_batch_index <= self.sort_batch_index { match reader.next() { Some(Ok(batch)) => { @@ -251,20 +344,28 @@ impl FileCursor { if batch.num_rows() != sb.num_rows() { return Err(MergeError::Logic(format!( "Data batch rows ({}) != sort batch rows ({}) at index {}", - batch.num_rows(), sb.num_rows(), self.sort_batch_index + batch.num_rows(), + sb.num_rows(), + self.sort_batch_index ))); } } + let data_bytes = batch.get_array_memory_size(); + // Track full-column data batch — already allocated by data_reader.next() + reservation.grow(data_bytes); + self.current_data_batch_bytes = data_bytes; self.data_batch = Some(batch); } // Skipped batch — discard self.data_batch_index += 1; } Some(Err(e)) => return Err(e.into()), - None => return Err(MergeError::Logic(format!( - "Data reader exhausted at position {}, needed sort_batch_index={}", - self.data_batch_index, self.sort_batch_index - ))), + None => { + return Err(MergeError::Logic(format!( + "Data reader exhausted at position {}, needed sort_batch_index={}", + self.data_batch_index, self.sort_batch_index + ))) + } } } Ok(()) @@ -272,16 +373,32 @@ impl FileCursor { #[inline] pub fn current_sort_values(&self) -> MergeResult> { - let batch = self.sort_batch.as_ref() + let batch = self + .sort_batch + .as_ref() .ok_or_else(|| MergeError::Logic("Cursor exhausted".into()))?; - get_sort_values(batch, self.row_idx, &self.sort_col_indices, &self.sort_col_types, &self.nulls_first) + get_sort_values( + batch, + self.row_idx, + &self.sort_col_indices, + &self.sort_col_types, + &self.nulls_first, + ) } #[inline] pub fn last_sort_values(&self) -> MergeResult> { - let batch = self.sort_batch.as_ref() + let batch = self + .sort_batch + .as_ref() .ok_or_else(|| MergeError::Logic("Cursor exhausted".into()))?; - get_sort_values(batch, batch.num_rows() - 1, &self.sort_col_indices, &self.sort_col_types, &self.nulls_first) + get_sort_values( + batch, + batch.num_rows() - 1, + &self.sort_col_indices, + &self.sort_col_types, + &self.nulls_first, + ) } #[inline] @@ -290,20 +407,29 @@ impl FileCursor { } #[inline] - pub fn take_slice(&mut self, start: usize, len: usize) -> MergeResult { + pub fn take_slice( + &mut self, + start: usize, + len: usize, + reservation: &mut MemoryReservation, + ) -> MergeResult { if self.deferred { - self.ensure_data_loaded()?; - let batch = self.data_batch.as_ref() + self.ensure_data_loaded(reservation)?; + let batch = self + .data_batch + .as_ref() .ok_or_else(|| MergeError::Logic("Data batch not loaded".into()))?; Ok(batch.slice(start, len)) } else { - let batch = self.sort_batch.as_ref() + let batch = self + .sort_batch + .as_ref() .ok_or_else(|| MergeError::Logic("Batch is None".into()))?; Ok(batch.slice(start, len)) } } - pub fn advance(&mut self) -> MergeResult { + pub fn advance(&mut self, reservation: &mut MemoryReservation) -> MergeResult { if self.sort_batch.is_none() { return Ok(false); } @@ -311,14 +437,24 @@ impl FileCursor { if self.row_idx >= self.sort_batch.as_ref().unwrap().num_rows() { self.sort_batch = None; self.data_batch = None; - return self.load_next_batch(); + // Batch boundary crossed — release data_batch before loading next sort batch + if self.current_data_batch_bytes > 0 { + reservation.shrink(self.current_data_batch_bytes); + self.current_data_batch_bytes = 0; + } + return self.load_next_batch(reservation); } Ok(true) } - pub fn advance_past_batch(&mut self) -> MergeResult { + pub fn advance_past_batch(&mut self, reservation: &mut MemoryReservation) -> MergeResult { self.sort_batch = None; self.data_batch = None; - self.load_next_batch() + // Skip remaining rows — release data_batch before loading next sort batch + if self.current_data_batch_bytes > 0 { + reservation.shrink(self.current_data_batch_bytes); + self.current_data_batch_bytes = 0; + } + self.load_next_batch(reservation) } } diff --git a/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/heap.rs b/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/heap.rs index 55755159bdc1f..c8e28b9e89771 100644 --- a/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/heap.rs +++ b/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/heap.rs @@ -13,8 +13,8 @@ use arrow::array::{AsArray, RecordBatch}; use arrow::datatypes::{ DataType as ArrowDataType, Date32Type, Date64Type, DurationMicrosecondType, DurationMillisecondType, DurationNanosecondType, DurationSecondType, Float32Type, Float64Type, - Int16Type, Int32Type, Int64Type, Int8Type, TimestampMicrosecondType, - TimestampMillisecondType, TimestampNanosecondType, TimestampSecondType, + Int16Type, Int32Type, Int64Type, Int8Type, TimestampMicrosecondType, TimestampMillisecondType, + TimestampNanosecondType, TimestampSecondType, }; use super::error::{MergeError, MergeResult}; @@ -77,8 +77,12 @@ pub fn cmp_sort_values(a: &[SortKey], b: &[SortKey], reverse_sorts: &[bool]) -> if ord != Ordering::Equal { let reverse = reverse_sorts.get(i).copied().unwrap_or(false); let is_null_cmp = matches!(av, SortKey::NullFirst | SortKey::NullLast) - || matches!(bv, SortKey::NullFirst | SortKey::NullLast); - return if reverse && !is_null_cmp { ord.reverse() } else { ord }; + || matches!(bv, SortKey::NullFirst | SortKey::NullLast); + return if reverse && !is_null_cmp { + ord.reverse() + } else { + ord + }; } } Ordering::Equal @@ -130,7 +134,11 @@ pub fn get_sort_value( ) -> MergeResult { let col = batch.column(col_idx); if col.is_null(row) { - return Ok(if null_first { SortKey::NullFirst } else { SortKey::NullLast }); + return Ok(if null_first { + SortKey::NullFirst + } else { + SortKey::NullLast + }); } let key = match dtype { // Integer types → SortKey::Int @@ -141,25 +149,47 @@ pub fn get_sort_value( ArrowDataType::Date32 => SortKey::Int(col.as_primitive::().value(row) as i64), ArrowDataType::Date64 => SortKey::Int(col.as_primitive::().value(row)), ArrowDataType::Timestamp(unit, _) => SortKey::Int(match unit { - arrow::datatypes::TimeUnit::Second => col.as_primitive::().value(row), - arrow::datatypes::TimeUnit::Millisecond => col.as_primitive::().value(row), - arrow::datatypes::TimeUnit::Microsecond => col.as_primitive::().value(row), - arrow::datatypes::TimeUnit::Nanosecond => col.as_primitive::().value(row), + arrow::datatypes::TimeUnit::Second => { + col.as_primitive::().value(row) + } + arrow::datatypes::TimeUnit::Millisecond => { + col.as_primitive::().value(row) + } + arrow::datatypes::TimeUnit::Microsecond => { + col.as_primitive::().value(row) + } + arrow::datatypes::TimeUnit::Nanosecond => { + col.as_primitive::().value(row) + } }), ArrowDataType::Duration(unit) => SortKey::Int(match unit { - arrow::datatypes::TimeUnit::Second => col.as_primitive::().value(row), - arrow::datatypes::TimeUnit::Millisecond => col.as_primitive::().value(row), - arrow::datatypes::TimeUnit::Microsecond => col.as_primitive::().value(row), - arrow::datatypes::TimeUnit::Nanosecond => col.as_primitive::().value(row), + arrow::datatypes::TimeUnit::Second => { + col.as_primitive::().value(row) + } + arrow::datatypes::TimeUnit::Millisecond => { + col.as_primitive::().value(row) + } + arrow::datatypes::TimeUnit::Microsecond => { + col.as_primitive::().value(row) + } + arrow::datatypes::TimeUnit::Nanosecond => { + col.as_primitive::().value(row) + } }), // Float types → SortKey::Float ArrowDataType::Float64 => SortKey::Float(col.as_primitive::().value(row)), - ArrowDataType::Float32 => SortKey::Float(col.as_primitive::().value(row) as f64), + ArrowDataType::Float32 => { + SortKey::Float(col.as_primitive::().value(row) as f64) + } // String types → SortKey::Bytes - ArrowDataType::Utf8 => SortKey::Bytes(col.as_string::().value(row).as_bytes().to_vec()), - ArrowDataType::LargeUtf8 => SortKey::Bytes(col.as_string::().value(row).as_bytes().to_vec()), + ArrowDataType::Utf8 => { + SortKey::Bytes(col.as_string::().value(row).as_bytes().to_vec()) + } + ArrowDataType::LargeUtf8 => { + SortKey::Bytes(col.as_string::().value(row).as_bytes().to_vec()) + } other => { return Err(MergeError::Logic(format!( diff --git a/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/io_task.rs b/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/io_task.rs index ed432fd7c3b58..e2383aba53237 100644 --- a/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/io_task.rs +++ b/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/io_task.rs @@ -14,13 +14,13 @@ use parquet::file::writer::SerializedFileWriter; use rayon::ThreadPool; +use crate::crc_writer::CrcWriter; +use crate::log_error; +use crate::rate_limited_writer::RateLimitedWriter; +use native_bridge_common::log_info; use tokio::runtime::Runtime; use tokio::sync::{mpsc as tokio_mpsc, oneshot}; use tokio::task::JoinHandle; -use native_bridge_common::log_info; -use crate::crc_writer::CrcWriter; -use crate::rate_limited_writer::RateLimitedWriter; -use crate::log_error; use super::error::{MergeError, MergeResult}; // ============================================================================= @@ -92,9 +92,9 @@ pub enum IoCommand { async fn drain_on_error(rx: &mut tokio_mpsc::Receiver, msg: &str) { while let Some(cmd) = rx.recv().await { if let IoCommand::Close(reply) = cmd { - let _ = reply.send(Err(MergeError::Logic( - format!("Prior IO write failed: {msg}"), - ))); + let _ = reply.send(Err(MergeError::Logic(format!( + "Prior IO write failed: {msg}" + )))); } } } diff --git a/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/mod.rs b/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/mod.rs index 09d6d90a8d844..545337729e297 100644 --- a/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/mod.rs +++ b/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/mod.rs @@ -18,7 +18,9 @@ mod unsorted; pub use error::{MergeError, MergeResult}; pub use sorted::merge_sorted; +pub use sorted::merge_sorted_with_pool; pub use unsorted::merge_unsorted; +pub use unsorted::merge_unsorted_with_pool; /// Output of a merge operation. Carries both the row-ID mapping (for remapping /// secondary-format row IDs post-merge) and the Parquet file metadata + CRC32 diff --git a/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/schema.rs b/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/schema.rs index 9cfbb10fd8c7d..73e572f9b951f 100644 --- a/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/schema.rs +++ b/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/schema.rs @@ -33,18 +33,15 @@ pub fn build_parquet_root_schema( for descr in schema_descriptors { let root = descr.root_schema(); for field in root.get_fields() { - if field.name() != ROW_ID_COLUMN_NAME - && seen_names.insert(field.name().to_string()) - { + if field.name() != ROW_ID_COLUMN_NAME && seen_names.insert(field.name().to_string()) { parquet_fields.push(Arc::new(field.as_ref().clone())); } } } - let row_id_type = - Type::primitive_type_builder(ROW_ID_COLUMN_NAME, parquet::basic::Type::INT64) - .with_repetition(Repetition::REQUIRED) - .build()?; + let row_id_type = Type::primitive_type_builder(ROW_ID_COLUMN_NAME, parquet::basic::Type::INT64) + .with_repetition(Repetition::REQUIRED) + .build()?; parquet_fields.push(Arc::new(row_id_type)); let parquet_root = Type::group_type_builder("schema") @@ -65,7 +62,6 @@ pub fn projection_indices_excluding_row_id(schema: &ArrowSchema) -> Vec { .collect() } - /// Appends a `__row_id__` column with sequential values `[start_id, start_id + N)` /// to the given batch, producing a new batch with the output schema. pub fn append_row_id( @@ -117,7 +113,11 @@ impl ColumnMapping { } } - Self { mapping, target_schema: target_schema.clone(), is_identity } + Self { + mapping, + target_schema: target_schema.clone(), + is_identity, + } } /// Remap a batch using the precomputed mapping. Zero-copy when schemas match. diff --git a/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/sorted.rs b/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/sorted.rs index 109fd3374ee66..7e1dbca8586e0 100644 --- a/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/sorted.rs +++ b/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/sorted.rs @@ -21,6 +21,9 @@ use super::heap::{cmp_sort_values, get_sort_values, HeapItem}; use super::io_task::get_merge_pool; use super::schema::ColumnMapping; +use crate::memory::merge_pool; +use native_bridge_common::memory_pool::{MemoryReservation, PoolBehavior}; + /// Performs a streaming k-way merge with an explicit sort direction per column. pub fn merge_sorted( input_files: &[String], @@ -30,6 +33,31 @@ pub fn merge_sorted( reverse_sorts: &[bool], nulls_first: &[bool], output_writer_generation: i64, +) -> super::MergeResult { + let mut reservation = + MemoryReservation::new(merge_pool(), "merge_sorted", PoolBehavior::Reject); + merge_sorted_with_pool( + input_files, + output_path, + index_name, + sort_columns, + reverse_sorts, + nulls_first, + output_writer_generation, + &mut reservation, + ) +} + +/// Performs a streaming k-way merge using the provided memory reservation. +pub fn merge_sorted_with_pool( + input_files: &[String], + output_path: &str, + index_name: &str, + sort_columns: &[String], + reverse_sorts: &[bool], + nulls_first: &[bool], + output_writer_generation: i64, + reservation: &mut MemoryReservation, ) -> super::MergeResult { let config = crate::writer::SETTINGS_STORE .get(index_name) @@ -82,8 +110,15 @@ pub fn merge_sorted( for (file_id, path) in input_files.iter().enumerate() { log_debug!("[RUST] Opening cursor {} for file: {}", file_id, path); - let (cursor, projected_schema, parquet_descr, generation, row_count) = - FileCursor::new(path, file_id, sort_columns, nulls_first, batch_size, deferred_threshold)?; + let (cursor, projected_schema, parquet_descr, generation, row_count) = FileCursor::new( + path, + file_id, + sort_columns, + nulls_first, + batch_size, + deferred_threshold, + reservation, + )?; cursors.push(cursor); arrow_schemas.push(projected_schema.as_ref().clone()); parquet_descriptors.push(parquet_descr); @@ -94,6 +129,7 @@ pub fn merge_sorted( let num_cursors = cursors.len(); // ── Phase 2: Create MergeContext (union schemas, writer, IO task) ─── + let ctx_reservation = reservation.child("merge:flush"); let mut ctx = MergeContext::new( arrow_schemas.clone(), &parquet_descriptors, @@ -103,16 +139,23 @@ pub fn merge_sorted( rayon_threads, io_threads, output_writer_generation, + ctx_reservation, )?; // Precompute column mappings per cursor (avoids per-batch name lookups) - let col_mappings: Vec = arrow_schemas.iter() + let col_mappings: Vec = arrow_schemas + .iter() .map(|s| ColumnMapping::new(s, ctx.data_schema())) .collect(); // Row-ID mapping: pre-allocate the flat mapping array and compute offsets // from file metadata row counts (known before reading any data). let total_rows: usize = file_row_counts.iter().sum(); + let mapping_bytes = total_rows * std::mem::size_of::(); + // Reserve for row-ID mapping Vec — total_rows × 8 bytes, allocated next line + reservation + .request(mapping_bytes) + .map_err(|e| super::MergeError::Logic(format!("Merge pool exceeded (mapping): {}", e)))?; let mut mapping: Vec = vec![0i64; total_rows]; let mut gen_keys: Vec = Vec::with_capacity(num_cursors); let mut gen_offsets: Vec = Vec::with_capacity(num_cursors); @@ -161,7 +204,7 @@ pub fn merge_sorted( loop { let remaining = cursor.batch_height() - cursor.row_idx; if remaining > 0 { - let slice = cursor.take_slice(cursor.row_idx, remaining)?; + let slice = cursor.take_slice(cursor.row_idx, remaining, reservation)?; for _ in 0..remaining { mapping[file_offset + rows_emitted_per_file[file_id]] = new_row_id; rows_emitted_per_file[file_id] += 1; @@ -169,7 +212,7 @@ pub fn merge_sorted( } ctx.push_batch(col_mapping.pad_batch(&slice)?)?; } - if !cursor.advance_past_batch()? { + if !cursor.advance_past_batch(reservation)? { break; } } @@ -188,7 +231,7 @@ pub fn merge_sorted( let last_val = cursor.last_sort_values()?; if cmp_sort_values(&last_val, heap_top, reverse_sorts) != Ordering::Greater { let remaining = cursor.batch_height() - cursor.row_idx; - let slice = cursor.take_slice(cursor.row_idx, remaining)?; + let slice = cursor.take_slice(cursor.row_idx, remaining, reservation)?; for _ in 0..remaining { mapping[file_offset + rows_emitted_per_file[file_id]] = new_row_id; rows_emitted_per_file[file_id] += 1; @@ -196,7 +239,7 @@ pub fn merge_sorted( } ctx.push_batch(col_mapping.pad_batch(&slice)?)?; - if !cursor.advance_past_batch()? { + if !cursor.advance_past_batch(reservation)? { break; } // Check if cursor should yield after loading new batch @@ -240,7 +283,7 @@ pub fn merge_sorted( let run_len = run_end - run_start + 1; if run_len > 0 { - let slice = cursor.take_slice(run_start, run_len)?; + let slice = cursor.take_slice(run_start, run_len, reservation)?; for _ in 0..run_len { mapping[file_offset + rows_emitted_per_file[file_id]] = new_row_id; rows_emitted_per_file[file_id] += 1; @@ -250,7 +293,7 @@ pub fn merge_sorted( } cursor.row_idx = run_end; - if !cursor.advance()? { + if !cursor.advance(reservation)? { break; } @@ -278,6 +321,9 @@ pub fn merge_sorted( stats.crc32 ); + // Detach mapping from reservation — FFI layer will track via merge_pool().grow + reservation.shrink(mapping_bytes); + Ok(super::MergeOutput { mapping, gen_keys, diff --git a/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/unsorted.rs b/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/unsorted.rs index 8ce8059ceb65f..8f1ef0260666d 100644 --- a/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/unsorted.rs +++ b/sandbox/plugins/parquet-data-format/src/main/rust/src/merge/unsorted.rs @@ -19,6 +19,9 @@ use super::context::MergeContext; use super::error::MergeResult; use super::schema::{projection_indices_excluding_row_id, ColumnMapping}; +use crate::memory::merge_pool; +use native_bridge_common::memory_pool::{MemoryReservation, PoolBehavior}; + /// Unsorted merge: reads each input file sequentially, pads to union schema, /// rewrites `__row_id__` with globally sequential values. No sorting performed. pub fn merge_unsorted( @@ -26,6 +29,25 @@ pub fn merge_unsorted( output_path: &str, index_name: &str, output_writer_generation: i64, +) -> MergeResult { + let mut reservation = + MemoryReservation::new(merge_pool(), "merge_unsorted", PoolBehavior::Reject); + merge_unsorted_with_pool( + input_files, + output_path, + index_name, + output_writer_generation, + &mut reservation, + ) +} + +/// Unsorted merge with an explicit memory reservation. +pub fn merge_unsorted_with_pool( + input_files: &[String], + output_path: &str, + index_name: &str, + output_writer_generation: i64, + reservation: &mut MemoryReservation, ) -> MergeResult { let config = crate::writer::SETTINGS_STORE .get(index_name) @@ -54,11 +76,17 @@ pub fn merge_unsorted( let schema = builder.schema().clone(); let parquet_descr = builder.parquet_schema().clone(); let num_rows = builder.metadata().file_metadata().num_rows() as usize; - let generation = crate::writer_properties_builder::read_writer_generation(builder.metadata().file_metadata(), file_idx); + let generation = crate::writer_properties_builder::read_writer_generation( + builder.metadata().file_metadata(), + file_idx, + ); let projection_indices = projection_indices_excluding_row_id(&schema); let projection = parquet::arrow::ProjectionMask::roots(&parquet_descr, projection_indices); - let reader = builder.with_batch_size(batch_size).with_projection(projection).build()?; + let reader = builder + .with_batch_size(batch_size) + .with_projection(projection) + .build()?; // The reader's schema is the projected schema (__row_id__ excluded). arrow_schemas.push(reader.schema().as_ref().clone()); @@ -68,6 +96,7 @@ pub fn merge_unsorted( file_generations.push(generation); } + let ctx_reservation = reservation.child("merge:flush"); let mut ctx = MergeContext::new( arrow_schemas.clone(), &parquet_descriptors, @@ -77,16 +106,22 @@ pub fn merge_unsorted( rayon_threads, io_threads, output_writer_generation, + ctx_reservation, )?; // Precompute column mappings per reader - let col_mappings: Vec = arrow_schemas.iter() + let col_mappings: Vec = arrow_schemas + .iter() .map(|s| ColumnMapping::new(s, ctx.data_schema())) .collect(); // Build row-ID mapping: for unsorted merge, files are concatenated sequentially. // old_row_id maps directly to new_row_id with a per-file offset. let total_rows: usize = file_row_counts.iter().sum(); + let mapping_bytes = total_rows * std::mem::size_of::(); + reservation + .request(mapping_bytes) + .map_err(|e| super::MergeError::Logic(format!("Merge pool exceeded (mapping): {}", e)))?; let mut mapping: Vec = vec![0i64; total_rows]; let mut gen_keys: Vec = Vec::with_capacity(input_files.len()); let mut gen_offsets: Vec = Vec::with_capacity(input_files.len()); @@ -108,10 +143,23 @@ pub fn merge_unsorted( let file_start_row_id = new_row_id; let col_mapping = &col_mappings[file_idx]; + let mut batch_tracked: usize = 0; for batch_result in reader { let batch = batch_result?; let num_rows = batch.num_rows(); - // Record mapping: each row in this batch gets the next sequential new_row_id + let batch_bytes = batch.get_array_memory_size(); + // Track reader batch memory: grow on first batch, delta-adjust on subsequent + if batch_tracked == 0 { + reservation.grow(batch_bytes); + batch_tracked = batch_bytes; + } else if batch_bytes != batch_tracked { + if batch_bytes > batch_tracked { + reservation.grow(batch_bytes - batch_tracked); + } else { + reservation.shrink(batch_tracked - batch_bytes); + } + batch_tracked = batch_bytes; + } for _ in 0..num_rows { mapping[mapping_offset] = new_row_id; mapping_offset += 1; @@ -119,6 +167,8 @@ pub fn merge_unsorted( } ctx.push_batch(col_mapping.pad_batch(&batch)?)?; } + // File done — release batch memory (reader dropped, batch no longer alive) + reservation.shrink(batch_tracked); let file_rows = (new_row_id - file_start_row_id) as i32; gen_sizes.push(file_rows); @@ -134,6 +184,9 @@ pub fn merge_unsorted( stats.crc32 ); + // Detach mapping from reservation — FFI layer will track via merge_pool().grow + reservation.shrink(mapping_bytes); + Ok(super::MergeOutput { mapping, gen_keys, diff --git a/sandbox/plugins/parquet-data-format/src/main/rust/src/native_settings.rs b/sandbox/plugins/parquet-data-format/src/main/rust/src/native_settings.rs index 1dcb2b94a721a..44bf68c277a25 100644 --- a/sandbox/plugins/parquet-data-format/src/main/rust/src/native_settings.rs +++ b/sandbox/plugins/parquet-data-format/src/main/rust/src/native_settings.rs @@ -32,7 +32,6 @@ pub struct NativeSettings { pub reverse_sorts: Vec, pub nulls_first: Vec, pub sort_in_memory_threshold_bytes: Option, - pub sort_batch_size: Option, pub merge_batch_size: Option, pub row_group_max_rows: Option, pub row_group_max_bytes: Option, @@ -83,15 +82,14 @@ impl NativeSettings { } pub fn has_field_configs(&self) -> bool { - self.field_configs.as_ref().map_or(false, |configs| !configs.is_empty()) + self.field_configs + .as_ref() + .map_or(false, |configs| !configs.is_empty()) } pub fn get_sort_in_memory_threshold_bytes(&self) -> u64 { - self.sort_in_memory_threshold_bytes.unwrap_or(32 * 1024 * 1024) - } - - pub fn get_sort_batch_size(&self) -> usize { - self.sort_batch_size.unwrap_or(8192) + self.sort_in_memory_threshold_bytes + .unwrap_or(32 * 1024 * 1024) } pub fn get_merge_batch_size(&self) -> usize { @@ -149,12 +147,15 @@ mod tests { use std::collections::HashMap; let mut field_configs = HashMap::new(); - field_configs.insert("timestamp".to_string(), FieldConfig { - compression_type: Some("SNAPPY".to_string()), - compression_level: None, - encoding_type: None, - ..Default::default() - }); + field_configs.insert( + "timestamp".to_string(), + FieldConfig { + compression_type: Some("SNAPPY".to_string()), + compression_level: None, + encoding_type: None, + ..Default::default() + }, + ); let config = NativeSettings { compression_type: Some("ZSTD".to_string()), field_configs: Some(field_configs), diff --git a/sandbox/plugins/parquet-data-format/src/main/rust/src/rate_limited_writer.rs b/sandbox/plugins/parquet-data-format/src/main/rust/src/rate_limited_writer.rs index 32826276b0fd7..e0afd7d2759af 100644 --- a/sandbox/plugins/parquet-data-format/src/main/rust/src/rate_limited_writer.rs +++ b/sandbox/plugins/parquet-data-format/src/main/rust/src/rate_limited_writer.rs @@ -209,5 +209,3 @@ impl Write for RateLimitedWriter { self.inner.flush() } } - - diff --git a/sandbox/plugins/parquet-data-format/src/main/rust/src/test_utils.rs b/sandbox/plugins/parquet-data-format/src/main/rust/src/test_utils.rs index 032fd9647ac2c..28efa40fbb249 100644 --- a/sandbox/plugins/parquet-data-format/src/main/rust/src/test_utils.rs +++ b/sandbox/plugins/parquet-data-format/src/main/rust/src/test_utils.rs @@ -6,12 +6,12 @@ * compatible open source license. */ +use arrow::array::Array; use arrow::array::{Int32Array, StringArray, StructArray}; use arrow::compute::concat_batches; use arrow::datatypes::{DataType, Field, Schema}; use arrow::ffi::{FFI_ArrowArray, FFI_ArrowSchema}; use arrow::record_batch::RecordBatch; -use arrow::array::Array; use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder; use std::fs::File; use std::sync::Arc; @@ -39,7 +39,10 @@ pub fn create_test_ffi_data() -> Result<(i64, i64), Box> create_test_ffi_data_with_ids(vec![1, 2, 3], vec![Some("Alice"), Some("Bob"), None]) } -pub fn create_test_ffi_data_with_ids(ids: Vec, names: Vec>) -> Result<(i64, i64), Box> { +pub fn create_test_ffi_data_with_ids( + ids: Vec, + names: Vec>, +) -> Result<(i64, i64), Box> { let schema = Arc::new(Schema::new(vec![ Field::new("id", DataType::Int32, false), Field::new("name", DataType::Utf8, true), @@ -72,15 +75,33 @@ pub fn get_temp_file_path(name: &str) -> (tempfile::TempDir, String) { pub fn create_writer_and_assert_success(filename: &str) -> (Arc, i64) { let (schema, schema_ptr) = create_test_ffi_schema(); - let result = NativeParquetWriter::create_writer(filename.to_string(), "test-index".to_string(), schema_ptr, vec![], vec![], vec![], 0); + let result = NativeParquetWriter::create_writer( + filename.to_string(), + "test-index".to_string(), + schema_ptr, + vec![], + vec![], + vec![], + 0, + ); assert!(result.is_ok()); (schema, schema_ptr) } -pub fn create_sorted_writer_and_assert_success(filename: &str, sort_column: &str, reverse: bool) -> (Arc, i64) { +pub fn create_sorted_writer_and_assert_success( + filename: &str, + sort_column: &str, + reverse: bool, +) -> (Arc, i64) { let (schema, schema_ptr) = create_test_ffi_schema(); let result = NativeParquetWriter::create_writer( - filename.to_string(), "test-index".to_string(), schema_ptr, vec![sort_column.to_string()], vec![reverse], vec![false], 0 + filename.to_string(), + "test-index".to_string(), + schema_ptr, + vec![sort_column.to_string()], + vec![reverse], + vec![false], + 0, ); assert!(result.is_ok()); (schema, schema_ptr) @@ -116,7 +137,10 @@ pub fn create_mismatched_ffi_data() -> Result<(i64, i64), Box crate::writer::FinalizeResult { +pub fn close_writer_and_get_metadata( + filename: &str, + schema_ptr: i64, +) -> crate::writer::FinalizeResult { let result = NativeParquetWriter::finalize_writer(filename.to_string()); cleanup_ffi_schema(schema_ptr); result.unwrap().unwrap() @@ -132,7 +156,10 @@ pub fn read_parquet_file(filename: &str) -> Vec { pub fn read_parquet_file_sorted_ids(filename: &str) -> Vec { let batches = read_parquet_file(filename); let combined = concat_batches(&batches[0].schema(), &batches).unwrap(); - let id_col = combined.column(0) - .as_any().downcast_ref::().unwrap(); + let id_col = combined + .column(0) + .as_any() + .downcast_ref::() + .unwrap(); (0..id_col.len()).map(|i| id_col.value(i)).collect() } diff --git a/sandbox/plugins/parquet-data-format/src/main/rust/src/tests/mod.rs b/sandbox/plugins/parquet-data-format/src/main/rust/src/tests/mod.rs index 12137031d5591..356116528ab31 100644 --- a/sandbox/plugins/parquet-data-format/src/main/rust/src/tests/mod.rs +++ b/sandbox/plugins/parquet-data-format/src/main/rust/src/tests/mod.rs @@ -12,10 +12,10 @@ use std::sync::Arc; use std::thread; use tempfile::tempdir; +use crate::native_settings::NativeSettings; use crate::test_utils::*; use crate::writer::NativeParquetWriter; use crate::writer::SETTINGS_STORE; -use crate::native_settings::NativeSettings; use std::fs::File; use std::io::Read; @@ -32,7 +32,15 @@ fn test_create_writer_success() { fn test_create_writer_invalid_path() { let invalid_path = "/invalid/path/that/does/not/exist/test.parquet"; let (_schema, schema_ptr) = create_test_ffi_schema(); - let result = NativeParquetWriter::create_writer(invalid_path.to_string(), "test-index".to_string(), schema_ptr, vec![], vec![], vec![], 0); + let result = NativeParquetWriter::create_writer( + invalid_path.to_string(), + "test-index".to_string(), + schema_ptr, + vec![], + vec![], + vec![], + 0, + ); assert!(result.is_err()); cleanup_ffi_schema(schema_ptr); } @@ -40,9 +48,20 @@ fn test_create_writer_invalid_path() { #[test] fn test_create_writer_invalid_schema_pointer() { let (_temp_dir, filename) = get_temp_file_path("invalid_schema.parquet"); - let result = NativeParquetWriter::create_writer(filename, "test-index".to_string(), 0, vec![], vec![], vec![], 0); + let result = NativeParquetWriter::create_writer( + filename, + "test-index".to_string(), + 0, + vec![], + vec![], + vec![], + 0, + ); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("Invalid schema address")); + assert!(result + .unwrap_err() + .to_string() + .contains("Invalid schema address")); } #[test] @@ -50,9 +69,20 @@ fn test_create_writer_multiple_times_same_file() { let (_temp_dir, filename) = get_temp_file_path("duplicate.parquet"); let (_schema, schema_ptr) = create_writer_and_assert_success(&filename); let (_, schema_ptr2) = create_test_ffi_schema(); - let result2 = NativeParquetWriter::create_writer(filename.clone(), "test-index".to_string(), schema_ptr2, vec![], vec![], vec![], 0); + let result2 = NativeParquetWriter::create_writer( + filename.clone(), + "test-index".to_string(), + schema_ptr2, + vec![], + vec![], + vec![], + 0, + ); assert!(result2.is_err()); - assert!(result2.unwrap_err().to_string().contains("Writer already exists")); + assert!(result2 + .unwrap_err() + .to_string() + .contains("Writer already exists")); cleanup_ffi_schema(schema_ptr2); close_writer_and_cleanup_schema(&filename, schema_ptr); } @@ -71,7 +101,8 @@ fn test_write_data_success() { #[test] fn test_write_data_no_writer() { let (array_ptr, schema_ptr) = create_test_ffi_data().unwrap(); - let result = NativeParquetWriter::write_data("nonexistent.parquet".to_string(), array_ptr, schema_ptr); + let result = + NativeParquetWriter::write_data("nonexistent.parquet".to_string(), array_ptr, schema_ptr); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("Writer not found")); cleanup_ffi_data(array_ptr, schema_ptr); @@ -94,10 +125,16 @@ fn test_write_data_invalid_pointers() { let (_schema, schema_ptr) = create_writer_and_assert_success(&filename); let result = NativeParquetWriter::write_data(filename.clone(), 0, 0); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("Invalid FFI addresses")); + assert!(result + .unwrap_err() + .to_string() + .contains("Invalid FFI addresses")); let result = NativeParquetWriter::write_data(filename.clone(), 0, schema_ptr); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("Invalid FFI addresses")); + assert!(result + .unwrap_err() + .to_string() + .contains("Invalid FFI addresses")); close_writer_and_cleanup_schema(&filename, schema_ptr); } @@ -138,7 +175,10 @@ fn test_finalize_writer_with_data_returns_correct_metadata() { let metadata = result.unwrap().unwrap(); assert_eq!(metadata.metadata.file_metadata().num_rows(), 6); assert!(metadata.metadata.file_metadata().version() > 0); - assert_ne!(metadata.crc32, 0, "CRC32 should be non-zero for a file with data"); + assert_ne!( + metadata.crc32, 0, + "CRC32 should be non-zero for a file with data" + ); cleanup_ffi_schema(schema_ptr); } @@ -159,7 +199,10 @@ fn test_close_multiple_times_same_file() { assert!(result1.is_ok()); let result2 = NativeParquetWriter::finalize_writer(filename); assert!(result2.is_err()); - assert!(result2.unwrap_err().to_string().contains("Writer not found")); + assert!(result2 + .unwrap_err() + .to_string() + .contains("Writer not found")); cleanup_ffi_schema(schema_ptr); } @@ -189,22 +232,25 @@ fn test_sorted_writer_ascending() { let (_temp_dir, filename) = get_temp_file_path("sorted_asc.parquet"); let (_schema, schema_ptr) = create_sorted_writer_and_assert_success(&filename, "id", false); - let (ap1, sp1) = create_test_ffi_data_with_ids( - vec![30, 10, 50], vec![Some("C"), Some("A"), Some("E")] - ).unwrap(); + let (ap1, sp1) = + create_test_ffi_data_with_ids(vec![30, 10, 50], vec![Some("C"), Some("A"), Some("E")]) + .unwrap(); NativeParquetWriter::write_data(filename.clone(), ap1, sp1).unwrap(); cleanup_ffi_data(ap1, sp1); - let (ap2, sp2) = create_test_ffi_data_with_ids( - vec![20, 40], vec![Some("B"), Some("D")] - ).unwrap(); + let (ap2, sp2) = + create_test_ffi_data_with_ids(vec![20, 40], vec![Some("B"), Some("D")]).unwrap(); NativeParquetWriter::write_data(filename.clone(), ap2, sp2).unwrap(); cleanup_ffi_data(ap2, sp2); NativeParquetWriter::finalize_writer(filename.clone()).unwrap(); let ids = read_parquet_file_sorted_ids(&filename); - assert_eq!(ids, vec![10, 20, 30, 40, 50], "Data should be sorted ascending by id"); + assert_eq!( + ids, + vec![10, 20, 30, 40, 50], + "Data should be sorted ascending by id" + ); cleanup_ffi_schema(schema_ptr); } @@ -214,22 +260,25 @@ fn test_sorted_writer_descending() { let (_temp_dir, filename) = get_temp_file_path("sorted_desc.parquet"); let (_schema, schema_ptr) = create_sorted_writer_and_assert_success(&filename, "id", true); - let (ap1, sp1) = create_test_ffi_data_with_ids( - vec![30, 10, 50], vec![Some("C"), Some("A"), Some("E")] - ).unwrap(); + let (ap1, sp1) = + create_test_ffi_data_with_ids(vec![30, 10, 50], vec![Some("C"), Some("A"), Some("E")]) + .unwrap(); NativeParquetWriter::write_data(filename.clone(), ap1, sp1).unwrap(); cleanup_ffi_data(ap1, sp1); - let (ap2, sp2) = create_test_ffi_data_with_ids( - vec![20, 40], vec![Some("B"), Some("D")] - ).unwrap(); + let (ap2, sp2) = + create_test_ffi_data_with_ids(vec![20, 40], vec![Some("B"), Some("D")]).unwrap(); NativeParquetWriter::write_data(filename.clone(), ap2, sp2).unwrap(); cleanup_ffi_data(ap2, sp2); NativeParquetWriter::finalize_writer(filename.clone()).unwrap(); let ids = read_parquet_file_sorted_ids(&filename); - assert_eq!(ids, vec![50, 40, 30, 20, 10], "Data should be sorted descending by id"); + assert_eq!( + ids, + vec![50, 40, 30, 20, 10], + "Data should be sorted descending by id" + ); cleanup_ffi_schema(schema_ptr); } @@ -239,22 +288,25 @@ fn test_unsorted_writer_preserves_insertion_order() { let (_temp_dir, filename) = get_temp_file_path("unsorted.parquet"); let (_schema, schema_ptr) = create_writer_and_assert_success(&filename); - let (ap1, sp1) = create_test_ffi_data_with_ids( - vec![30, 10, 50], vec![Some("C"), Some("A"), Some("E")] - ).unwrap(); + let (ap1, sp1) = + create_test_ffi_data_with_ids(vec![30, 10, 50], vec![Some("C"), Some("A"), Some("E")]) + .unwrap(); NativeParquetWriter::write_data(filename.clone(), ap1, sp1).unwrap(); cleanup_ffi_data(ap1, sp1); - let (ap2, sp2) = create_test_ffi_data_with_ids( - vec![20, 40], vec![Some("B"), Some("D")] - ).unwrap(); + let (ap2, sp2) = + create_test_ffi_data_with_ids(vec![20, 40], vec![Some("B"), Some("D")]).unwrap(); NativeParquetWriter::write_data(filename.clone(), ap2, sp2).unwrap(); cleanup_ffi_data(ap2, sp2); NativeParquetWriter::finalize_writer(filename.clone()).unwrap(); let ids = read_parquet_file_sorted_ids(&filename); - assert_eq!(ids, vec![30, 10, 50, 20, 40], "Data should preserve insertion order"); + assert_eq!( + ids, + vec![30, 10, 50, 20, 40], + "Data should preserve insertion order" + ); cleanup_ffi_schema(schema_ptr); } @@ -268,16 +320,24 @@ fn test_ipc_staging_sorted_writer_creates_and_cleans_up_staging_file() { // With eager sort-and-write, no staging file exists until a chunk is flushed. // The writer accumulates in memory. Verify the writer is open. - assert!(NativeParquetWriter::has_writer(&filename), "Writer should be open"); + assert!( + NativeParquetWriter::has_writer(&filename), + "Writer should be open" + ); - let (ap, sp) = create_test_ffi_data_with_ids(vec![30, 10, 20], vec![Some("C"), Some("A"), Some("B")]).unwrap(); + let (ap, sp) = + create_test_ffi_data_with_ids(vec![30, 10, 20], vec![Some("C"), Some("A"), Some("B")]) + .unwrap(); NativeParquetWriter::write_data(filename.clone(), ap, sp).unwrap(); cleanup_ffi_data(ap, sp); NativeParquetWriter::finalize_writer(filename.clone()).unwrap(); // The final Parquet file should exist - assert!(Path::new(&filename).exists(), "Final Parquet file should exist"); + assert!( + Path::new(&filename).exists(), + "Final Parquet file should exist" + ); // Verify data is sorted let ids = read_parquet_file_sorted_ids(&filename); @@ -291,7 +351,10 @@ fn test_ipc_staging_has_writer_returns_true() { let (_temp_dir, filename) = get_temp_file_path("ipc_has_writer.parquet"); let (_schema, schema_ptr) = create_sorted_writer_and_assert_success(&filename, "id", false); - assert!(NativeParquetWriter::has_writer(&filename), "has_writer should return true for IPC writer"); + assert!( + NativeParquetWriter::has_writer(&filename), + "has_writer should return true for IPC writer" + ); close_writer_and_cleanup_schema(&filename, schema_ptr); } @@ -303,11 +366,19 @@ fn test_ipc_staging_duplicate_writer_rejected() { let (_, schema_ptr2) = create_test_ffi_schema(); let result = NativeParquetWriter::create_writer( - filename.clone(), "test-index".to_string(), schema_ptr2, - vec!["id".to_string()], vec![false], vec![false], 0 + filename.clone(), + "test-index".to_string(), + schema_ptr2, + vec!["id".to_string()], + vec![false], + vec![false], + 0, ); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("Writer already exists")); + assert!(result + .unwrap_err() + .to_string() + .contains("Writer already exists")); cleanup_ffi_schema(schema_ptr2); close_writer_and_cleanup_schema(&filename, schema_ptr); @@ -321,7 +392,10 @@ fn test_ipc_staging_empty_data_produces_valid_parquet() { // Finalize without writing any data let result = NativeParquetWriter::finalize_writer(filename.clone()); assert!(result.is_ok()); - assert!(Path::new(&filename).exists(), "Empty Parquet file should be created"); + assert!( + Path::new(&filename).exists(), + "Empty Parquet file should be created" + ); let metadata = result.unwrap().unwrap(); assert_eq!(metadata.metadata.file_metadata().num_rows(), 0); @@ -335,22 +409,29 @@ fn test_ipc_staging_multi_batch_sort() { let (_schema, schema_ptr) = create_sorted_writer_and_assert_success(&filename, "id", false); // Write multiple batches with interleaved values - let (ap1, sp1) = create_test_ffi_data_with_ids(vec![50, 10], vec![Some("E"), Some("A")]).unwrap(); + let (ap1, sp1) = + create_test_ffi_data_with_ids(vec![50, 10], vec![Some("E"), Some("A")]).unwrap(); NativeParquetWriter::write_data(filename.clone(), ap1, sp1).unwrap(); cleanup_ffi_data(ap1, sp1); - let (ap2, sp2) = create_test_ffi_data_with_ids(vec![30, 20], vec![Some("C"), Some("B")]).unwrap(); + let (ap2, sp2) = + create_test_ffi_data_with_ids(vec![30, 20], vec![Some("C"), Some("B")]).unwrap(); NativeParquetWriter::write_data(filename.clone(), ap2, sp2).unwrap(); cleanup_ffi_data(ap2, sp2); - let (ap3, sp3) = create_test_ffi_data_with_ids(vec![40, 60], vec![Some("D"), Some("F")]).unwrap(); + let (ap3, sp3) = + create_test_ffi_data_with_ids(vec![40, 60], vec![Some("D"), Some("F")]).unwrap(); NativeParquetWriter::write_data(filename.clone(), ap3, sp3).unwrap(); cleanup_ffi_data(ap3, sp3); NativeParquetWriter::finalize_writer(filename.clone()).unwrap(); let ids = read_parquet_file_sorted_ids(&filename); - assert_eq!(ids, vec![10, 20, 30, 40, 50, 60], "Multiple IPC batches should be sorted correctly"); + assert_eq!( + ids, + vec![10, 20, 30, 40, 50, 60], + "Multiple IPC batches should be sorted correctly" + ); cleanup_ffi_schema(schema_ptr); } @@ -360,14 +441,20 @@ fn test_ipc_staging_descending_sort() { let (_temp_dir, filename) = get_temp_file_path("ipc_desc.parquet"); let (_schema, schema_ptr) = create_sorted_writer_and_assert_success(&filename, "id", true); - let (ap, sp) = create_test_ffi_data_with_ids(vec![10, 30, 20], vec![Some("A"), Some("C"), Some("B")]).unwrap(); + let (ap, sp) = + create_test_ffi_data_with_ids(vec![10, 30, 20], vec![Some("A"), Some("C"), Some("B")]) + .unwrap(); NativeParquetWriter::write_data(filename.clone(), ap, sp).unwrap(); cleanup_ffi_data(ap, sp); NativeParquetWriter::finalize_writer(filename.clone()).unwrap(); let ids = read_parquet_file_sorted_ids(&filename); - assert_eq!(ids, vec![30, 20, 10], "IPC path should support descending sort"); + assert_eq!( + ids, + vec![30, 20, 10], + "IPC path should support descending sort" + ); cleanup_ffi_schema(schema_ptr); } @@ -382,11 +469,15 @@ fn test_ipc_and_parquet_writers_coexist() { let (_schema2, sp2) = create_writer_and_assert_success(&unsorted_file); // Write to both - let (ap1, dp1) = create_test_ffi_data_with_ids(vec![30, 10, 20], vec![Some("C"), Some("A"), Some("B")]).unwrap(); + let (ap1, dp1) = + create_test_ffi_data_with_ids(vec![30, 10, 20], vec![Some("C"), Some("A"), Some("B")]) + .unwrap(); NativeParquetWriter::write_data(sorted_file.clone(), ap1, dp1).unwrap(); cleanup_ffi_data(ap1, dp1); - let (ap2, dp2) = create_test_ffi_data_with_ids(vec![30, 10, 20], vec![Some("C"), Some("A"), Some("B")]).unwrap(); + let (ap2, dp2) = + create_test_ffi_data_with_ids(vec![30, 10, 20], vec![Some("C"), Some("A"), Some("B")]) + .unwrap(); NativeParquetWriter::write_data(unsorted_file.clone(), ap2, dp2).unwrap(); cleanup_ffi_data(ap2, dp2); @@ -422,17 +513,28 @@ fn test_ipc_staging_concurrent_sorted_writers() { let (_schema, schema_ptr) = create_test_ffi_schema(); if NativeParquetWriter::create_writer( - filename.clone(), "test-index".to_string(), schema_ptr, - vec!["id".to_string()], vec![false], vec![false], 0 - ).is_ok() { + filename.clone(), + "test-index".to_string(), + schema_ptr, + vec!["id".to_string()], + vec![false], + vec![false], + 0, + ) + .is_ok() + { let (ap, sp) = create_test_ffi_data_with_ids( - vec![30, 10, 20], vec![Some("C"), Some("A"), Some("B")] - ).unwrap(); + vec![30, 10, 20], + vec![Some("C"), Some("A"), Some("B")], + ) + .unwrap(); let write_ok = NativeParquetWriter::write_data(filename.clone(), ap, sp).is_ok(); cleanup_ffi_data(ap, sp); if write_ok { - if let Ok(Some(metadata)) = NativeParquetWriter::finalize_writer(filename.clone()) { + if let Ok(Some(metadata)) = + NativeParquetWriter::finalize_writer(filename.clone()) + { if metadata.metadata.file_metadata().num_rows() == 3 { let ids = read_parquet_file_sorted_ids(&filename); if ids == vec![10, 20, 30] { @@ -505,7 +607,9 @@ fn compute_file_crc32(path: &str) -> u32 { let mut buf = [0u8; 64 * 1024]; loop { let n = file.read(&mut buf).unwrap(); - if n == 0 { break; } + if n == 0 { + break; + } hasher.update(&buf[..n]); } hasher.finalize() @@ -573,7 +677,17 @@ fn test_concurrent_writer_creation() { let filename = file_path.to_string_lossy().to_string(); let (_schema, schema_ptr) = create_test_ffi_schema(); - if NativeParquetWriter::create_writer(filename.clone(), "test-index".to_string(), schema_ptr, vec![], vec![], vec![], 0).is_ok() { + if NativeParquetWriter::create_writer( + filename.clone(), + "test-index".to_string(), + schema_ptr, + vec![], + vec![], + vec![], + 0, + ) + .is_ok() + { success_count.fetch_add(1, Ordering::SeqCst); let (ap, sp) = create_test_ffi_data().unwrap(); let _ = NativeParquetWriter::write_data(filename.clone(), ap, sp); @@ -663,7 +777,9 @@ fn test_concurrent_writes_different_files() { let mut schema_ptrs = vec![]; for i in 0..file_count { - let file_path = temp_dir.path().join(format!("concurrent_write_{}.parquet", i)); + let file_path = temp_dir + .path() + .join(format!("concurrent_write_{}.parquet", i)); let filename = file_path.to_string_lossy().to_string(); let (_schema, schema_ptr) = create_writer_and_assert_success(&filename); filenames.push(filename); @@ -677,7 +793,9 @@ fn test_concurrent_writes_different_files() { let handle = thread::spawn(move || { for _ in 0..2 { let (array_ptr, data_schema_ptr) = create_test_ffi_data().unwrap(); - if NativeParquetWriter::write_data(filename.clone(), array_ptr, data_schema_ptr).is_ok() { + if NativeParquetWriter::write_data(filename.clone(), array_ptr, data_schema_ptr) + .is_ok() + { success_count.fetch_add(1, Ordering::SeqCst); } cleanup_ffi_data(array_ptr, data_schema_ptr); @@ -715,13 +833,20 @@ fn test_bloom_filter_false_propagates_through_settings_store() { let (_temp_dir, filename) = get_temp_file_path("bloom_test.parquet"); let (_schema, schema_ptr) = create_test_ffi_schema(); let result = NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, vec![], vec![], vec![], 0 + filename.clone(), + index_name.to_string(), + schema_ptr, + vec![], + vec![], + vec![], + 0, ); assert!(result.is_ok()); let stored_after = SETTINGS_STORE.get(index_name).unwrap(); assert_eq!( - stored_after.bloom_filter_enabled, Some(false), + stored_after.bloom_filter_enabled, + Some(false), "bloom_filter_enabled should remain false after create_writer, but got: {:?}", stored_after.bloom_filter_enabled ); @@ -739,13 +864,20 @@ fn test_bloom_filter_default_when_no_settings() { let (_temp_dir, filename) = get_temp_file_path("bloom_default.parquet"); let (_schema, schema_ptr) = create_test_ffi_schema(); let result = NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, vec![], vec![], vec![], 0 + filename.clone(), + index_name.to_string(), + schema_ptr, + vec![], + vec![], + vec![], + 0, ); assert!(result.is_ok()); let stored = SETTINGS_STORE.get(index_name).unwrap(); assert_eq!( - stored.get_bloom_filter_enabled(), false, + stored.get_bloom_filter_enabled(), + false, "Default bloom_filter_enabled should be false when no settings exist" ); drop(stored); @@ -777,25 +909,29 @@ fn run_sort_permutation_test(_use_large_file: bool, _label: &str) { #[allow(dead_code)] /// Helper: creates an IPC file with (age: Int64, __row_id__: Int64) columns. fn create_test_ipc_file(dir: &Path, ages: &[i64], row_ids: &[i64]) -> String { + use crate::merge::schema::ROW_ID_COLUMN_NAME; use arrow::array::Int64Array; use arrow::datatypes::{DataType, Field, Schema}; use arrow::record_batch::RecordBatch; use arrow_ipc::writer::FileWriter as IpcFileWriter; - use crate::merge::schema::ROW_ID_COLUMN_NAME; let schema = Arc::new(Schema::new(vec![ Field::new("age", DataType::Int64, false), Field::new(ROW_ID_COLUMN_NAME, DataType::Int64, false), ])); - let ipc_path = dir.join("staging.arrow_ipc_staging").to_string_lossy().to_string(); + let ipc_path = dir + .join("staging.arrow_ipc_staging") + .to_string_lossy() + .to_string(); let batch = RecordBatch::try_new( schema.clone(), vec![ Arc::new(Int64Array::from(ages.to_vec())), Arc::new(Int64Array::from(row_ids.to_vec())), ], - ).unwrap(); + ) + .unwrap(); let file = File::create(&ipc_path).unwrap(); let mut ipc_writer = IpcFileWriter::try_new(file, &schema).unwrap(); @@ -814,8 +950,8 @@ fn create_test_ipc_file(dir: &Path, ages: &[i64], row_ids: &[i64]) -> String { /// Helper: creates FFI schema pointer for a schema with (age: Int32, name: Utf8, __row_id__: Int64). fn create_row_id_schema_ptr() -> (Arc, i64) { - use arrow::datatypes::{DataType, Field, Schema}; use crate::merge::schema::ROW_ID_COLUMN_NAME; + use arrow::datatypes::{DataType, Field, Schema}; let schema = Arc::new(Schema::new(vec![ Field::new("age", DataType::Int32, false), @@ -834,10 +970,10 @@ fn create_ffi_data_with_row_id( names: Vec>, row_ids: Vec, ) -> (i64, i64) { + use crate::merge::schema::ROW_ID_COLUMN_NAME; use arrow::array::{Array, Int32Array, Int64Array, StringArray}; use arrow::datatypes::{DataType, Field, Schema}; use arrow::record_batch::RecordBatch; - use crate::merge::schema::ROW_ID_COLUMN_NAME; let schema = Arc::new(Schema::new(vec![ Field::new("age", DataType::Int32, false), @@ -847,10 +983,8 @@ fn create_ffi_data_with_row_id( let age_array: Arc = Arc::new(Int32Array::from(ages)); let name_array: Arc = Arc::new(StringArray::from(names)); let row_id_array: Arc = Arc::new(Int64Array::from(row_ids)); - let record_batch = RecordBatch::try_new( - schema.clone(), - vec![age_array, name_array, row_id_array], - ).unwrap(); + let record_batch = + RecordBatch::try_new(schema.clone(), vec![age_array, name_array, row_id_array]).unwrap(); let struct_array = arrow::array::StructArray::from(record_batch); let (ffi_array, ffi_schema) = arrow::ffi::to_ffi(&struct_array.to_data()).unwrap(); let array_ptr = Box::into_raw(Box::new(ffi_array)) as i64; @@ -860,18 +994,23 @@ fn create_ffi_data_with_row_id( /// Helper: reads a Parquet file and returns the __row_id__ column values. fn read_row_ids_from_parquet(filename: &str) -> Vec { + use crate::merge::schema::ROW_ID_COLUMN_NAME; use arrow::array::Int64Array; use arrow::compute::concat_batches; - use crate::merge::schema::ROW_ID_COLUMN_NAME; let batches = read_parquet_file(filename); if batches.is_empty() { return vec![]; } let combined = concat_batches(&batches[0].schema(), &batches).unwrap(); - let idx = combined.schema().index_of(ROW_ID_COLUMN_NAME) + let idx = combined + .schema() + .index_of(ROW_ID_COLUMN_NAME) .expect("__row_id__ column should exist"); - let col = combined.column(idx).as_any().downcast_ref::() + let col = combined + .column(idx) + .as_any() + .downcast_ref::() .expect("__row_id__ should be Int64"); (0..col.len()).map(|i| col.value(i)).collect() } @@ -886,8 +1025,14 @@ fn read_ages_from_parquet(filename: &str) -> Vec { return vec![]; } let combined = concat_batches(&batches[0].schema(), &batches).unwrap(); - let idx = combined.schema().index_of("age").expect("age column should exist"); - let col = combined.column(idx).as_any().downcast_ref::() + let idx = combined + .schema() + .index_of("age") + .expect("age column should exist"); + let col = combined + .column(idx) + .as_any() + .downcast_ref::() .expect("age should be Int32"); (0..col.len()).map(|i| col.value(i)).collect() } @@ -910,8 +1055,13 @@ fn test_chunked_writer_single_chunk_row_ids_sequential() { // Create writer with sort on "age" ascending let (_schema, schema_ptr) = create_row_id_schema_ptr(); let result = NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], 0, + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + 0, ); assert!(result.is_ok(), "create_writer failed: {:?}", result.err()); @@ -922,26 +1072,40 @@ fn test_chunked_writer_single_chunk_row_ids_sequential() { ); NativeParquetWriter::write_data(filename.clone(), ap, sp).unwrap(); - let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()).unwrap().unwrap(); + let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()) + .unwrap() + .unwrap(); // Verify output is sorted by age ascending let ages = read_ages_from_parquet(&filename); - assert_eq!(ages, vec![10, 20, 30, 40, 50], "Output should be sorted by age ASC"); + assert_eq!( + ages, + vec![10, 20, 30, 40, 50], + "Output should be sorted by age ASC" + ); // Verify __row_id__ is sequential 0..N let row_ids = read_row_ids_from_parquet(&filename); let expected_sequential: Vec = (0..5).collect(); - assert_eq!(row_ids, expected_sequential, "__row_id__ should be sequential 0..4"); + assert_eq!( + row_ids, expected_sequential, + "__row_id__ should be sequential 0..4" + ); // Verify permutation mapping // Original: age=[30,10,50,20,40], row_ids=[0,1,2,3,4] // Sorted: age=[10,20,30,40,50] → original row_ids were [1,3,0,4,2] // mapping[original_row_id] = new_position // mapping[0]=2, mapping[1]=0, mapping[2]=4, mapping[3]=1, mapping[4]=3 - let mapping = finalize_result.row_id_mapping.expect("Should have row_id_mapping"); + let mapping = finalize_result + .row_id_mapping + .expect("Should have row_id_mapping"); assert_eq!(mapping.len(), 5); - assert_eq!(mapping, vec![2, 0, 4, 1, 3], - "Permutation mapping should map original row IDs to new sorted positions"); + assert_eq!( + mapping, + vec![2, 0, 4, 1, 3], + "Permutation mapping should map original row IDs to new sorted positions" + ); SETTINGS_STORE.remove(index_name); } @@ -963,34 +1127,58 @@ fn test_chunked_writer_multi_chunk_row_ids_sequential() { let (_schema, schema_ptr) = create_row_id_schema_ptr(); let result = NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], 0, + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + 0, ); assert!(result.is_ok(), "create_writer failed: {:?}", result.err()); let (ap, sp) = create_ffi_data_with_row_id( vec![50, 30, 10, 40, 20, 60, 5, 45, 35, 15], - vec![Some("E"), Some("C"), Some("A"), Some("D"), Some("B"), - Some("F"), Some("G"), Some("H"), Some("I"), Some("J")], + vec![ + Some("E"), + Some("C"), + Some("A"), + Some("D"), + Some("B"), + Some("F"), + Some("G"), + Some("H"), + Some("I"), + Some("J"), + ], vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9], ); NativeParquetWriter::write_data(filename.clone(), ap, sp).unwrap(); - let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()).unwrap().unwrap(); + let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()) + .unwrap() + .unwrap(); // Verify output is sorted by age ascending let ages = read_ages_from_parquet(&filename); - assert_eq!(ages, vec![5, 10, 15, 20, 30, 35, 40, 45, 50, 60], - "Output should be sorted by age ASC"); + assert_eq!( + ages, + vec![5, 10, 15, 20, 30, 35, 40, 45, 50, 60], + "Output should be sorted by age ASC" + ); // Verify __row_id__ is sequential 0..N let row_ids = read_row_ids_from_parquet(&filename); let expected_sequential: Vec = (0..10).collect(); - assert_eq!(row_ids, expected_sequential, - "__row_id__ should be sequential 0..9 in merged output"); + assert_eq!( + row_ids, expected_sequential, + "__row_id__ should be sequential 0..9 in merged output" + ); // Verify permutation mapping exists and has correct length - let mapping = finalize_result.row_id_mapping.expect("Should have row_id_mapping"); + let mapping = finalize_result + .row_id_mapping + .expect("Should have row_id_mapping"); assert_eq!(mapping.len(), 10); // Verify permutation correctness: @@ -1003,8 +1191,10 @@ fn test_chunked_writer_multi_chunk_row_ids_sequential() { // mapping[6]=0 (age=5→pos0), mapping[7]=7 (age=45→pos7), mapping[8]=5 (age=35→pos5) // mapping[9]=2 (age=15→pos2) let expected_mapping: Vec = vec![8, 4, 1, 6, 3, 9, 0, 7, 5, 2]; - assert_eq!(mapping, expected_mapping, - "Permutation mapping should correctly map original row IDs to sorted positions"); + assert_eq!( + mapping, expected_mapping, + "Permutation mapping should correctly map original row IDs to sorted positions" + ); SETTINGS_STORE.remove(index_name); } @@ -1024,9 +1214,15 @@ fn test_chunked_writer_multi_chunk_descending_sort() { let (_schema, schema_ptr) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![true], vec![false], 0, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![true], + vec![false], + 0, + ) + .unwrap(); let (ap, sp) = create_ffi_data_with_row_id( vec![10, 50, 30, 20, 40], @@ -1035,22 +1231,33 @@ fn test_chunked_writer_multi_chunk_descending_sort() { ); NativeParquetWriter::write_data(filename.clone(), ap, sp).unwrap(); - let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()).unwrap().unwrap(); + let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()) + .unwrap() + .unwrap(); // Verify output is sorted by age descending let ages = read_ages_from_parquet(&filename); - assert_eq!(ages, vec![50, 40, 30, 20, 10], "Output should be sorted by age DESC"); + assert_eq!( + ages, + vec![50, 40, 30, 20, 10], + "Output should be sorted by age DESC" + ); // Verify __row_id__ is sequential 0..N let row_ids = read_row_ids_from_parquet(&filename); let expected_sequential: Vec = (0..5).collect(); - assert_eq!(row_ids, expected_sequential, "__row_id__ should be sequential 0..4"); + assert_eq!( + row_ids, expected_sequential, + "__row_id__ should be sequential 0..4" + ); // Verify permutation: // Original: age=[10,50,30,20,40], row_ids=[0,1,2,3,4] // Sorted DESC: age=[50,40,30,20,10] → original row_ids [1,4,2,3,0] // mapping[0]=4, mapping[1]=0, mapping[2]=2, mapping[3]=3, mapping[4]=1 - let mapping = finalize_result.row_id_mapping.expect("Should have row_id_mapping"); + let mapping = finalize_result + .row_id_mapping + .expect("Should have row_id_mapping"); assert_eq!(mapping, vec![4, 0, 2, 3, 1]); SETTINGS_STORE.remove(index_name); @@ -1073,9 +1280,15 @@ fn test_chunked_writer_multiple_write_calls() { // First batch: row_ids 0..4 let (_schema, schema_ptr) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], 0, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + 0, + ) + .unwrap(); let (ap1, sp1) = create_ffi_data_with_row_id( vec![50, 30, 10, 40, 20], @@ -1092,21 +1305,30 @@ fn test_chunked_writer_multiple_write_calls() { ); NativeParquetWriter::write_data(filename.clone(), ap2, sp2).unwrap(); - let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()).unwrap().unwrap(); + let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()) + .unwrap() + .unwrap(); // Verify sorted output let ages = read_ages_from_parquet(&filename); - assert_eq!(ages, vec![10, 15, 20, 25, 30, 35, 40, 45, 50, 55], - "Output should be globally sorted by age ASC"); + assert_eq!( + ages, + vec![10, 15, 20, 25, 30, 35, 40, 45, 50, 55], + "Output should be globally sorted by age ASC" + ); // Verify sequential row IDs let row_ids = read_row_ids_from_parquet(&filename); let expected_sequential: Vec = (0..10).collect(); - assert_eq!(row_ids, expected_sequential, - "__row_id__ should be sequential 0..9"); + assert_eq!( + row_ids, expected_sequential, + "__row_id__ should be sequential 0..9" + ); // Verify permutation mapping - let mapping = finalize_result.row_id_mapping.expect("Should have row_id_mapping"); + let mapping = finalize_result + .row_id_mapping + .expect("Should have row_id_mapping"); assert_eq!(mapping.len(), 10); // Original: age=[50,30,10,40,20,25,55,15,35,45], row_ids=[0..9] @@ -1115,8 +1337,10 @@ fn test_chunked_writer_multiple_write_calls() { // mapping[0]=8, mapping[1]=4, mapping[2]=0, mapping[3]=6, mapping[4]=2 // mapping[5]=3, mapping[6]=9, mapping[7]=1, mapping[8]=5, mapping[9]=7 let expected_mapping: Vec = vec![8, 4, 0, 6, 2, 3, 9, 1, 5, 7]; - assert_eq!(mapping, expected_mapping, - "Permutation should correctly map across multiple write calls"); + assert_eq!( + mapping, expected_mapping, + "Permutation should correctly map across multiple write calls" + ); SETTINGS_STORE.remove(index_name); } @@ -1137,16 +1361,29 @@ fn test_chunked_writer_empty_finalize() { // Just create the writer with the schema, don't write data let (_schema, schema_ptr) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], 0, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + 0, + ) + .unwrap(); + + let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()) + .unwrap() + .unwrap(); - let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()).unwrap().unwrap(); - - assert_eq!(finalize_result.metadata.file_metadata().num_rows(), 0, - "Empty writer should produce 0-row Parquet"); - assert!(finalize_result.row_id_mapping.is_none(), - "Empty writer should have no row_id_mapping"); + assert_eq!( + finalize_result.metadata.file_metadata().num_rows(), + 0, + "Empty writer should produce 0-row Parquet" + ); + assert!( + finalize_result.row_id_mapping.is_none(), + "Empty writer should have no row_id_mapping" + ); SETTINGS_STORE.remove(index_name); } @@ -1171,16 +1408,22 @@ fn test_chunked_writer_permutation_is_invertible() { let (_schema, schema_ptr) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], 0, - ).unwrap(); - - let (ap, sp) = create_ffi_data_with_row_id( - original_ages.clone(), names, row_ids, - ); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + 0, + ) + .unwrap(); + + let (ap, sp) = create_ffi_data_with_row_id(original_ages.clone(), names, row_ids); NativeParquetWriter::write_data(filename.clone(), ap, sp).unwrap(); - let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()).unwrap().unwrap(); + let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()) + .unwrap() + .unwrap(); let mapping = finalize_result.row_id_mapping.expect("Should have mapping"); assert_eq!(mapping.len(), 10); @@ -1194,15 +1437,19 @@ fn test_chunked_writer_permutation_is_invertible() { let mut sorted_mapping = mapping.clone(); sorted_mapping.sort(); let expected_perm: Vec = (0..10).collect(); - assert_eq!(sorted_mapping, expected_perm, - "Mapping should be a valid permutation of 0..N"); + assert_eq!( + sorted_mapping, expected_perm, + "Mapping should be a valid permutation of 0..N" + ); // Verify: original_ages[i] should appear at position mapping[i] in sorted output for i in 0..10 { let new_pos = mapping[i] as usize; - assert_eq!(sorted_ages[new_pos], original_ages[i], + assert_eq!( + sorted_ages[new_pos], original_ages[i], "original_ages[{}]={} should be at sorted position {}, but found {}", - i, original_ages[i], new_pos, sorted_ages[new_pos]); + i, original_ages[i], new_pos, sorted_ages[new_pos] + ); } SETTINGS_STORE.remove(index_name); @@ -1228,28 +1475,41 @@ fn test_chunked_writer_large_dataset_multi_chunk() { let (_schema, schema_ptr) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], 0, - ).unwrap(); - - let (ap, sp) = create_ffi_data_with_row_id( - ages.clone(), names, row_ids, - ); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + 0, + ) + .unwrap(); + + let (ap, sp) = create_ffi_data_with_row_id(ages.clone(), names, row_ids); NativeParquetWriter::write_data(filename.clone(), ap, sp).unwrap(); - let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()).unwrap().unwrap(); + let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()) + .unwrap() + .unwrap(); // Verify output is sorted let sorted_ages = read_ages_from_parquet(&filename); let mut expected_sorted = ages.clone(); expected_sorted.sort(); - assert_eq!(sorted_ages, expected_sorted, "Output should be sorted ascending"); + assert_eq!( + sorted_ages, expected_sorted, + "Output should be sorted ascending" + ); // Verify sequential row IDs let row_ids_in_file = read_row_ids_from_parquet(&filename); let expected_sequential: Vec = (0..num_rows as i64).collect(); - assert_eq!(row_ids_in_file, expected_sequential, - "__row_id__ should be sequential 0..{}", num_rows - 1); + assert_eq!( + row_ids_in_file, + expected_sequential, + "__row_id__ should be sequential 0..{}", + num_rows - 1 + ); // Verify permutation let mapping = finalize_result.row_id_mapping.expect("Should have mapping"); @@ -1258,13 +1518,19 @@ fn test_chunked_writer_large_dataset_multi_chunk() { // Verify it's a valid permutation let mut sorted_mapping = mapping.clone(); sorted_mapping.sort(); - assert_eq!(sorted_mapping, expected_sequential, "Mapping should be a valid permutation"); + assert_eq!( + sorted_mapping, expected_sequential, + "Mapping should be a valid permutation" + ); // Verify correctness: original_ages[i] at sorted position mapping[i] for i in 0..num_rows as usize { let new_pos = mapping[i] as usize; - assert_eq!(sorted_ages[new_pos], ages[i], - "ages[{}]={} should be at sorted position {}", i, ages[i], new_pos); + assert_eq!( + sorted_ages[new_pos], ages[i], + "ages[{}]={} should be at sorted position {}", + i, ages[i], new_pos + ); } SETTINGS_STORE.remove(index_name); @@ -1279,13 +1545,12 @@ fn read_writer_generation_from_parquet(filename: &str) -> Option { let file = File::open(filename).unwrap(); let reader = SerializedFileReader::new(file).unwrap(); let metadata = reader.metadata().file_metadata(); - metadata.key_value_metadata() - .and_then(|kvs| { - kvs.iter() - .find(|kv| kv.key == "opensearch.writer_generation") - .and_then(|kv| kv.value.as_ref()) - .and_then(|v| v.parse::().ok()) - }) + metadata.key_value_metadata().and_then(|kvs| { + kvs.iter() + .find(|kv| kv.key == "opensearch.writer_generation") + .and_then(|kv| kv.value.as_ref()) + .and_then(|v| v.parse::().ok()) + }) } /// Test: writer_generation is stored in Parquet metadata for sorted single-chunk output. @@ -1304,9 +1569,15 @@ fn test_chunked_writer_generation_in_metadata_single_chunk() { let writer_generation = 42i64; let (_schema, schema_ptr) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], writer_generation, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + writer_generation, + ) + .unwrap(); let (ap, sp) = create_ffi_data_with_row_id( vec![30, 10, 20], @@ -1317,7 +1588,11 @@ fn test_chunked_writer_generation_in_metadata_single_chunk() { NativeParquetWriter::finalize_writer(filename.clone()).unwrap(); let gen = read_writer_generation_from_parquet(&filename); - assert_eq!(gen, Some(42), "writer_generation=42 should be in Parquet metadata"); + assert_eq!( + gen, + Some(42), + "writer_generation=42 should be in Parquet metadata" + ); SETTINGS_STORE.remove(index_name); } @@ -1341,14 +1616,30 @@ fn test_chunked_writer_generation_in_metadata_multi_chunk() { let writer_generation = 7i64; let (_schema, schema_ptr) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], writer_generation, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + writer_generation, + ) + .unwrap(); let (ap, sp) = create_ffi_data_with_row_id( vec![50, 30, 10, 40, 20, 60, 5, 45, 35, 15], - vec![Some("E"), Some("C"), Some("A"), Some("D"), Some("B"), - Some("F"), Some("G"), Some("H"), Some("I"), Some("J")], + vec![ + Some("E"), + Some("C"), + Some("A"), + Some("D"), + Some("B"), + Some("F"), + Some("G"), + Some("H"), + Some("I"), + Some("J"), + ], vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9], ); NativeParquetWriter::write_data(filename.clone(), ap, sp).unwrap(); @@ -1357,8 +1648,11 @@ fn test_chunked_writer_generation_in_metadata_multi_chunk() { // Previously writer_generation was lost in the k-way merge path. This has been fixed — // merge_sorted now propagates writer_generation into the output file metadata. let gen = read_writer_generation_from_parquet(&filename); - assert_eq!(gen, Some(7), - "writer_generation should be propagated in multi-chunk merge path"); + assert_eq!( + gen, + Some(7), + "writer_generation should be propagated in multi-chunk merge path" + ); // Data correctness is still maintained let ages = read_ages_from_parquet(&filename); @@ -1386,20 +1680,27 @@ fn test_chunked_writer_generation_zero() { let (_schema, schema_ptr) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], 0, - ).unwrap(); - - let (ap, sp) = create_ffi_data_with_row_id( - vec![20, 10], - vec![Some("B"), Some("A")], - vec![0, 1], - ); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + 0, + ) + .unwrap(); + + let (ap, sp) = + create_ffi_data_with_row_id(vec![20, 10], vec![Some("B"), Some("A")], vec![0, 1]); NativeParquetWriter::write_data(filename.clone(), ap, sp).unwrap(); NativeParquetWriter::finalize_writer(filename.clone()).unwrap(); let gen = read_writer_generation_from_parquet(&filename); - assert_eq!(gen, Some(0), "writer_generation=0 should be stored in metadata"); + assert_eq!( + gen, + Some(0), + "writer_generation=0 should be stored in metadata" + ); SETTINGS_STORE.remove(index_name); } @@ -1420,24 +1721,44 @@ fn test_chunked_writer_generation_large_value() { let writer_generation = 999_999i64; let (_schema, schema_ptr) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], writer_generation, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + writer_generation, + ) + .unwrap(); let (ap, sp) = create_ffi_data_with_row_id( vec![80, 20, 60, 40, 10, 90, 50, 30, 70], - vec![Some("H"), Some("B"), Some("F"), Some("D"), Some("A"), - Some("I"), Some("E"), Some("C"), Some("G")], + vec![ + Some("H"), + Some("B"), + Some("F"), + Some("D"), + Some("A"), + Some("I"), + Some("E"), + Some("C"), + Some("G"), + ], vec![0, 1, 2, 3, 4, 5, 6, 7, 8], ); NativeParquetWriter::write_data(filename.clone(), ap, sp).unwrap(); - let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()).unwrap().unwrap(); + let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()) + .unwrap() + .unwrap(); // Verify writer_generation in metadata let gen = read_writer_generation_from_parquet(&filename); - assert_eq!(gen, Some(999_999), - "Large writer_generation should be preserved in Parquet metadata"); + assert_eq!( + gen, + Some(999_999), + "Large writer_generation should be preserved in Parquet metadata" + ); // Verify sort correctness let ages = read_ages_from_parquet(&filename); @@ -1474,9 +1795,15 @@ fn test_unsorted_writer_generation_in_metadata() { let (_, schema_ptr) = create_row_id_schema_ptr(); // No sort columns — uses direct Parquet writer path NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec![], vec![], vec![], writer_generation, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec![], + vec![], + vec![], + writer_generation, + ) + .unwrap(); let (ap, sp) = create_ffi_data_with_row_id( vec![30, 10, 20], @@ -1487,8 +1814,11 @@ fn test_unsorted_writer_generation_in_metadata() { NativeParquetWriter::finalize_writer(filename.clone()).unwrap(); let gen = read_writer_generation_from_parquet(&filename); - assert_eq!(gen, Some(17), - "writer_generation should be in metadata for unsorted writer too"); + assert_eq!( + gen, + Some(17), + "writer_generation should be in metadata for unsorted writer too" + ); // Unsorted: data should preserve insertion order let ages = read_ages_from_parquet(&filename); @@ -1514,9 +1844,15 @@ fn test_chunked_writer_crc32_single_chunk() { let (_schema, schema_ptr) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], 0, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + 0, + ) + .unwrap(); let (ap, sp) = create_ffi_data_with_row_id( vec![30, 10, 50, 20, 40], @@ -1525,16 +1861,22 @@ fn test_chunked_writer_crc32_single_chunk() { ); NativeParquetWriter::write_data(filename.clone(), ap, sp).unwrap(); - let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()).unwrap().unwrap(); + let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()) + .unwrap() + .unwrap(); // CRC should be non-zero for a file with data - assert_ne!(finalize_result.crc32, 0, - "CRC32 should be non-zero for single-chunk sorted output"); + assert_ne!( + finalize_result.crc32, 0, + "CRC32 should be non-zero for single-chunk sorted output" + ); // Verify CRC matches actual file content let actual_crc = compute_file_crc32(&filename); - assert_eq!(finalize_result.crc32, actual_crc, - "CRC32 from finalize should match recomputed CRC of the file on disk"); + assert_eq!( + finalize_result.crc32, actual_crc, + "CRC32 from finalize should match recomputed CRC of the file on disk" + ); SETTINGS_STORE.remove(index_name); } @@ -1554,25 +1896,45 @@ fn test_chunked_writer_crc32_multi_chunk() { let (_schema, schema_ptr) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], 0, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + 0, + ) + .unwrap(); let (ap, sp) = create_ffi_data_with_row_id( vec![50, 30, 10, 40, 20, 60, 5, 45, 35, 15], - vec![Some("E"), Some("C"), Some("A"), Some("D"), Some("B"), - Some("F"), Some("G"), Some("H"), Some("I"), Some("J")], + vec![ + Some("E"), + Some("C"), + Some("A"), + Some("D"), + Some("B"), + Some("F"), + Some("G"), + Some("H"), + Some("I"), + Some("J"), + ], vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9], ); NativeParquetWriter::write_data(filename.clone(), ap, sp).unwrap(); - let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()).unwrap().unwrap(); + let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()) + .unwrap() + .unwrap(); // Multi-chunk merge also produces a CRC (from merge_sorted's CrcWriter) // Verify it matches the actual file let actual_crc = compute_file_crc32(&filename); - assert_eq!(finalize_result.crc32, actual_crc, - "CRC32 from finalize should match recomputed CRC for multi-chunk output"); + assert_eq!( + finalize_result.crc32, actual_crc, + "CRC32 from finalize should match recomputed CRC for multi-chunk output" + ); SETTINGS_STORE.remove(index_name); } @@ -1596,33 +1958,51 @@ fn test_chunked_writer_crc32_differs_for_different_data() { // Writer 1 let (_schema, schema_ptr1) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename1.clone(), index_name1.to_string(), schema_ptr1, - vec!["age".to_string()], vec![false], vec![false], 0, - ).unwrap(); + filename1.clone(), + index_name1.to_string(), + schema_ptr1, + vec!["age".to_string()], + vec![false], + vec![false], + 0, + ) + .unwrap(); let (ap1, sp1) = create_ffi_data_with_row_id( vec![10, 20, 30], vec![Some("A"), Some("B"), Some("C")], vec![0, 1, 2], ); NativeParquetWriter::write_data(filename1.clone(), ap1, sp1).unwrap(); - let result1 = NativeParquetWriter::finalize_writer(filename1.clone()).unwrap().unwrap(); + let result1 = NativeParquetWriter::finalize_writer(filename1.clone()) + .unwrap() + .unwrap(); // Writer 2 — different data let (_schema, schema_ptr2) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename2.clone(), index_name2.to_string(), schema_ptr2, - vec!["age".to_string()], vec![false], vec![false], 0, - ).unwrap(); + filename2.clone(), + index_name2.to_string(), + schema_ptr2, + vec!["age".to_string()], + vec![false], + vec![false], + 0, + ) + .unwrap(); let (ap2, sp2) = create_ffi_data_with_row_id( vec![99, 88, 77], vec![Some("X"), Some("Y"), Some("Z")], vec![0, 1, 2], ); NativeParquetWriter::write_data(filename2.clone(), ap2, sp2).unwrap(); - let result2 = NativeParquetWriter::finalize_writer(filename2.clone()).unwrap().unwrap(); + let result2 = NativeParquetWriter::finalize_writer(filename2.clone()) + .unwrap() + .unwrap(); - assert_ne!(result1.crc32, result2.crc32, - "Different data should produce different CRC32 values"); + assert_ne!( + result1.crc32, result2.crc32, + "Different data should produce different CRC32 values" + ); SETTINGS_STORE.remove(index_name1); SETTINGS_STORE.remove(index_name2); @@ -1652,31 +2032,52 @@ fn test_chunked_writer_batch_slicing_large_batch() { let (_schema, schema_ptr) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], 0, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + 0, + ) + .unwrap(); // Write a batch with 8 rows — each row will be sliced individually let (ap, sp) = create_ffi_data_with_row_id( vec![80, 20, 60, 40, 10, 70, 30, 50], - vec![Some("H"), Some("B"), Some("F"), Some("D"), Some("A"), - Some("G"), Some("C"), Some("E")], + vec![ + Some("H"), + Some("B"), + Some("F"), + Some("D"), + Some("A"), + Some("G"), + Some("C"), + Some("E"), + ], vec![0, 1, 2, 3, 4, 5, 6, 7], ); NativeParquetWriter::write_data(filename.clone(), ap, sp).unwrap(); - let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()).unwrap().unwrap(); + let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()) + .unwrap() + .unwrap(); // Verify output is sorted let ages = read_ages_from_parquet(&filename); - assert_eq!(ages, vec![10, 20, 30, 40, 50, 60, 70, 80], - "Output should be sorted by age ASC even with per-row slicing"); + assert_eq!( + ages, + vec![10, 20, 30, 40, 50, 60, 70, 80], + "Output should be sorted by age ASC even with per-row slicing" + ); // Verify sequential row IDs let row_ids = read_row_ids_from_parquet(&filename); let expected_sequential: Vec = (0..8).collect(); - assert_eq!(row_ids, expected_sequential, - "__row_id__ should be sequential 0..7"); + assert_eq!( + row_ids, expected_sequential, + "__row_id__ should be sequential 0..7" + ); // Verify permutation let mapping = finalize_result.row_id_mapping.expect("Should have mapping"); @@ -1690,8 +2091,10 @@ fn test_chunked_writer_batch_slicing_large_batch() { // Verify CRC is valid let actual_crc = compute_file_crc32(&filename); - assert_eq!(finalize_result.crc32, actual_crc, - "CRC should match file content after batch slicing"); + assert_eq!( + finalize_result.crc32, actual_crc, + "CRC should match file content after batch slicing" + ); SETTINGS_STORE.remove(index_name); } @@ -1714,25 +2117,46 @@ fn test_chunked_writer_batch_slicing_two_rows_per_slice() { let (_schema, schema_ptr) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], 0, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + 0, + ) + .unwrap(); // Write 10 rows — should be sliced into ~5 chunks of 2 rows each let (ap, sp) = create_ffi_data_with_row_id( vec![90, 10, 70, 30, 50, 80, 20, 60, 40, 100], - vec![Some("I"), Some("A"), Some("G"), Some("C"), Some("E"), - Some("H"), Some("B"), Some("F"), Some("D"), Some("J")], + vec![ + Some("I"), + Some("A"), + Some("G"), + Some("C"), + Some("E"), + Some("H"), + Some("B"), + Some("F"), + Some("D"), + Some("J"), + ], vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9], ); NativeParquetWriter::write_data(filename.clone(), ap, sp).unwrap(); - let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()).unwrap().unwrap(); + let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()) + .unwrap() + .unwrap(); // Verify sorted output let ages = read_ages_from_parquet(&filename); - assert_eq!(ages, vec![10, 20, 30, 40, 50, 60, 70, 80, 90, 100], - "Output should be globally sorted despite batch slicing"); + assert_eq!( + ages, + vec![10, 20, 30, 40, 50, 60, 70, 80, 90, 100], + "Output should be globally sorted despite batch slicing" + ); // Verify sequential row IDs let row_ids = read_row_ids_from_parquet(&filename); @@ -1744,14 +2168,20 @@ fn test_chunked_writer_batch_slicing_two_rows_per_slice() { assert_eq!(mapping.len(), 10); let mut sorted_mapping = mapping.clone(); sorted_mapping.sort(); - assert_eq!(sorted_mapping, expected_sequential, "Mapping should be a valid permutation"); + assert_eq!( + sorted_mapping, expected_sequential, + "Mapping should be a valid permutation" + ); // Verify permutation correctness let original_ages = vec![90, 10, 70, 30, 50, 80, 20, 60, 40, 100]; for i in 0..10 { let new_pos = mapping[i] as usize; - assert_eq!(ages[new_pos], original_ages[i], - "original_ages[{}]={} should be at sorted position {}", i, original_ages[i], new_pos); + assert_eq!( + ages[new_pos], original_ages[i], + "original_ages[{}]={} should be at sorted position {}", + i, original_ages[i], new_pos + ); } SETTINGS_STORE.remove(index_name); @@ -1772,9 +2202,15 @@ fn test_chunked_writer_batch_slicing_descending() { let (_schema, schema_ptr) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![true], vec![false], 0, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![true], + vec![false], + 0, + ) + .unwrap(); let (ap, sp) = create_ffi_data_with_row_id( vec![10, 50, 30, 20, 40], @@ -1783,12 +2219,17 @@ fn test_chunked_writer_batch_slicing_descending() { ); NativeParquetWriter::write_data(filename.clone(), ap, sp).unwrap(); - let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()).unwrap().unwrap(); + let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()) + .unwrap() + .unwrap(); // Verify descending sort let ages = read_ages_from_parquet(&filename); - assert_eq!(ages, vec![50, 40, 30, 20, 10], - "Output should be sorted by age DESC with batch slicing"); + assert_eq!( + ages, + vec![50, 40, 30, 20, 10], + "Output should be sorted by age DESC with batch slicing" + ); // Verify sequential row IDs let row_ids = read_row_ids_from_parquet(&filename); @@ -1820,9 +2261,15 @@ fn test_chunked_writer_batch_slicing_multiple_writes() { let (_schema, schema_ptr) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], 0, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + 0, + ) + .unwrap(); // First batch: rows 0..4 let (ap1, sp1) = create_ffi_data_with_row_id( @@ -1840,12 +2287,17 @@ fn test_chunked_writer_batch_slicing_multiple_writes() { ); NativeParquetWriter::write_data(filename.clone(), ap2, sp2).unwrap(); - let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()).unwrap().unwrap(); + let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()) + .unwrap() + .unwrap(); // Verify globally sorted let ages = read_ages_from_parquet(&filename); - assert_eq!(ages, vec![10, 20, 30, 40, 50, 60], - "Output should be globally sorted across sliced multi-write batches"); + assert_eq!( + ages, + vec![10, 20, 30, 40, 50, 60], + "Output should be globally sorted across sliced multi-write batches" + ); // Verify sequential row IDs let row_ids = read_row_ids_from_parquet(&filename); @@ -1883,10 +2335,7 @@ fn create_no_row_id_schema_ptr() -> (Arc, i64) { } /// Helper: creates FFI data WITHOUT __row_id__ column. -fn create_ffi_data_no_row_id( - ages: Vec, - names: Vec>, -) -> (i64, i64) { +fn create_ffi_data_no_row_id(ages: Vec, names: Vec>) -> (i64, i64) { use arrow::array::{Array, Int32Array, StringArray}; use arrow::datatypes::{DataType, Field, Schema}; use arrow::record_batch::RecordBatch; @@ -1897,10 +2346,7 @@ fn create_ffi_data_no_row_id( ])); let age_array: Arc = Arc::new(Int32Array::from(ages)); let name_array: Arc = Arc::new(StringArray::from(names)); - let record_batch = RecordBatch::try_new( - schema.clone(), - vec![age_array, name_array], - ).unwrap(); + let record_batch = RecordBatch::try_new(schema.clone(), vec![age_array, name_array]).unwrap(); let struct_array = arrow::array::StructArray::from(record_batch); let (ffi_array, ffi_schema) = arrow::ffi::to_ffi(&struct_array.to_data()).unwrap(); let array_ptr = Box::into_raw(Box::new(ffi_array)) as i64; @@ -1924,9 +2370,15 @@ fn test_chunked_writer_no_row_id_single_chunk() { let (_schema, schema_ptr) = create_no_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], 0, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + 0, + ) + .unwrap(); let (ap, sp) = create_ffi_data_no_row_id( vec![50, 10, 30, 20, 40], @@ -1934,16 +2386,23 @@ fn test_chunked_writer_no_row_id_single_chunk() { ); NativeParquetWriter::write_data(filename.clone(), ap, sp).unwrap(); - let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()).unwrap().unwrap(); + let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()) + .unwrap() + .unwrap(); // Verify sorted output let ages = read_ages_from_parquet(&filename); - assert_eq!(ages, vec![10, 20, 30, 40, 50], - "Output should be sorted by age ASC even without __row_id__"); + assert_eq!( + ages, + vec![10, 20, 30, 40, 50], + "Output should be sorted by age ASC even without __row_id__" + ); // No permutation mapping when __row_id__ is absent - assert!(finalize_result.row_id_mapping.is_none(), - "row_id_mapping should be None when schema has no __row_id__ column"); + assert!( + finalize_result.row_id_mapping.is_none(), + "row_id_mapping should be None when schema has no __row_id__ column" + ); SETTINGS_STORE.remove(index_name); } @@ -1964,27 +2423,48 @@ fn test_chunked_writer_no_row_id_multi_chunk() { let (_schema, schema_ptr) = create_no_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], 0, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + 0, + ) + .unwrap(); let (ap, sp) = create_ffi_data_no_row_id( vec![80, 20, 60, 40, 10, 70, 30, 50], - vec![Some("H"), Some("B"), Some("F"), Some("D"), Some("A"), - Some("G"), Some("C"), Some("E")], + vec![ + Some("H"), + Some("B"), + Some("F"), + Some("D"), + Some("A"), + Some("G"), + Some("C"), + Some("E"), + ], ); NativeParquetWriter::write_data(filename.clone(), ap, sp).unwrap(); - let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()).unwrap().unwrap(); + let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()) + .unwrap() + .unwrap(); // Verify sorted output let ages = read_ages_from_parquet(&filename); - assert_eq!(ages, vec![10, 20, 30, 40, 50, 60, 70, 80], - "Output should be sorted even without __row_id__ in multi-chunk path"); + assert_eq!( + ages, + vec![10, 20, 30, 40, 50, 60, 70, 80], + "Output should be sorted even without __row_id__ in multi-chunk path" + ); // No permutation mapping - assert!(finalize_result.row_id_mapping.is_none(), - "row_id_mapping should be None without __row_id__ column"); + assert!( + finalize_result.row_id_mapping.is_none(), + "row_id_mapping should be None without __row_id__ column" + ); SETTINGS_STORE.remove(index_name); } @@ -2005,22 +2485,40 @@ fn test_chunked_writer_no_row_id_batch_slicing() { let (_schema, schema_ptr) = create_no_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![true], vec![false], 0, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![true], + vec![false], + 0, + ) + .unwrap(); let (ap, sp) = create_ffi_data_no_row_id( vec![10, 50, 30, 20, 40, 60], - vec![Some("A"), Some("E"), Some("C"), Some("B"), Some("D"), Some("F")], + vec![ + Some("A"), + Some("E"), + Some("C"), + Some("B"), + Some("D"), + Some("F"), + ], ); NativeParquetWriter::write_data(filename.clone(), ap, sp).unwrap(); - let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()).unwrap().unwrap(); + let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()) + .unwrap() + .unwrap(); // Verify descending sort let ages = read_ages_from_parquet(&filename); - assert_eq!(ages, vec![60, 50, 40, 30, 20, 10], - "Output should be sorted DESC without __row_id__ in slicing path"); + assert_eq!( + ages, + vec![60, 50, 40, 30, 20, 10], + "Output should be sorted DESC without __row_id__ in slicing path" + ); // No permutation assert!(finalize_result.row_id_mapping.is_none()); @@ -2037,8 +2535,8 @@ fn test_chunked_writer_no_row_id_batch_slicing() { /// Helper: creates FFI schema with (age: Int32, score: Int32, name: Utf8, __row_id__: Int64). fn create_multi_sort_schema_ptr() -> (Arc, i64) { - use arrow::datatypes::{DataType, Field, Schema}; use crate::merge::schema::ROW_ID_COLUMN_NAME; + use arrow::datatypes::{DataType, Field, Schema}; let schema = Arc::new(Schema::new(vec![ Field::new("age", DataType::Int32, false), @@ -2058,10 +2556,10 @@ fn create_ffi_data_multi_sort( names: Vec>, row_ids: Vec, ) -> (i64, i64) { + use crate::merge::schema::ROW_ID_COLUMN_NAME; use arrow::array::{Array, Int32Array, Int64Array, StringArray}; use arrow::datatypes::{DataType, Field, Schema}; use arrow::record_batch::RecordBatch; - use crate::merge::schema::ROW_ID_COLUMN_NAME; let schema = Arc::new(Schema::new(vec![ Field::new("age", DataType::Int32, false), @@ -2076,7 +2574,8 @@ fn create_ffi_data_multi_sort( let record_batch = RecordBatch::try_new( schema.clone(), vec![age_array, score_array, name_array, row_id_array], - ).unwrap(); + ) + .unwrap(); let struct_array = arrow::array::StructArray::from(record_batch); let (ffi_array, ffi_schema) = arrow::ffi::to_ffi(&struct_array.to_data()).unwrap(); let array_ptr = Box::into_raw(Box::new(ffi_array)) as i64; @@ -2094,8 +2593,14 @@ fn read_scores_from_parquet(filename: &str) -> Vec { return vec![]; } let combined = concat_batches(&batches[0].schema(), &batches).unwrap(); - let idx = combined.schema().index_of("score").expect("score column should exist"); - let col = combined.column(idx).as_any().downcast_ref::() + let idx = combined + .schema() + .index_of("score") + .expect("score column should exist"); + let col = combined + .column(idx) + .as_any() + .downcast_ref::() .expect("score should be Int32"); (0..col.len()).map(|i| col.value(i)).collect() } @@ -2116,20 +2621,35 @@ fn test_multi_column_sort_age_asc_score_desc() { let (_schema, schema_ptr) = create_multi_sort_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string(), "score".to_string()], vec![false, true], vec![false, false], 0, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string(), "score".to_string()], + vec![false, true], + vec![false, false], + 0, + ) + .unwrap(); // Data with duplicate ages to test tie-breaking by score DESC let (ap, sp) = create_ffi_data_multi_sort( - vec![30, 20, 30, 20, 10, 30], // ages: ties at 20 and 30 - vec![50, 80, 90, 40, 70, 10], // scores for tie-breaking - vec![Some("A"), Some("B"), Some("C"), Some("D"), Some("E"), Some("F")], + vec![30, 20, 30, 20, 10, 30], // ages: ties at 20 and 30 + vec![50, 80, 90, 40, 70, 10], // scores for tie-breaking + vec![ + Some("A"), + Some("B"), + Some("C"), + Some("D"), + Some("E"), + Some("F"), + ], vec![0, 1, 2, 3, 4, 5], ); NativeParquetWriter::write_data(filename.clone(), ap, sp).unwrap(); - let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()).unwrap().unwrap(); + let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()) + .unwrap() + .unwrap(); let ages = read_ages_from_parquet(&filename); let scores = read_scores_from_parquet(&filename); @@ -2171,9 +2691,15 @@ fn test_multi_column_sort_age_desc_score_asc() { let (_schema, schema_ptr) = create_multi_sort_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string(), "score".to_string()], vec![true, false], vec![false, false], 0, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string(), "score".to_string()], + vec![true, false], + vec![false, false], + 0, + ) + .unwrap(); let (ap, sp) = create_ffi_data_multi_sort( vec![20, 30, 20, 30, 10], @@ -2214,21 +2740,39 @@ fn test_multi_column_sort_multi_chunk() { let (_schema, schema_ptr) = create_multi_sort_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string(), "score".to_string()], vec![false, false], vec![false, false], 0, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string(), "score".to_string()], + vec![false, false], + vec![false, false], + 0, + ) + .unwrap(); // 10 rows with many ties in age to stress tie-breaking across chunks let (ap, sp) = create_ffi_data_multi_sort( vec![20, 10, 20, 10, 30, 20, 10, 30, 20, 10], vec![50, 30, 10, 70, 20, 40, 10, 60, 30, 50], - vec![Some("A"), Some("B"), Some("C"), Some("D"), Some("E"), - Some("F"), Some("G"), Some("H"), Some("I"), Some("J")], + vec![ + Some("A"), + Some("B"), + Some("C"), + Some("D"), + Some("E"), + Some("F"), + Some("G"), + Some("H"), + Some("I"), + Some("J"), + ], vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9], ); NativeParquetWriter::write_data(filename.clone(), ap, sp).unwrap(); - let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()).unwrap().unwrap(); + let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()) + .unwrap() + .unwrap(); let ages = read_ages_from_parquet(&filename); let scores = read_scores_from_parquet(&filename); @@ -2250,7 +2794,10 @@ fn test_multi_column_sort_multi_chunk() { assert_eq!(mapping.len(), 10); let mut sorted_mapping = mapping.clone(); sorted_mapping.sort(); - assert_eq!(sorted_mapping, expected_sequential, "Mapping should be a valid permutation"); + assert_eq!( + sorted_mapping, expected_sequential, + "Mapping should be a valid permutation" + ); SETTINGS_STORE.remove(index_name); } @@ -2270,9 +2817,15 @@ fn test_multi_column_sort_batch_slicing() { let (_schema, schema_ptr) = create_multi_sort_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string(), "score".to_string()], vec![false, true], vec![false, false], 0, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string(), "score".to_string()], + vec![false, true], + vec![false, false], + 0, + ) + .unwrap(); let (ap, sp) = create_ffi_data_multi_sort( vec![20, 20, 10, 10, 20], @@ -2312,10 +2865,7 @@ fn create_nullable_schema_ptr() -> (Arc, i64) { } /// Helper: creates FFI data with nullable age column. -fn create_ffi_data_nullable_age( - ages: Vec>, - names: Vec>, -) -> (i64, i64) { +fn create_ffi_data_nullable_age(ages: Vec>, names: Vec>) -> (i64, i64) { use arrow::array::{Array, Int32Array, StringArray}; use arrow::datatypes::{DataType, Field, Schema}; use arrow::record_batch::RecordBatch; @@ -2326,10 +2876,7 @@ fn create_ffi_data_nullable_age( ])); let age_array: Arc = Arc::new(Int32Array::from(ages)); let name_array: Arc = Arc::new(StringArray::from(names)); - let record_batch = RecordBatch::try_new( - schema.clone(), - vec![age_array, name_array], - ).unwrap(); + let record_batch = RecordBatch::try_new(schema.clone(), vec![age_array, name_array]).unwrap(); let struct_array = arrow::array::StructArray::from(record_batch); let (ffi_array, ffi_schema) = arrow::ffi::to_ffi(&struct_array.to_data()).unwrap(); let array_ptr = Box::into_raw(Box::new(ffi_array)) as i64; @@ -2345,8 +2892,20 @@ fn read_nullable_ages_from_parquet(filename: &str) -> Vec> { let batches = read_parquet_file(filename); let combined = concat_batches(&batches[0].schema(), &batches).unwrap(); let idx = combined.schema().index_of("age").unwrap(); - let col = combined.column(idx).as_any().downcast_ref::().unwrap(); - (0..col.len()).map(|i| if col.is_null(i) { None } else { Some(col.value(i)) }).collect() + let col = combined + .column(idx) + .as_any() + .downcast_ref::() + .unwrap(); + (0..col.len()) + .map(|i| { + if col.is_null(i) { + None + } else { + Some(col.value(i)) + } + }) + .collect() } /// Test: nulls_first=true places NULL values before non-null values in ascending sort. @@ -2358,15 +2917,21 @@ fn test_nulls_first_true_ascending() { let mut settings = NativeSettings::default(); settings.sort_columns = vec!["age".to_string()]; settings.reverse_sorts = vec![false]; // ASC - settings.nulls_first = vec![true]; // NULLs first + settings.nulls_first = vec![true]; // NULLs first settings.sort_in_memory_threshold_bytes = Some(10 * 1024 * 1024); SETTINGS_STORE.insert(index_name.to_string(), settings); let (_schema, schema_ptr) = create_nullable_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![true], 0, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![true], + 0, + ) + .unwrap(); let (ap, sp) = create_ffi_data_nullable_age( vec![Some(30), None, Some(10), None, Some(20)], @@ -2391,15 +2956,21 @@ fn test_nulls_first_false_ascending() { let mut settings = NativeSettings::default(); settings.sort_columns = vec!["age".to_string()]; settings.reverse_sorts = vec![false]; // ASC - settings.nulls_first = vec![false]; // NULLs last + settings.nulls_first = vec![false]; // NULLs last settings.sort_in_memory_threshold_bytes = Some(10 * 1024 * 1024); SETTINGS_STORE.insert(index_name.to_string(), settings); let (_schema, schema_ptr) = create_nullable_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], 0, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + 0, + ) + .unwrap(); let (ap, sp) = create_ffi_data_nullable_age( vec![Some(30), None, Some(10), None, Some(20)], @@ -2417,8 +2988,8 @@ fn test_nulls_first_false_ascending() { /// Helper: creates FFI schema with nullable age + __row_id__ (age: Int32 nullable, name: Utf8, __row_id__: Int64). fn create_nullable_with_row_id_schema_ptr() -> (Arc, i64) { - use arrow::datatypes::{DataType, Field, Schema}; use crate::merge::schema::ROW_ID_COLUMN_NAME; + use arrow::datatypes::{DataType, Field, Schema}; let schema = Arc::new(Schema::new(vec![ Field::new("age", DataType::Int32, true), // nullable @@ -2436,10 +3007,10 @@ fn create_ffi_data_nullable_with_row_id( names: Vec>, row_ids: Vec, ) -> (i64, i64) { + use crate::merge::schema::ROW_ID_COLUMN_NAME; use arrow::array::{Array, Int32Array, Int64Array, StringArray}; use arrow::datatypes::{DataType, Field, Schema}; use arrow::record_batch::RecordBatch; - use crate::merge::schema::ROW_ID_COLUMN_NAME; let schema = Arc::new(Schema::new(vec![ Field::new("age", DataType::Int32, true), @@ -2449,10 +3020,8 @@ fn create_ffi_data_nullable_with_row_id( let age_array: Arc = Arc::new(Int32Array::from(ages)); let name_array: Arc = Arc::new(StringArray::from(names)); let row_id_array: Arc = Arc::new(Int64Array::from(row_ids)); - let record_batch = RecordBatch::try_new( - schema.clone(), - vec![age_array, name_array, row_id_array], - ).unwrap(); + let record_batch = + RecordBatch::try_new(schema.clone(), vec![age_array, name_array, row_id_array]).unwrap(); let struct_array = arrow::array::StructArray::from(record_batch); let (ffi_array, ffi_schema) = arrow::ffi::to_ffi(&struct_array.to_data()).unwrap(); let array_ptr = Box::into_raw(Box::new(ffi_array)) as i64; @@ -2470,15 +3039,21 @@ fn test_nulls_first_with_row_id_and_permutation() { let mut settings = NativeSettings::default(); settings.sort_columns = vec!["age".to_string()]; settings.reverse_sorts = vec![false]; // ASC - settings.nulls_first = vec![true]; // NULLs first + settings.nulls_first = vec![true]; // NULLs first settings.sort_in_memory_threshold_bytes = Some(10 * 1024 * 1024); SETTINGS_STORE.insert(index_name.to_string(), settings); let (_schema, schema_ptr) = create_nullable_with_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![true], 0, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![true], + 0, + ) + .unwrap(); // Original: row0=age30, row1=NULL, row2=age10, row3=NULL, row4=age20 let (ap, sp) = create_ffi_data_nullable_with_row_id( @@ -2488,7 +3063,9 @@ fn test_nulls_first_with_row_id_and_permutation() { ); NativeParquetWriter::write_data(filename.clone(), ap, sp).unwrap(); - let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()).unwrap().unwrap(); + let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()) + .unwrap() + .unwrap(); // Verify sort: NULLs first, then ascending let ages = read_nullable_ages_from_parquet(&filename); @@ -2496,8 +3073,11 @@ fn test_nulls_first_with_row_id_and_permutation() { // Verify __row_id__ is sequential 0..N let row_ids = read_row_ids_from_parquet(&filename); - assert_eq!(row_ids, vec![0, 1, 2, 3, 4], - "__row_id__ should be sequential after rewrite"); + assert_eq!( + row_ids, + vec![0, 1, 2, 3, 4], + "__row_id__ should be sequential after rewrite" + ); // Verify permutation mapping // Original: age=[30, NULL, 10, NULL, 20], row_ids=[0,1,2,3,4] @@ -2505,10 +3085,15 @@ fn test_nulls_first_with_row_id_and_permutation() { // The two NULLs were at original positions 1 and 3 (stable order preserved by RowConverter) // Sorted positions: NULL(row1)→pos0, NULL(row3)→pos1, 10(row2)→pos2, 20(row4)→pos3, 30(row0)→pos4 // mapping[0]=4, mapping[1]=0, mapping[2]=2, mapping[3]=1, mapping[4]=3 - let mapping = finalize_result.row_id_mapping.expect("Should have row_id_mapping"); + let mapping = finalize_result + .row_id_mapping + .expect("Should have row_id_mapping"); assert_eq!(mapping.len(), 5); - assert_eq!(mapping, vec![4, 0, 2, 1, 3], - "Permutation should correctly place NULLs first and map original row IDs"); + assert_eq!( + mapping, + vec![4, 0, 2, 1, 3], + "Permutation should correctly place NULLs first and map original row IDs" + ); SETTINGS_STORE.remove(index_name); } @@ -2522,15 +3107,21 @@ fn test_nulls_last_with_row_id_and_permutation() { let mut settings = NativeSettings::default(); settings.sort_columns = vec!["age".to_string()]; settings.reverse_sorts = vec![false]; // ASC - settings.nulls_first = vec![false]; // NULLs last + settings.nulls_first = vec![false]; // NULLs last settings.sort_in_memory_threshold_bytes = Some(10 * 1024 * 1024); SETTINGS_STORE.insert(index_name.to_string(), settings); let (_schema, schema_ptr) = create_nullable_with_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], 0, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + 0, + ) + .unwrap(); // Original: row0=age30, row1=NULL, row2=age10, row3=NULL, row4=age20 let (ap, sp) = create_ffi_data_nullable_with_row_id( @@ -2540,7 +3131,9 @@ fn test_nulls_last_with_row_id_and_permutation() { ); NativeParquetWriter::write_data(filename.clone(), ap, sp).unwrap(); - let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()).unwrap().unwrap(); + let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()) + .unwrap() + .unwrap(); // Verify sort: ascending non-nulls, then NULLs last let ages = read_nullable_ages_from_parquet(&filename); @@ -2555,10 +3148,15 @@ fn test_nulls_last_with_row_id_and_permutation() { // Sorted: age=[10, 20, 30, NULL, NULL] // 10(row2)→pos0, 20(row4)→pos1, 30(row0)→pos2, NULL(row1)→pos3, NULL(row3)→pos4 // mapping[0]=2, mapping[1]=3, mapping[2]=0, mapping[3]=4, mapping[4]=1 - let mapping = finalize_result.row_id_mapping.expect("Should have row_id_mapping"); + let mapping = finalize_result + .row_id_mapping + .expect("Should have row_id_mapping"); assert_eq!(mapping.len(), 5); - assert_eq!(mapping, vec![2, 3, 0, 4, 1], - "Permutation should correctly place NULLs last and map original row IDs"); + assert_eq!( + mapping, + vec![2, 3, 0, 4, 1], + "Permutation should correctly place NULLs last and map original row IDs" + ); SETTINGS_STORE.remove(index_name); } @@ -2581,16 +3179,18 @@ fn test_empty_batch_write_does_not_corrupt_sorted_writer() { let (_schema, schema_ptr) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], 0, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + 0, + ) + .unwrap(); // Write an empty batch (0 rows) - let (ap_empty, sp_empty) = create_ffi_data_with_row_id( - vec![], - vec![], - vec![], - ); + let (ap_empty, sp_empty) = create_ffi_data_with_row_id(vec![], vec![], vec![]); NativeParquetWriter::write_data(filename.clone(), ap_empty, sp_empty).unwrap(); // Write a real batch after the empty one @@ -2601,7 +3201,9 @@ fn test_empty_batch_write_does_not_corrupt_sorted_writer() { ); NativeParquetWriter::write_data(filename.clone(), ap, sp).unwrap(); - let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()).unwrap().unwrap(); + let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()) + .unwrap() + .unwrap(); // Verify sorted output — empty batch should have no effect let ages = read_ages_from_parquet(&filename); @@ -2631,9 +3233,15 @@ fn test_only_empty_batches_produces_empty_output() { let (_schema, schema_ptr) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], 0, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + 0, + ) + .unwrap(); // Write multiple empty batches for _ in 0..3 { @@ -2641,7 +3249,9 @@ fn test_only_empty_batches_produces_empty_output() { NativeParquetWriter::write_data(filename.clone(), ap, sp).unwrap(); } - let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()).unwrap().unwrap(); + let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()) + .unwrap() + .unwrap(); assert_eq!(finalize_result.metadata.file_metadata().num_rows(), 0); assert!(finalize_result.row_id_mapping.is_none()); @@ -2666,45 +3276,66 @@ fn test_memory_usage_ipc_writer_reports_chunk_row_ids() { let (_schema, schema_ptr) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], 0, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + 0, + ) + .unwrap(); // Before writing, memory should be 0 (no chunks flushed yet) - let path_prefix = Path::new(&filename).parent().unwrap().to_string_lossy().to_string(); - let mem_before = NativeParquetWriter::get_filtered_writer_memory_usage( - path_prefix.clone() - ).unwrap(); + let path_prefix = Path::new(&filename) + .parent() + .unwrap() + .to_string_lossy() + .to_string(); + let mem_before = + NativeParquetWriter::get_filtered_writer_memory_usage(path_prefix.clone()).unwrap(); assert_eq!(mem_before, 0, "Memory should be 0 before any chunk flush"); // Write enough data to trigger at least one chunk flush let (ap, sp) = create_ffi_data_with_row_id( vec![50, 30, 10, 40, 20, 60, 5, 45, 35, 15], - vec![Some("E"), Some("C"), Some("A"), Some("D"), Some("B"), - Some("F"), Some("G"), Some("H"), Some("I"), Some("J")], + vec![ + Some("E"), + Some("C"), + Some("A"), + Some("D"), + Some("B"), + Some("F"), + Some("G"), + Some("H"), + Some("I"), + Some("J"), + ], vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9], ); NativeParquetWriter::write_data(filename.clone(), ap, sp).unwrap(); // After writing (chunks flushed), memory should be > 0 due to chunk_row_ids - let mem_after = NativeParquetWriter::get_filtered_writer_memory_usage( - path_prefix.clone() - ).unwrap(); - assert!(mem_after > 0, + let mem_after = + NativeParquetWriter::get_filtered_writer_memory_usage(path_prefix.clone()).unwrap(); + assert!( + mem_after > 0, "Memory should be > 0 after chunk flushes (chunk_row_ids accumulated), got {}", - mem_after); + mem_after + ); // Memory should be proportional to rows written × sizeof(i64) // 10 rows × 8 bytes = 80 bytes minimum (could be more if split across chunks) - assert!(mem_after >= 80, - "Memory should be at least 10 rows × 8 bytes = 80, got {}", mem_after); + assert!( + mem_after >= 80, + "Memory should be at least 10 rows × 8 bytes = 80, got {}", + mem_after + ); // Finalize and verify writer is removed NativeParquetWriter::finalize_writer(filename.clone()).unwrap(); - let mem_final = NativeParquetWriter::get_filtered_writer_memory_usage( - path_prefix - ).unwrap(); + let mem_final = NativeParquetWriter::get_filtered_writer_memory_usage(path_prefix).unwrap(); assert_eq!(mem_final, 0, "Memory should be 0 after writer is finalized"); SETTINGS_STORE.remove(index_name); @@ -2728,16 +3359,28 @@ fn test_memory_usage_path_prefix_filtering() { // Create writer 1 let (_schema, schema_ptr1) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename1.clone(), index_name.to_string(), schema_ptr1, - vec!["age".to_string()], vec![false], vec![false], 0, - ).unwrap(); + filename1.clone(), + index_name.to_string(), + schema_ptr1, + vec!["age".to_string()], + vec![false], + vec![false], + 0, + ) + .unwrap(); // Create writer 2 let (_schema, schema_ptr2) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename2.clone(), index_name.to_string(), schema_ptr2, - vec!["age".to_string()], vec![false], vec![false], 0, - ).unwrap(); + filename2.clone(), + index_name.to_string(), + schema_ptr2, + vec!["age".to_string()], + vec![false], + vec![false], + 0, + ) + .unwrap(); // Write to writer 1 only let (ap, sp) = create_ffi_data_with_row_id( @@ -2748,8 +3391,16 @@ fn test_memory_usage_path_prefix_filtering() { NativeParquetWriter::write_data(filename1.clone(), ap, sp).unwrap(); // Query with prefix that matches only writer 1's directory - let prefix1 = Path::new(&filename1).parent().unwrap().to_string_lossy().to_string(); - let prefix2 = Path::new(&filename2).parent().unwrap().to_string_lossy().to_string(); + let prefix1 = Path::new(&filename1) + .parent() + .unwrap() + .to_string_lossy() + .to_string(); + let prefix2 = Path::new(&filename2) + .parent() + .unwrap() + .to_string_lossy() + .to_string(); let mem1 = NativeParquetWriter::get_filtered_writer_memory_usage(prefix1).unwrap(); let mem2 = NativeParquetWriter::get_filtered_writer_memory_usage(prefix2).unwrap(); @@ -2781,8 +3432,10 @@ fn test_write_data_no_ipc_writer() { let result = NativeParquetWriter::write_data(filename.clone(), ap, sp); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("Writer not found"), - "Should get 'Writer not found' error for non-existent IPC writer"); + assert!( + result.unwrap_err().to_string().contains("Writer not found"), + "Should get 'Writer not found' error for non-existent IPC writer" + ); } // ===== Sort stability edge case ===== @@ -2808,9 +3461,15 @@ fn test_sort_all_identical_keys_produces_valid_permutation() { let (_schema, schema_ptr) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], 0, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + 0, + ) + .unwrap(); // All rows have age=30 — sort has zero ordering information let (ap, sp) = create_ffi_data_with_row_id( @@ -2820,7 +3479,9 @@ fn test_sort_all_identical_keys_produces_valid_permutation() { ); NativeParquetWriter::write_data(filename.clone(), ap, sp).unwrap(); - let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()).unwrap().unwrap(); + let finalize_result = NativeParquetWriter::finalize_writer(filename.clone()) + .unwrap() + .unwrap(); // All ages should be 30 let ages = read_ages_from_parquet(&filename); @@ -2835,8 +3496,11 @@ fn test_sort_all_identical_keys_produces_valid_permutation() { assert_eq!(mapping.len(), 5); let mut sorted_mapping = mapping.clone(); sorted_mapping.sort(); - assert_eq!(sorted_mapping, vec![0, 1, 2, 3, 4], - "Mapping must be a valid permutation even with all-identical sort keys"); + assert_eq!( + sorted_mapping, + vec![0, 1, 2, 3, 4], + "Mapping must be a valid permutation even with all-identical sort keys" + ); } // ===== Writer properties verification tests ===== @@ -2851,9 +3515,15 @@ fn read_compression_from_parquet(filename: &str) -> parquet::basic::Compression let file = File::open(filename).unwrap(); let reader = SerializedFileReader::new(file).unwrap(); let metadata = reader.metadata(); - assert!(metadata.num_row_groups() > 0, "File should have at least one row group"); + assert!( + metadata.num_row_groups() > 0, + "File should have at least one row group" + ); let rg = metadata.row_group(0); - assert!(rg.num_columns() > 0, "Row group should have at least one column"); + assert!( + rg.num_columns() > 0, + "Row group should have at least one column" + ); rg.column(0).compression() } @@ -2884,12 +3554,11 @@ fn read_format_version_from_parquet(filename: &str) -> Option { let file = File::open(filename).unwrap(); let reader = SerializedFileReader::new(file).unwrap(); let metadata = reader.metadata().file_metadata(); - metadata.key_value_metadata() - .and_then(|kvs| { - kvs.iter() - .find(|kv| kv.key == "opensearch.format_version") - .and_then(|kv| kv.value.clone()) - }) + metadata.key_value_metadata().and_then(|kvs| { + kvs.iter() + .find(|kv| kv.key == "opensearch.format_version") + .and_then(|kv| kv.value.clone()) + }) } /// Test: Writer properties are honored in the EMPTY path (0 chunks). @@ -2911,22 +3580,34 @@ fn test_writer_properties_honored_empty_path() { let writer_generation = 99i64; let (_schema, schema_ptr) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], writer_generation, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + writer_generation, + ) + .unwrap(); // Don't write any data — triggers the empty path NativeParquetWriter::finalize_writer(filename.clone()).unwrap(); // Verify format version is stamped let format_version = read_format_version_from_parquet(&filename); - assert_eq!(format_version.as_deref(), Some("1.0.0.0"), - "Empty path should stamp format version in output file"); + assert_eq!( + format_version.as_deref(), + Some("1.0.0.0"), + "Empty path should stamp format version in output file" + ); // Verify writer generation is stamped let gen = read_writer_generation_from_parquet(&filename); - assert_eq!(gen, Some(99), - "Empty path should stamp writer_generation in output file"); + assert_eq!( + gen, + Some(99), + "Empty path should stamp writer_generation in output file" + ); // Empty file has no row groups, so we can't check compression or bloom filter // on column chunks. But the properties were applied to the writer. @@ -2953,9 +3634,15 @@ fn test_writer_properties_honored_single_chunk_snappy_no_bloom() { let writer_generation = 55i64; let (_schema, schema_ptr) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], writer_generation, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + writer_generation, + ) + .unwrap(); let (ap, sp) = create_ffi_data_with_row_id( vec![30, 10, 50, 20, 40], @@ -2967,22 +3654,33 @@ fn test_writer_properties_honored_single_chunk_snappy_no_bloom() { // Verify compression is SNAPPY let compression = read_compression_from_parquet(&filename); - assert!(matches!(compression, parquet::basic::Compression::SNAPPY), - "Single chunk path should honor SNAPPY compression, got: {:?}", compression); + assert!( + matches!(compression, parquet::basic::Compression::SNAPPY), + "Single chunk path should honor SNAPPY compression, got: {:?}", + compression + ); // Verify bloom filter is NOT present (disabled) - assert!(!has_bloom_filter_in_parquet(&filename), - "Single chunk path should honor bloom_filter_enabled=false (no bloom filter in file)"); + assert!( + !has_bloom_filter_in_parquet(&filename), + "Single chunk path should honor bloom_filter_enabled=false (no bloom filter in file)" + ); // Verify format version let format_version = read_format_version_from_parquet(&filename); - assert_eq!(format_version.as_deref(), Some("1.0.0.0"), - "Single chunk path should stamp format version"); + assert_eq!( + format_version.as_deref(), + Some("1.0.0.0"), + "Single chunk path should stamp format version" + ); // Verify writer generation let gen = read_writer_generation_from_parquet(&filename); - assert_eq!(gen, Some(55), - "Single chunk path should stamp writer_generation"); + assert_eq!( + gen, + Some(55), + "Single chunk path should stamp writer_generation" + ); // Verify data correctness (sort order) let ages = read_ages_from_parquet(&filename); @@ -3013,9 +3711,15 @@ fn test_writer_properties_honored_single_chunk_zstd_with_bloom() { let writer_generation = 12i64; let (_schema, schema_ptr) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], writer_generation, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + writer_generation, + ) + .unwrap(); let (ap, sp) = create_ffi_data_with_row_id( vec![30, 10, 50, 20, 40], @@ -3027,12 +3731,17 @@ fn test_writer_properties_honored_single_chunk_zstd_with_bloom() { // Verify compression is ZSTD let compression = read_compression_from_parquet(&filename); - assert!(matches!(compression, parquet::basic::Compression::ZSTD(_)), - "Single chunk path should honor ZSTD compression, got: {:?}", compression); + assert!( + matches!(compression, parquet::basic::Compression::ZSTD(_)), + "Single chunk path should honor ZSTD compression, got: {:?}", + compression + ); // Verify bloom filter IS present (enabled) - assert!(has_bloom_filter_in_parquet(&filename), - "Single chunk path should honor bloom_filter_enabled=true (bloom filter should be in file)"); + assert!( + has_bloom_filter_in_parquet(&filename), + "Single chunk path should honor bloom_filter_enabled=true (bloom filter should be in file)" + ); // Verify format version let format_version = read_format_version_from_parquet(&filename); @@ -3064,14 +3773,30 @@ fn test_writer_properties_honored_multi_chunk_snappy_no_bloom() { let writer_generation = 77i64; let (_schema, schema_ptr) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], writer_generation, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + writer_generation, + ) + .unwrap(); let (ap, sp) = create_ffi_data_with_row_id( vec![50, 30, 10, 40, 20, 60, 5, 45, 35, 15], - vec![Some("E"), Some("C"), Some("A"), Some("D"), Some("B"), - Some("F"), Some("G"), Some("H"), Some("I"), Some("J")], + vec![ + Some("E"), + Some("C"), + Some("A"), + Some("D"), + Some("B"), + Some("F"), + Some("G"), + Some("H"), + Some("I"), + Some("J"), + ], vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9], ); NativeParquetWriter::write_data(filename.clone(), ap, sp).unwrap(); @@ -3079,17 +3804,25 @@ fn test_writer_properties_honored_multi_chunk_snappy_no_bloom() { // Verify compression is SNAPPY in the merged output let compression = read_compression_from_parquet(&filename); - assert!(matches!(compression, parquet::basic::Compression::SNAPPY), - "Multi chunk (k-way merge) path should honor SNAPPY compression, got: {:?}", compression); + assert!( + matches!(compression, parquet::basic::Compression::SNAPPY), + "Multi chunk (k-way merge) path should honor SNAPPY compression, got: {:?}", + compression + ); // Verify bloom filter is NOT present - assert!(!has_bloom_filter_in_parquet(&filename), - "Multi chunk path should honor bloom_filter_enabled=false"); + assert!( + !has_bloom_filter_in_parquet(&filename), + "Multi chunk path should honor bloom_filter_enabled=false" + ); // Verify format version let format_version = read_format_version_from_parquet(&filename); - assert_eq!(format_version.as_deref(), Some("1.0.0.0"), - "Multi chunk path should stamp format version"); + assert_eq!( + format_version.as_deref(), + Some("1.0.0.0"), + "Multi chunk path should stamp format version" + ); // Verify data correctness let ages = read_ages_from_parquet(&filename); @@ -3120,14 +3853,30 @@ fn test_writer_properties_honored_multi_chunk_zstd_with_bloom() { let writer_generation = 33i64; let (_schema, schema_ptr) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], writer_generation, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + writer_generation, + ) + .unwrap(); let (ap, sp) = create_ffi_data_with_row_id( vec![50, 30, 10, 40, 20, 60, 5, 45, 35, 15], - vec![Some("E"), Some("C"), Some("A"), Some("D"), Some("B"), - Some("F"), Some("G"), Some("H"), Some("I"), Some("J")], + vec![ + Some("E"), + Some("C"), + Some("A"), + Some("D"), + Some("B"), + Some("F"), + Some("G"), + Some("H"), + Some("I"), + Some("J"), + ], vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9], ); NativeParquetWriter::write_data(filename.clone(), ap, sp).unwrap(); @@ -3135,12 +3884,17 @@ fn test_writer_properties_honored_multi_chunk_zstd_with_bloom() { // Verify compression is ZSTD in the merged output let compression = read_compression_from_parquet(&filename); - assert!(matches!(compression, parquet::basic::Compression::ZSTD(_)), - "Multi chunk (k-way merge) path should honor ZSTD compression, got: {:?}", compression); + assert!( + matches!(compression, parquet::basic::Compression::ZSTD(_)), + "Multi chunk (k-way merge) path should honor ZSTD compression, got: {:?}", + compression + ); // Verify bloom filter IS present - assert!(has_bloom_filter_in_parquet(&filename), - "Multi chunk path should honor bloom_filter_enabled=true"); + assert!( + has_bloom_filter_in_parquet(&filename), + "Multi chunk path should honor bloom_filter_enabled=true" + ); // Verify format version let format_version = read_format_version_from_parquet(&filename); @@ -3171,9 +3925,15 @@ fn test_writer_properties_honored_single_chunk_uncompressed() { let (_schema, schema_ptr) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], 0, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + 0, + ) + .unwrap(); let (ap, sp) = create_ffi_data_with_row_id( vec![30, 10, 50, 20, 40], @@ -3185,12 +3945,17 @@ fn test_writer_properties_honored_single_chunk_uncompressed() { // Verify compression is UNCOMPRESSED let compression = read_compression_from_parquet(&filename); - assert!(matches!(compression, parquet::basic::Compression::UNCOMPRESSED), - "Single chunk path should honor UNCOMPRESSED setting, got: {:?}", compression); + assert!( + matches!(compression, parquet::basic::Compression::UNCOMPRESSED), + "Single chunk path should honor UNCOMPRESSED setting, got: {:?}", + compression + ); // Verify bloom filter IS present - assert!(has_bloom_filter_in_parquet(&filename), - "Single chunk path should honor bloom_filter_enabled=true"); + assert!( + has_bloom_filter_in_parquet(&filename), + "Single chunk path should honor bloom_filter_enabled=true" + ); SETTINGS_STORE.remove(index_name); } @@ -3212,14 +3977,30 @@ fn test_writer_properties_honored_multi_chunk_uncompressed() { let (_schema, schema_ptr) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], 0, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + 0, + ) + .unwrap(); let (ap, sp) = create_ffi_data_with_row_id( vec![50, 30, 10, 40, 20, 60, 5, 45, 35, 15], - vec![Some("E"), Some("C"), Some("A"), Some("D"), Some("B"), - Some("F"), Some("G"), Some("H"), Some("I"), Some("J")], + vec![ + Some("E"), + Some("C"), + Some("A"), + Some("D"), + Some("B"), + Some("F"), + Some("G"), + Some("H"), + Some("I"), + Some("J"), + ], vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9], ); NativeParquetWriter::write_data(filename.clone(), ap, sp).unwrap(); @@ -3227,12 +4008,17 @@ fn test_writer_properties_honored_multi_chunk_uncompressed() { // Verify compression is UNCOMPRESSED let compression = read_compression_from_parquet(&filename); - assert!(matches!(compression, parquet::basic::Compression::UNCOMPRESSED), - "Multi chunk path should honor UNCOMPRESSED setting, got: {:?}", compression); + assert!( + matches!(compression, parquet::basic::Compression::UNCOMPRESSED), + "Multi chunk path should honor UNCOMPRESSED setting, got: {:?}", + compression + ); // Verify bloom filter IS present - assert!(has_bloom_filter_in_parquet(&filename), - "Multi chunk path should honor bloom_filter_enabled=true"); + assert!( + has_bloom_filter_in_parquet(&filename), + "Multi chunk path should honor bloom_filter_enabled=true" + ); // Verify data correctness let ages = read_ages_from_parquet(&filename); @@ -3258,9 +4044,15 @@ fn test_writer_properties_defaults_single_chunk() { let (_schema, schema_ptr) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], 0, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + 0, + ) + .unwrap(); let (ap, sp) = create_ffi_data_with_row_id( vec![30, 10, 50, 20, 40], @@ -3272,12 +4064,17 @@ fn test_writer_properties_defaults_single_chunk() { // Default compression is LZ4_RAW let compression = read_compression_from_parquet(&filename); - assert!(matches!(compression, parquet::basic::Compression::LZ4_RAW), - "Default compression should be LZ4_RAW, got: {:?}", compression); + assert!( + matches!(compression, parquet::basic::Compression::LZ4_RAW), + "Default compression should be LZ4_RAW, got: {:?}", + compression + ); // Default bloom filter is enabled - assert!(!has_bloom_filter_in_parquet(&filename), - "Default bloom_filter_enabled should be false"); + assert!( + !has_bloom_filter_in_parquet(&filename), + "Default bloom_filter_enabled should be false" + ); // Format version always stamped let format_version = read_format_version_from_parquet(&filename); @@ -3301,14 +4098,30 @@ fn test_writer_properties_defaults_multi_chunk() { let (_schema, schema_ptr) = create_row_id_schema_ptr(); NativeParquetWriter::create_writer( - filename.clone(), index_name.to_string(), schema_ptr, - vec!["age".to_string()], vec![false], vec![false], 0, - ).unwrap(); + filename.clone(), + index_name.to_string(), + schema_ptr, + vec!["age".to_string()], + vec![false], + vec![false], + 0, + ) + .unwrap(); let (ap, sp) = create_ffi_data_with_row_id( vec![50, 30, 10, 40, 20, 60, 5, 45, 35, 15], - vec![Some("E"), Some("C"), Some("A"), Some("D"), Some("B"), - Some("F"), Some("G"), Some("H"), Some("I"), Some("J")], + vec![ + Some("E"), + Some("C"), + Some("A"), + Some("D"), + Some("B"), + Some("F"), + Some("G"), + Some("H"), + Some("I"), + Some("J"), + ], vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9], ); NativeParquetWriter::write_data(filename.clone(), ap, sp).unwrap(); @@ -3316,12 +4129,17 @@ fn test_writer_properties_defaults_multi_chunk() { // Default compression is LZ4_RAW let compression = read_compression_from_parquet(&filename); - assert!(matches!(compression, parquet::basic::Compression::LZ4_RAW), - "Default compression should be LZ4_RAW in multi-chunk path, got: {:?}", compression); + assert!( + matches!(compression, parquet::basic::Compression::LZ4_RAW), + "Default compression should be LZ4_RAW in multi-chunk path, got: {:?}", + compression + ); // Default bloom filter is enabled - assert!(!has_bloom_filter_in_parquet(&filename), - "Default bloom_filter_enabled should be false in multi-chunk path"); + assert!( + !has_bloom_filter_in_parquet(&filename), + "Default bloom_filter_enabled should be false in multi-chunk path" + ); // Format version always stamped let format_version = read_format_version_from_parquet(&filename); diff --git a/sandbox/plugins/parquet-data-format/src/main/rust/src/writer.rs b/sandbox/plugins/parquet-data-format/src/main/rust/src/writer.rs index 3bce35f286f5d..0167362c30c4f 100644 --- a/sandbox/plugins/parquet-data-format/src/main/rust/src/writer.rs +++ b/sandbox/plugins/parquet-data-format/src/main/rust/src/writer.rs @@ -6,12 +6,12 @@ * compatible open source license. */ +use arrow::compute::{concat_batches, take}; use arrow::ffi::{FFI_ArrowArray, FFI_ArrowSchema}; use arrow::record_batch::RecordBatch; -use arrow::compute::{concat_batches, take}; use arrow::row::{RowConverter, SortField}; -use arrow_ipc::writer::FileWriter as IpcFileWriter; use arrow_ipc::reader::FileReader as IpcFileReader; +use arrow_ipc::writer::FileWriter as IpcFileWriter; use dashmap::DashMap; use lazy_static::lazy_static; use parquet::arrow::ArrowWriter; @@ -20,11 +20,13 @@ use std::fs::File; use std::path::Path; use std::sync::{Arc, Mutex}; -use crate::{log_error, log_debug, log_info}; use crate::crc_writer::CrcWriter; -use crate::merge::{merge_sorted, schema::ROW_ID_COLUMN_NAME}; +use crate::memory::write_pool; +use crate::merge::{merge_sorted_with_pool, schema::ROW_ID_COLUMN_NAME}; use crate::native_settings::NativeSettings; use crate::writer_properties_builder::WriterPropertiesBuilder; +use crate::{log_debug, log_error, log_info}; +use native_bridge_common::memory_pool::{MemoryReservation, PoolBehavior}; /// Result from finalizing a writer: Parquet metadata + whole-file CRC32 + optional sort permutation. #[derive(Debug)] @@ -148,7 +150,11 @@ impl SortingChunkedWriter { Ok(()) } - fn write(&mut self, batch: &RecordBatch) -> Result<(), Box> { + fn write( + &mut self, + batch: &RecordBatch, + reservation: &mut MemoryReservation, + ) -> Result<(), Box> { if self.current_ipc_writer.is_none() { return Ok(()); } @@ -162,7 +168,7 @@ impl SortingChunkedWriter { if self.current_chunk_bytes > 0 && self.current_chunk_bytes + incoming_batch_bytes > self.memory_threshold_bytes { - self.flush_and_sort_chunk()?; + self.flush_and_sort_chunk(reservation)?; } // If the batch itself fits within the threshold, write it directly. @@ -179,10 +185,8 @@ impl SortingChunkedWriter { let num_rows = batch.num_rows(); let bytes_per_row = incoming_batch_bytes / num_rows as u64; // Compute how many rows fit within the threshold (at least 1 to make progress). - let rows_per_slice = std::cmp::max( - 1, - (self.memory_threshold_bytes / bytes_per_row) as usize, - ); + let rows_per_slice = + std::cmp::max(1, (self.memory_threshold_bytes / bytes_per_row) as usize); let mut offset = 0; while offset < num_rows { @@ -200,21 +204,24 @@ impl SortingChunkedWriter { // Flush after each slice that fills the budget. if self.current_chunk_bytes >= self.memory_threshold_bytes { - self.flush_and_sort_chunk()?; + self.flush_and_sort_chunk(reservation)?; } } } // Safety net: flush if we ended up at or above the threshold. if self.current_chunk_bytes >= self.memory_threshold_bytes { - self.flush_and_sort_chunk()?; + self.flush_and_sort_chunk(reservation)?; } Ok(()) } /// Close the current IPC file, read it back, sort, write as sorted Parquet chunk. - fn flush_and_sort_chunk(&mut self) -> Result<(), Box> { + fn flush_and_sort_chunk( + &mut self, + reservation: &mut MemoryReservation, + ) -> Result<(), Box> { use arrow::array::Int64Array; log_debug!( @@ -222,6 +229,10 @@ impl SortingChunkedWriter { self.chunk_idx, self.current_chunk_bytes, self.current_rows, self.memory_size() ); + // Reserve memory for sort (read-back + sorted copy coexist = 2× chunk) + let sort_reserve = self.current_chunk_bytes as usize * 2; + reservation.request(sort_reserve)?; + // Close the IPC writer if let Some(mut writer) = self.current_ipc_writer.take() { writer.finish()?; @@ -242,6 +253,7 @@ impl SortingChunkedWriter { if batches.is_empty() { // Nothing to sort, just reopen + reservation.shrink(sort_reserve); let _ = std::fs::remove_file(&ipc_path); self.open_new_ipc()?; return Ok(()); @@ -251,14 +263,22 @@ impl SortingChunkedWriter { let combined = concat_batches(&self.schema, &batches)?; drop(batches); // free memory before sort allocates let sorted_batch = NativeParquetWriter::sort_batch( - &combined, &self.sort_columns, &self.reverse_sorts, &self.nulls_first, + &combined, + &self.sort_columns, + &self.reverse_sorts, + &self.nulls_first, )?; drop(combined); // free unsorted data // Capture original row IDs for permutation building, then rewrite to sequential 0..N - let row_id_col_idx = self.schema.fields().iter().position(|f| f.name() == ROW_ID_COLUMN_NAME); + let row_id_col_idx = self + .schema + .fields() + .iter() + .position(|f| f.name() == ROW_ID_COLUMN_NAME); let final_batch = if let Some(idx) = row_id_col_idx { - let row_id_array = sorted_batch.column(idx) + let row_id_array = sorted_batch + .column(idx) .as_any() .downcast_ref::() .expect("___row_id column must be Int64"); @@ -268,9 +288,8 @@ impl SortingChunkedWriter { self.chunk_row_ids.push(ids); // Rewrite ___row_id to sequential 0..N so the chunk file is self-consistent - let sequential_ids = Int64Array::from_iter_values( - (0..sorted_batch.num_rows() as i64).map(|x| x) - ); + let sequential_ids = + Int64Array::from_iter_values((0..sorted_batch.num_rows() as i64).map(|x| x)); let mut columns = sorted_batch.columns().to_vec(); columns[idx] = Arc::new(sequential_ids); RecordBatch::try_new(self.schema.clone(), columns)? @@ -281,13 +300,24 @@ impl SortingChunkedWriter { // Write sorted chunk as Parquet let chunk_path = self.sorted_chunk_path(self.chunk_idx); let crc32 = NativeParquetWriter::write_final_file( - &chunk_path, &self.index_name, &final_batch, self.schema.clone(), Some(self.writer_generation), + &chunk_path, + &self.index_name, + &final_batch, + self.schema.clone(), + Some(self.writer_generation), )?; self.completed_chunks.push(chunk_path); self.chunk_crcs.push(crc32); self.chunk_idx += 1; + // Release sort memory + // Release sort working memory (read-back + sorted copy no longer alive) + reservation.shrink(sort_reserve); + // Track accumulated row_ids for this chunk — Vec per chunk persists until finish() + let row_ids_bytes = self.current_rows * std::mem::size_of::(); + reservation.grow(row_ids_bytes); + // Delete the IPC staging file and open a fresh one let _ = std::fs::remove_file(&ipc_path); self.open_new_ipc()?; @@ -295,9 +325,12 @@ impl SortingChunkedWriter { } /// Finalize: flush remaining IPC data (sort + write) and return chunk paths + row IDs + CRCs. - fn finish(mut self) -> Result<(Vec, Vec>, Vec), Box> { + fn finish( + mut self, + reservation: &mut MemoryReservation, + ) -> Result<(Vec, Vec>, Vec), Box> { if self.current_rows > 0 { - self.flush_and_sort_chunk()?; + self.flush_and_sort_chunk(reservation)?; } // Close and remove the trailing IPC staging file if let Some(mut writer) = self.current_ipc_writer.take() { @@ -322,7 +355,8 @@ impl SortingChunkedWriter { /// where the IPC file is read back and sorted — that's short-lived and freed /// before the method returns. fn memory_size(&self) -> usize { - self.chunk_row_ids.iter() + self.chunk_row_ids + .iter() .map(|ids| ids.len() * std::mem::size_of::()) .sum() } @@ -335,6 +369,7 @@ struct WriterState { settings: NativeSettings, crc_handle: Option, writer_generation: i64, + reservation: MemoryReservation, } /// Path suffix for the intermediate Arrow IPC file used during sort-on-close. @@ -360,7 +395,10 @@ impl NativeParquetWriter { let path = Path::new(filename); path.parent() .unwrap_or_else(|| Path::new("")) - .join(format!("temp-{}", path.file_name().unwrap().to_str().unwrap())) + .join(format!( + "temp-{}", + path.file_name().unwrap().to_str().unwrap() + )) .to_string_lossy() .to_string() } @@ -380,7 +418,10 @@ impl NativeParquetWriter { ); if (schema_address as *mut u8).is_null() { - log_error!("ERROR: Invalid schema address (null pointer) for file: {}", filename); + log_error!( + "ERROR: Invalid schema address (null pointer) for file: {}", + filename + ); return Err("Invalid schema address".into()); } @@ -422,29 +463,55 @@ impl NativeParquetWriter { settings.nulls_first.clone(), writer_generation, )?; - (WriterVariant::Ipc(Arc::new(Mutex::new(chunked_writer))), None) + ( + WriterVariant::Ipc(Arc::new(Mutex::new(chunked_writer))), + None, + ) } else { let file = File::create(&temp_filename)?; let (crc_file, crc_handle) = CrcWriter::new(file); - let props = WriterPropertiesBuilder::build_with_generation(&settings, Some(writer_generation), &schema) - .map_err(|e| format!("Invalid encoding/compression config: {}", e))?; + let props = WriterPropertiesBuilder::build_with_generation( + &settings, + Some(writer_generation), + &schema, + ) + .map_err(|e| format!("Invalid encoding/compression config: {}", e))?; let writer = ArrowWriter::try_new(crc_file, schema, Some(props))?; - (WriterVariant::Parquet(Arc::new(Mutex::new(writer))), Some(crc_handle)) + ( + WriterVariant::Parquet(Arc::new(Mutex::new(writer))), + Some(crc_handle), + ) }; - WRITERS.insert(temp_filename, WriterState { - variant, - settings, - crc_handle, - writer_generation, - }); + WRITERS.insert( + temp_filename, + WriterState { + variant, + settings, + crc_handle, + writer_generation, + reservation: MemoryReservation::new( + write_pool(), + "parquet_writer", + PoolBehavior::IgnoreLimit, + ), + }, + ); Ok(()) } - pub fn write_data(filename: String, array_address: i64, schema_address: i64) -> Result<(), Box> { + pub fn write_data( + filename: String, + array_address: i64, + schema_address: i64, + ) -> Result<(), Box> { let temp_filename = Self::temp_filename(&filename); - log_debug!("write_data called for file: {} (temp: {})", filename, temp_filename); + log_debug!( + "write_data called for file: {} (temp: {})", + filename, + temp_filename + ); if (array_address as *mut u8).is_null() || (schema_address as *mut u8).is_null() { log_error!("ERROR: Invalid FFI addresses for file: {}", temp_filename); @@ -460,19 +527,35 @@ impl NativeParquetWriter { if let Some(struct_array) = array.as_any().downcast_ref::() { let schema = Arc::new(arrow::datatypes::Schema::new(struct_array.fields().clone())); let record_batch = RecordBatch::try_new(schema, struct_array.columns().to_vec())?; - log_debug!("Created RecordBatch with {} rows and {} columns", record_batch.num_rows(), record_batch.num_columns()); + log_debug!( + "Created RecordBatch with {} rows and {} columns", + record_batch.num_rows(), + record_batch.num_columns() + ); - if let Some(state) = WRITERS.get_mut(&temp_filename) { + if let Some(mut state) = WRITERS.get_mut(&temp_filename) { match &state.variant { WriterVariant::Ipc(writer_arc) => { log_debug!("Writing RecordBatch to IPC staging file"); + let writer_arc = writer_arc.clone(); let mut writer = writer_arc.lock().unwrap(); - writer.write(&record_batch)?; + writer.write(&record_batch, &mut state.reservation)?; } WriterVariant::Parquet(writer_arc) => { log_debug!("Writing RecordBatch to Parquet file"); + let batch_bytes = record_batch.get_array_memory_size(); + // Reserve 3× batch as estimate — ArrowWriter encoding may temporarily + // hold dictionary, compressed pages, and data page buffers. + let estimated = batch_bytes * 3; + let writer_arc = writer_arc.clone(); + state.reservation.reserve_estimated(estimated)?; let mut writer = writer_arc.lock().unwrap(); + let before = writer.memory_size(); writer.write(&record_batch)?; + // Reconcile: adjust reservation to actual delta reported by ArrowWriter + let actual = writer.memory_size().saturating_sub(before); + drop(writer); + state.reservation.reconcile(estimated, actual); } } Ok(()) @@ -481,18 +564,33 @@ impl NativeParquetWriter { Err("Writer not found".into()) } } else { - log_error!("ERROR: Array is not a StructArray, type: {:?}", array.data_type()); + log_error!( + "ERROR: Array is not a StructArray, type: {:?}", + array.data_type() + ); Err("Expected struct array from VectorSchemaRoot".into()) } } } - pub fn finalize_writer(filename: String) -> Result, Box> { + pub fn finalize_writer( + filename: String, + ) -> Result, Box> { let temp_filename = Self::temp_filename(&filename); - log_debug!("finalize_writer called for file: {} (temp: {})", filename, temp_filename); + log_debug!( + "finalize_writer called for file: {} (temp: {})", + filename, + temp_filename + ); if let Some((_, state)) = WRITERS.remove(&temp_filename) { - let WriterState { variant, settings, crc_handle, writer_generation } = state; + let WriterState { + variant, + settings, + crc_handle, + writer_generation, + mut reservation, + } = state; let index_name = settings.index_name.as_deref().unwrap_or(""); match variant { @@ -502,16 +600,25 @@ impl NativeParquetWriter { let chunked_writer = mutex.into_inner().unwrap(); let total_rows = chunked_writer.total_rows(); let schema = chunked_writer.schema.clone(); - let (chunk_paths, chunk_row_ids, chunk_crcs) = chunked_writer.finish()?; + let (chunk_paths, chunk_row_ids, chunk_crcs) = + chunked_writer.finish(&mut reservation)?; log_info!( "Successfully closed sorting chunked writer for: {}, total_rows={}, chunks={}", temp_filename, total_rows, chunk_paths.len() ); let (crc32, row_id_mapping) = Self::finalize_sorted_chunks( - &chunk_paths, &chunk_row_ids, &chunk_crcs, &filename, index_name, - &settings.sort_columns, &settings.reverse_sorts, &settings.nulls_first, - writer_generation, schema.clone(), + &chunk_paths, + &chunk_row_ids, + &chunk_crcs, + &filename, + index_name, + &settings.sort_columns, + &settings.reverse_sorts, + &settings.nulls_first, + writer_generation, + schema.clone(), + &mut reservation, )?; // Clean up sorted chunk files only after successful finalization. @@ -526,10 +633,23 @@ impl NativeParquetWriter { let reader = SerializedFileReader::new(file)?; let parquet_metadata = reader.metadata().clone(); - Ok(Some(FinalizeResult { metadata: parquet_metadata, crc32, row_id_mapping })) + // Detach mapping from reservation before handing to FFI/Java. + // FFI layer will track it via write_pool().grow/shrink. + if let Some(ref mapping) = row_id_mapping { + reservation.shrink(mapping.len() * std::mem::size_of::()); + } + + Ok(Some(FinalizeResult { + metadata: parquet_metadata, + crc32, + row_id_mapping, + })) } Err(_) => { - log_error!("ERROR: IPC Writer still in use for temp file: {}", temp_filename); + log_error!( + "ERROR: IPC Writer still in use for temp file: {}", + temp_filename + ); Err("IPC Writer still in use".into()) } } @@ -541,7 +661,10 @@ impl NativeParquetWriter { match writer.close() { Ok(_) => { let crc32 = crc_handle.map(|h| h.crc32()).unwrap_or(0); - log_info!("Successfully closed temp writer for: {}", temp_filename); + log_info!( + "Successfully closed temp writer for: {}", + temp_filename + ); // Parquet variant is used for non-sorted data; just rename. std::fs::rename(&temp_filename, &filename)?; @@ -552,16 +675,26 @@ impl NativeParquetWriter { let reader = SerializedFileReader::new(file)?; let parquet_metadata = reader.metadata().clone(); - Ok(Some(FinalizeResult { metadata: parquet_metadata, crc32, row_id_mapping: None })) + Ok(Some(FinalizeResult { + metadata: parquet_metadata, + crc32, + row_id_mapping: None, + })) } Err(e) => { - log_error!("ERROR: Failed to close writer for temp file: {}", temp_filename); + log_error!( + "ERROR: Failed to close writer for temp file: {}", + temp_filename + ); Err(e.into()) } } } Err(_) => { - log_error!("ERROR: Writer still in use for temp file: {}", temp_filename); + log_error!( + "ERROR: Writer still in use for temp file: {}", + temp_filename + ); Err("Writer still in use".into()) } } @@ -587,6 +720,7 @@ impl NativeParquetWriter { nulls_first: &[bool], writer_generation: i64, schema: Arc, + reservation: &mut MemoryReservation, ) -> Result<(u32, Option>), Box> { if chunk_paths.is_empty() { log_info!("finalize_sorted_chunks: no chunks, writing empty Parquet file"); @@ -594,8 +728,12 @@ impl NativeParquetWriter { .get(index_name) .map(|r| r.clone()) .unwrap_or_default(); - let props = WriterPropertiesBuilder::build_with_generation(&config, Some(writer_generation), &schema) - .map_err(|e| format!("Invalid encoding/compression config: {}", e))?; + let props = WriterPropertiesBuilder::build_with_generation( + &config, + Some(writer_generation), + &schema, + ) + .map_err(|e| format!("Invalid encoding/compression config: {}", e))?; let file = File::create(output_filename)?; let writer = ArrowWriter::try_new(file, schema, Some(props))?; writer.close()?; @@ -614,6 +752,9 @@ impl NativeParquetWriter { let row_id_mapping = if !chunk_row_ids.is_empty() && !chunk_row_ids[0].is_empty() { let ids = &chunk_row_ids[0]; let total = ids.len(); + let mapping_bytes = total * std::mem::size_of::(); + // Reserve for permutation Vec before allocating + reservation.request(mapping_bytes)?; let mut mapping = vec![0i64; total]; for (new_pos, &old_row_id) in ids.iter().enumerate() { let orig_idx = old_row_id as usize; @@ -633,10 +774,16 @@ impl NativeParquetWriter { let overall_start = std::time::Instant::now(); log_info!( "finalize_sorted_chunks: merging {} pre-sorted chunks for {}", - chunk_paths.len(), output_filename + chunk_paths.len(), + output_filename ); - let merge_output = merge_sorted( + let mut merge_reservation = MemoryReservation::new( + write_pool(), + "writer:k_way_merge", + PoolBehavior::IgnoreLimit, + ); + let merge_output = merge_sorted_with_pool( chunk_paths, output_filename, index_name, @@ -644,6 +791,7 @@ impl NativeParquetWriter { reverse_sorts, nulls_first, writer_generation, + &mut merge_reservation, ) .map_err(|e| -> Box { format!("Streaming merge failed: {}", e).into() @@ -651,12 +799,17 @@ impl NativeParquetWriter { let merge_duration = overall_start.elapsed(); log_info!( "finalize_sorted_chunks: k-way merge complete: {} chunks merged, duration={:?}", - chunk_paths.len(), merge_duration + chunk_paths.len(), + merge_duration ); // Build the flat permutation: result[original_row_id] = new_row_id + let crc32 = merge_output.crc32; let row_id_mapping = if !merge_output.mapping.is_empty() && !chunk_row_ids.is_empty() { let total = merge_output.mapping.len(); + let mapping_bytes = total * std::mem::size_of::(); + // Reserve 2× mapping: merge_output.mapping (alive) + flat_mapping (about to allocate) + reservation.request(mapping_bytes * 2)?; let mut flat_mapping = vec![0i64; total]; for i in 0..total { flat_mapping[i] = i as i64; @@ -671,7 +824,14 @@ impl NativeParquetWriter { pos += 1; } } - log_info!("finalize_sorted_chunks: produced {} permutation entries for {}", flat_mapping.len(), output_filename); + drop(merge_output); + // merge_output.mapping freed — release its share, flat_mapping remains tracked + reservation.shrink(mapping_bytes); + log_info!( + "finalize_sorted_chunks: produced {} permutation entries for {}", + flat_mapping.len(), + output_filename + ); Some(flat_mapping) } else { None @@ -679,9 +839,11 @@ impl NativeParquetWriter { log_info!( "finalize_sorted_chunks: DONE file={}, chunks={}, merge_duration={:?}", - output_filename, chunk_paths.len(), merge_duration + output_filename, + chunk_paths.len(), + merge_duration ); - Ok((merge_output.crc32, row_id_mapping)) + Ok((crc32, row_id_mapping)) } /// Sort a batch using RowConverter: converts sort columns into compact @@ -697,7 +859,9 @@ impl NativeParquetWriter { .iter() .enumerate() .map(|(i, col_name)| { - let col_index = batch.schema().index_of(col_name) + let col_index = batch + .schema() + .index_of(col_name) .map_err(|_| format!("Sort column '{}' not found in schema", col_name))?; let data_type = batch.schema().field(col_index).data_type().clone(); let options = arrow::compute::SortOptions { @@ -743,46 +907,48 @@ impl NativeParquetWriter { .get(index_name) .map(|r| r.clone()) .unwrap_or_default(); - let props = WriterPropertiesBuilder::build_with_generation(&config, writer_generation, &schema) - .map_err(|e| format!("Invalid encoding/compression config: {}", e))?; + let props = + WriterPropertiesBuilder::build_with_generation(&config, writer_generation, &schema) + .map_err(|e| format!("Invalid encoding/compression config: {}", e))?; let file = File::create(output_filename)?; let (crc_file, crc_handle) = CrcWriter::new(file); let mut writer = ArrowWriter::try_new(crc_file, schema, Some(props))?; writer.write(batch)?; writer.close()?; let crc32 = crc_handle.crc32(); - log_debug!("Successfully wrote final file: {} (crc32={:#010x})", output_filename, crc32); + log_debug!( + "Successfully wrote final file: {} (crc32={:#010x})", + output_filename, + crc32 + ); Ok(crc32) } - - pub fn get_filtered_writer_memory_usage(path_prefix: String) -> Result> { + pub fn get_filtered_writer_memory_usage( + path_prefix: String, + ) -> Result> { let mut total_memory = 0; for entry in WRITERS.iter() { if entry.key().starts_with(&path_prefix) { - match &entry.value().variant { - WriterVariant::Parquet(writer_arc) => { - if let Ok(writer) = writer_arc.lock() { - total_memory += writer.memory_size(); - } - } - WriterVariant::Ipc(writer_arc) => { - if let Ok(writer) = writer_arc.lock() { - total_memory += writer.memory_size(); - } - } - } + total_memory += entry.value().reservation.size(); } } Ok(total_memory) } - pub fn get_file_metadata(filename: String) -> Result> { + pub fn get_file_metadata( + filename: String, + ) -> Result> { let file = File::open(&filename)?; let reader = SerializedFileReader::new(file)?; let metadata = reader.metadata().clone(); - log_debug!("Metadata for {}: version={}, num_rows={}, num_row_groups={}", - filename, metadata.file_metadata().version(), metadata.file_metadata().num_rows(), metadata.num_row_groups()); + log_debug!( + "Metadata for {}: version={}, num_rows={}, num_row_groups={}", + filename, + metadata.file_metadata().version(), + metadata.file_metadata().num_rows(), + metadata.num_row_groups() + ); Ok(metadata) } } diff --git a/sandbox/plugins/parquet-data-format/src/main/rust/src/writer_properties_builder.rs b/sandbox/plugins/parquet-data-format/src/main/rust/src/writer_properties_builder.rs index 8679951847a28..1e8b29de8463c 100644 --- a/sandbox/plugins/parquet-data-format/src/main/rust/src/writer_properties_builder.rs +++ b/sandbox/plugins/parquet-data-format/src/main/rust/src/writer_properties_builder.rs @@ -6,7 +6,7 @@ * compatible open source license. */ -use parquet::basic::{Compression, Encoding, ZstdLevel, GzipLevel, BrotliLevel}; +use parquet::basic::{BrotliLevel, Compression, Encoding, GzipLevel, ZstdLevel}; use parquet::file::metadata::{FileMetaData, KeyValue}; use parquet::file::properties::WriterProperties; @@ -102,12 +102,19 @@ impl WriterPropertiesBuilder { /// # Returns /// /// A fully configured WriterProperties instance - pub fn build(config: &NativeSettings, schema: &ArrowSchema) -> Result { + pub fn build( + config: &NativeSettings, + schema: &ArrowSchema, + ) -> Result { Self::build_with_generation(config, None, schema) } /// Builds WriterProperties with an optional writer generation stored as key-value metadata. - pub fn build_with_generation(config: &NativeSettings, writer_generation: Option, schema: &ArrowSchema) -> Result { + pub fn build_with_generation( + config: &NativeSettings, + writer_generation: Option, + schema: &ArrowSchema, + ) -> Result { let mut builder = WriterProperties::builder(); // Apply compression settings @@ -127,9 +134,10 @@ impl WriterPropertiesBuilder { // Build the full KV metadata vector. Format version is always stamped; writer // generation is stamped only when provided. - let mut kv_metadata = vec![ - KeyValue::new(FORMAT_VERSION_KEY.to_string(), Some(FORMAT_VERSION.to_string())), - ]; + let mut kv_metadata = vec![KeyValue::new( + FORMAT_VERSION_KEY.to_string(), + Some(FORMAT_VERSION.to_string()), + )]; if let Some(gen) = writer_generation { kv_metadata.push(KeyValue::new( WRITER_GENERATION_KEY.to_string(), @@ -144,11 +152,11 @@ impl WriterPropertiesBuilder { /// Applies compression settings to the builder. fn apply_compression_settings( builder: parquet::file::properties::WriterPropertiesBuilder, - config: &NativeSettings + config: &NativeSettings, ) -> Result { let compression = Self::parse_compression_type( config.get_compression_type(), - config.get_compression_level() + config.get_compression_level(), )?; Ok(builder.set_compression(compression)) } @@ -156,7 +164,7 @@ impl WriterPropertiesBuilder { /// Applies page size and row limit settings. fn apply_page_settings( builder: parquet::file::properties::WriterPropertiesBuilder, - config: &NativeSettings + config: &NativeSettings, ) -> parquet::file::properties::WriterPropertiesBuilder { builder .set_data_page_size_limit(config.get_page_size_bytes()) @@ -166,7 +174,7 @@ impl WriterPropertiesBuilder { /// Applies row group row count and byte size limits. fn apply_row_group_settings( builder: parquet::file::properties::WriterPropertiesBuilder, - config: &NativeSettings + config: &NativeSettings, ) -> parquet::file::properties::WriterPropertiesBuilder { builder .set_max_row_group_row_count(Some(config.get_row_group_max_rows())) @@ -176,7 +184,7 @@ impl WriterPropertiesBuilder { /// Applies dictionary encoding settings. fn apply_dictionary_settings( builder: parquet::file::properties::WriterPropertiesBuilder, - config: &NativeSettings + config: &NativeSettings, ) -> parquet::file::properties::WriterPropertiesBuilder { builder.set_dictionary_page_size_limit(config.get_dict_size_bytes()) } @@ -189,115 +197,167 @@ impl WriterPropertiesBuilder { schema: &ArrowSchema, ) -> Result { let type_map: std::collections::HashMap<&str, (&arrow::datatypes::DataType, String)> = - schema.fields().iter() - .map(|f| (f.name().as_str(), (f.data_type(), arrow_type_key(f.data_type())))) + schema + .fields() + .iter() + .map(|f| { + ( + f.name().as_str(), + (f.data_type(), arrow_type_key(f.data_type())), + ) + }) .collect(); let mut field_names: std::collections::HashSet<&str> = std::collections::HashSet::new(); if let Some(fc) = &config.field_configs { - for name in fc.keys() { field_names.insert(name.as_str()); } + for name in fc.keys() { + field_names.insert(name.as_str()); + } + } + for f in schema.fields() { + field_names.insert(f.name().as_str()); } - for f in schema.fields() { field_names.insert(f.name().as_str()); } for field_name in field_names { - let index_cfg = config.field_configs.as_ref().and_then(|m| m.get(field_name)); + let index_cfg = config + .field_configs + .as_ref() + .and_then(|m| m.get(field_name)); let (arrow_type, type_key) = match type_map.get(field_name) { Some(v) => (v.0, Some(v.1.as_str())), - None => return Err(format!( - "Field '{}' in field_configs does not exist in schema", field_name - )), + None => { + return Err(format!( + "Field '{}' in field_configs does not exist in schema", + field_name + )) + } }; // Encoding: parse once, validate, apply. Skip if nothing set. - let encoding: Option = if let Some(enc) = index_cfg.and_then(|fc| fc.encoding_type.as_deref()) { - let parsed = Self::parse_encoding_type(enc)?; - if !Self::is_encoding_valid_for_type(parsed, arrow_type) { - return Err(format!( - "Encoding '{}' is not supported for field '{}' of type '{:?}'", - enc, field_name, arrow_type - )); - } - Some(parsed) - } else if let Some(enc) = type_key.and_then(|t| config.type_encoding_configs.as_ref()?.get(t).map(|s| s.as_str())) { - let parsed = Self::parse_encoding_type(enc)?; - if !Self::is_encoding_valid_for_type(parsed, arrow_type) { - return Err(format!( + let encoding: Option = + if let Some(enc) = index_cfg.and_then(|fc| fc.encoding_type.as_deref()) { + let parsed = Self::parse_encoding_type(enc)?; + if !Self::is_encoding_valid_for_type(parsed, arrow_type) { + return Err(format!( + "Encoding '{}' is not supported for field '{}' of type '{:?}'", + enc, field_name, arrow_type + )); + } + Some(parsed) + } else if let Some(enc) = type_key.and_then(|t| { + config + .type_encoding_configs + .as_ref()? + .get(t) + .map(|s| s.as_str()) + }) { + let parsed = Self::parse_encoding_type(enc)?; + if !Self::is_encoding_valid_for_type(parsed, arrow_type) { + return Err(format!( "Cluster-level encoding '{}' is not supported for type '{:?}' (field '{}')", enc, arrow_type, field_name )); - } - Some(parsed) - } else { - // Metadata field defaults by name, then type-based defaults - match field_name { - "__row_id__" | "_seq_no" | "_primary_term" | "_version" => Some(Encoding::DELTA_BINARY_PACKED), - "_id" => Some(Encoding::PLAIN), - _ => match type_key { - Some("timestamp") => Some(Encoding::DELTA_BINARY_PACKED), - _ => None, - }, - } - }; + } + Some(parsed) + } else { + // Metadata field defaults by name, then type-based defaults + match field_name { + "__row_id__" | "_seq_no" | "_primary_term" | "_version" => { + Some(Encoding::DELTA_BINARY_PACKED) + } + "_id" => Some(Encoding::PLAIN), + _ => match type_key { + Some("timestamp") => Some(Encoding::DELTA_BINARY_PACKED), + _ => None, + }, + } + }; if let Some(enc) = encoding { - if matches!(enc, Encoding::DELTA_BINARY_PACKED | Encoding::DELTA_BYTE_ARRAY - | Encoding::DELTA_LENGTH_BYTE_ARRAY | Encoding::BYTE_STREAM_SPLIT - | Encoding::RLE) { - builder = builder.set_column_dictionary_enabled( - field_name.to_string().into(), false - ); + if matches!( + enc, + Encoding::DELTA_BINARY_PACKED + | Encoding::DELTA_BYTE_ARRAY + | Encoding::DELTA_LENGTH_BYTE_ARRAY + | Encoding::BYTE_STREAM_SPLIT + | Encoding::RLE + ) { + builder = + builder.set_column_dictionary_enabled(field_name.to_string().into(), false); builder = builder.set_column_encoding(field_name.to_string().into(), enc); } else if matches!(enc, Encoding::RLE_DICTIONARY) { // RLE_DICTIONARY means use dictionary encoding - just ensure it's enabled - builder = builder.set_column_dictionary_enabled( - field_name.to_string().into(), true - ); + builder = + builder.set_column_dictionary_enabled(field_name.to_string().into(), true); } else { // PLAIN: explicitly disable dictionary so the writer uses plain encoding - builder = builder.set_column_dictionary_enabled( - field_name.to_string().into(), false - ); + builder = + builder.set_column_dictionary_enabled(field_name.to_string().into(), false); builder = builder.set_column_encoding(field_name.to_string().into(), enc); } } // Compression: parse once, apply. No type restriction. let level = index_cfg.and_then(|fc| fc.compression_level).unwrap_or(3); - let compression: Option = if let Some(comp) = index_cfg.and_then(|fc| fc.compression_type.as_deref()) { - Some(Self::parse_compression_type(comp, level)?) - } else if let Some(comp) = type_key.and_then(|t| config.type_compression_configs.as_ref()?.get(t).map(|s| s.as_str())) { - Some(Self::parse_compression_type(comp, level)?) - } else { - // Metadata field defaults by name, then type-based defaults - match field_name { - "_primary_term" | "_version" => Some(Compression::UNCOMPRESSED), - "_id" => Some(Self::parse_compression_type("LZ4_RAW", 0)?), - _ => match type_key { - Some("utf8") | Some("binary") => Some(Self::parse_compression_type("ZSTD", 3)?), - _ => None, - }, - } - }; + let compression: Option = + if let Some(comp) = index_cfg.and_then(|fc| fc.compression_type.as_deref()) { + Some(Self::parse_compression_type(comp, level)?) + } else if let Some(comp) = type_key.and_then(|t| { + config + .type_compression_configs + .as_ref()? + .get(t) + .map(|s| s.as_str()) + }) { + Some(Self::parse_compression_type(comp, level)?) + } else { + // Metadata field defaults by name, then type-based defaults + match field_name { + "_primary_term" | "_version" => Some(Compression::UNCOMPRESSED), + "_id" => Some(Self::parse_compression_type("LZ4_RAW", 0)?), + _ => match type_key { + Some("utf8") | Some("binary") => { + Some(Self::parse_compression_type("ZSTD", 3)?) + } + _ => None, + }, + } + }; if let Some(comp) = compression { builder = builder.set_column_compression(field_name.to_string().into(), comp); } // Bloom filter: field-level > type-level > global. Applied per-column. - let bf_enabled = index_cfg.and_then(|fc| fc.bloom_filter_enabled) - .or_else(|| type_key.and_then(|t| config.type_bloom_filter_enabled.as_ref()?.get(t).copied())) + let bf_enabled = index_cfg + .and_then(|fc| fc.bloom_filter_enabled) + .or_else(|| { + type_key + .and_then(|t| config.type_bloom_filter_enabled.as_ref()?.get(t).copied()) + }) .unwrap_or(config.get_bloom_filter_enabled()); if bf_enabled { - builder = builder.set_column_bloom_filter_enabled(field_name.to_string().into(), true); - let bf_fpp = index_cfg.and_then(|fc| fc.bloom_filter_fpp) - .or_else(|| type_key.and_then(|t| config.type_bloom_filter_fpp.as_ref()?.get(t).copied())) + builder = + builder.set_column_bloom_filter_enabled(field_name.to_string().into(), true); + let bf_fpp = index_cfg + .and_then(|fc| fc.bloom_filter_fpp) + .or_else(|| { + type_key + .and_then(|t| config.type_bloom_filter_fpp.as_ref()?.get(t).copied()) + }) .unwrap_or(config.get_bloom_filter_fpp()); - builder = builder.set_column_bloom_filter_fpp(field_name.to_string().into(), bf_fpp); - let bf_ndv = index_cfg.and_then(|fc| fc.bloom_filter_ndv) - .or_else(|| type_key.and_then(|t| config.type_bloom_filter_ndv.as_ref()?.get(t).copied())) + builder = + builder.set_column_bloom_filter_fpp(field_name.to_string().into(), bf_fpp); + let bf_ndv = index_cfg + .and_then(|fc| fc.bloom_filter_ndv) + .or_else(|| { + type_key + .and_then(|t| config.type_bloom_filter_ndv.as_ref()?.get(t).copied()) + }) .unwrap_or(config.get_bloom_filter_ndv()); - builder = builder.set_column_bloom_filter_ndv(field_name.to_string().into(), bf_ndv); + builder = + builder.set_column_bloom_filter_ndv(field_name.to_string().into(), bf_ndv); } } Ok(builder) @@ -309,17 +369,31 @@ impl WriterPropertiesBuilder { Encoding::PLAIN => true, Encoding::RLE_DICTIONARY => !matches!(dt, Boolean), Encoding::RLE => matches!(dt, Boolean), - Encoding::DELTA_BINARY_PACKED => matches!(dt, - Int8 | Int16 | Int32 | Int64 | UInt8 | UInt16 | UInt32 | UInt64 - | Timestamp(_, _) | Date32 | Date64 | Time32(_) | Time64(_) | Duration(_) - ), - Encoding::DELTA_LENGTH_BYTE_ARRAY => matches!(dt, - Utf8 | LargeUtf8 | Binary | LargeBinary + Encoding::DELTA_BINARY_PACKED => matches!( + dt, + Int8 | Int16 + | Int32 + | Int64 + | UInt8 + | UInt16 + | UInt32 + | UInt64 + | Timestamp(_, _) + | Date32 + | Date64 + | Time32(_) + | Time64(_) + | Duration(_) ), - Encoding::DELTA_BYTE_ARRAY => matches!(dt, + Encoding::DELTA_LENGTH_BYTE_ARRAY => { + matches!(dt, Utf8 | LargeUtf8 | Binary | LargeBinary) + } + Encoding::DELTA_BYTE_ARRAY => matches!( + dt, Utf8 | LargeUtf8 | Binary | LargeBinary | FixedSizeBinary(_) ), - Encoding::BYTE_STREAM_SPLIT => matches!(dt, + Encoding::BYTE_STREAM_SPLIT => matches!( + dt, Float32 | Float64 | Int32 | Int64 | UInt32 | UInt64 | FixedSizeBinary(_) ), _ => true, @@ -353,14 +427,14 @@ impl WriterPropertiesBuilder { fn parse_compression_type(compression_type: &str, level: i32) -> Result { match compression_type.to_uppercase().as_str() { "ZSTD" => Ok(Compression::ZSTD( - ZstdLevel::try_new(level).unwrap_or(ZstdLevel::default()) + ZstdLevel::try_new(level).unwrap_or(ZstdLevel::default()), )), "SNAPPY" => Ok(Compression::SNAPPY), "GZIP" => Ok(Compression::GZIP( - GzipLevel::try_new(level as u32).unwrap_or_default() + GzipLevel::try_new(level as u32).unwrap_or_default(), )), "BROTLI" => Ok(Compression::BROTLI( - BrotliLevel::try_new(level as u32).unwrap_or_default() + BrotliLevel::try_new(level as u32).unwrap_or_default(), )), "LZ4_RAW" => Ok(Compression::LZ4_RAW), "UNCOMPRESSED" => Ok(Compression::UNCOMPRESSED), @@ -372,19 +446,22 @@ impl WriterPropertiesBuilder { #[cfg(test)] mod tests { use super::*; - use crate::native_settings::NativeSettings; use crate::field_config::FieldConfig; + use crate::native_settings::NativeSettings; + use arrow::datatypes::{DataType as ArrowDataType, Field, Schema as ArrowSchema}; use std::collections::HashMap; - use arrow::datatypes::{Field, Schema as ArrowSchema, DataType as ArrowDataType}; fn empty_schema() -> ArrowSchema { ArrowSchema::new(Vec::::new()) } fn schema_with(fields: Vec<(&str, ArrowDataType)>) -> ArrowSchema { - ArrowSchema::new(fields.into_iter() - .map(|(name, dt)| Field::new(name, dt, true)) - .collect::>()) + ArrowSchema::new( + fields + .into_iter() + .map(|(name, dt)| Field::new(name, dt, true)) + .collect::>(), + ) } #[test] @@ -395,17 +472,38 @@ mod tests { ..Default::default() }; let props = WriterPropertiesBuilder::build(&config, &empty_schema()).unwrap(); - assert_ne!(props.compression(&parquet::schema::types::ColumnPath::from("test")), Compression::UNCOMPRESSED); + assert_ne!( + props.compression(&parquet::schema::types::ColumnPath::from("test")), + Compression::UNCOMPRESSED + ); } #[test] fn test_parse_compression_types() { - assert!(matches!(WriterPropertiesBuilder::parse_compression_type("ZSTD", 3).unwrap(), Compression::ZSTD(_))); - assert!(matches!(WriterPropertiesBuilder::parse_compression_type("SNAPPY", 0).unwrap(), Compression::SNAPPY)); - assert!(matches!(WriterPropertiesBuilder::parse_compression_type("GZIP", 6).unwrap(), Compression::GZIP(_))); - assert!(matches!(WriterPropertiesBuilder::parse_compression_type("LZ4_RAW", 0).unwrap(), Compression::LZ4_RAW)); - assert!(matches!(WriterPropertiesBuilder::parse_compression_type("BROTLI", 3).unwrap(), Compression::BROTLI(_))); - assert!(matches!(WriterPropertiesBuilder::parse_compression_type("UNCOMPRESSED", 0).unwrap(), Compression::UNCOMPRESSED)); + assert!(matches!( + WriterPropertiesBuilder::parse_compression_type("ZSTD", 3).unwrap(), + Compression::ZSTD(_) + )); + assert!(matches!( + WriterPropertiesBuilder::parse_compression_type("SNAPPY", 0).unwrap(), + Compression::SNAPPY + )); + assert!(matches!( + WriterPropertiesBuilder::parse_compression_type("GZIP", 6).unwrap(), + Compression::GZIP(_) + )); + assert!(matches!( + WriterPropertiesBuilder::parse_compression_type("LZ4_RAW", 0).unwrap(), + Compression::LZ4_RAW + )); + assert!(matches!( + WriterPropertiesBuilder::parse_compression_type("BROTLI", 3).unwrap(), + Compression::BROTLI(_) + )); + assert!(matches!( + WriterPropertiesBuilder::parse_compression_type("UNCOMPRESSED", 0).unwrap(), + Compression::UNCOMPRESSED + )); // LZ4 (framed) is deprecated per Parquet spec - use LZ4_RAW instead assert!(WriterPropertiesBuilder::parse_compression_type("LZ4", 0).is_err()); assert!(WriterPropertiesBuilder::parse_compression_type("SNPPY", 0).is_err()); @@ -414,11 +512,26 @@ mod tests { #[test] fn test_parse_encoding_types() { - assert_eq!(WriterPropertiesBuilder::parse_encoding_type("PLAIN").unwrap(), Encoding::PLAIN); - assert_eq!(WriterPropertiesBuilder::parse_encoding_type("DELTA_BINARY_PACKED").unwrap(), Encoding::DELTA_BINARY_PACKED); - assert_eq!(WriterPropertiesBuilder::parse_encoding_type("DELTA").unwrap(), Encoding::DELTA_BINARY_PACKED); - assert_eq!(WriterPropertiesBuilder::parse_encoding_type("RLE_DICTIONARY").unwrap(), Encoding::RLE_DICTIONARY); - assert_eq!(WriterPropertiesBuilder::parse_encoding_type("DICTIONARY").unwrap(), Encoding::RLE_DICTIONARY); + assert_eq!( + WriterPropertiesBuilder::parse_encoding_type("PLAIN").unwrap(), + Encoding::PLAIN + ); + assert_eq!( + WriterPropertiesBuilder::parse_encoding_type("DELTA_BINARY_PACKED").unwrap(), + Encoding::DELTA_BINARY_PACKED + ); + assert_eq!( + WriterPropertiesBuilder::parse_encoding_type("DELTA").unwrap(), + Encoding::DELTA_BINARY_PACKED + ); + assert_eq!( + WriterPropertiesBuilder::parse_encoding_type("RLE_DICTIONARY").unwrap(), + Encoding::RLE_DICTIONARY + ); + assert_eq!( + WriterPropertiesBuilder::parse_encoding_type("DICTIONARY").unwrap(), + Encoding::RLE_DICTIONARY + ); assert!(WriterPropertiesBuilder::parse_encoding_type("DELTA_BINRY_PACKED").is_err()); assert!(WriterPropertiesBuilder::parse_encoding_type("unknown").is_err()); } @@ -426,10 +539,13 @@ mod tests { #[test] fn test_field_encoding_applied() { let mut field_configs = HashMap::new(); - field_configs.insert("row_id".to_string(), FieldConfig { - encoding_type: Some("DELTA_BINARY_PACKED".to_string()), - ..Default::default() - }); + field_configs.insert( + "row_id".to_string(), + FieldConfig { + encoding_type: Some("DELTA_BINARY_PACKED".to_string()), + ..Default::default() + }, + ); let config = NativeSettings { field_configs: Some(field_configs), ..Default::default() @@ -437,16 +553,22 @@ mod tests { let schema = schema_with(vec![("row_id", ArrowDataType::Int64)]); let props = WriterPropertiesBuilder::build(&config, &schema).unwrap(); let col_path = parquet::schema::types::ColumnPath::from("row_id"); - assert_eq!(props.encoding(&col_path), Some(Encoding::DELTA_BINARY_PACKED)); + assert_eq!( + props.encoding(&col_path), + Some(Encoding::DELTA_BINARY_PACKED) + ); } #[test] fn test_field_level_compression_applied() { let mut field_configs = HashMap::new(); - field_configs.insert("timestamp".to_string(), FieldConfig { - compression_type: Some("SNAPPY".to_string()), - ..Default::default() - }); + field_configs.insert( + "timestamp".to_string(), + FieldConfig { + compression_type: Some("SNAPPY".to_string()), + ..Default::default() + }, + ); let config = NativeSettings { field_configs: Some(field_configs), ..Default::default() @@ -454,17 +576,23 @@ mod tests { let schema = schema_with(vec![("timestamp", ArrowDataType::Int64)]); let props = WriterPropertiesBuilder::build(&config, &schema).unwrap(); let col_path = parquet::schema::types::ColumnPath::from("timestamp"); - assert!(matches!(props.compression(&col_path), Compression::SNAPPY), - "expected SNAPPY but got {:?}", props.compression(&col_path)); + assert!( + matches!(props.compression(&col_path), Compression::SNAPPY), + "expected SNAPPY but got {:?}", + props.compression(&col_path) + ); } #[test] fn test_unknown_field_in_field_configs_returns_error() { let mut field_configs = HashMap::new(); - field_configs.insert("nonexistent".to_string(), FieldConfig { - encoding_type: Some("PLAIN".to_string()), - ..Default::default() - }); + field_configs.insert( + "nonexistent".to_string(), + FieldConfig { + encoding_type: Some("PLAIN".to_string()), + ..Default::default() + }, + ); let config = NativeSettings { field_configs: Some(field_configs), ..Default::default() @@ -478,10 +606,13 @@ mod tests { #[test] fn test_invalid_encoding_for_type_returns_error() { let mut field_configs = HashMap::new(); - field_configs.insert("name".to_string(), FieldConfig { - encoding_type: Some("DELTA_BINARY_PACKED".to_string()), // invalid for utf8 - ..Default::default() - }); + field_configs.insert( + "name".to_string(), + FieldConfig { + encoding_type: Some("DELTA_BINARY_PACKED".to_string()), // invalid for utf8 + ..Default::default() + }, + ); let config = NativeSettings { field_configs: Some(field_configs), ..Default::default() @@ -509,10 +640,13 @@ mod tests { #[test] fn test_valid_encoding_for_type_succeeds() { let mut field_configs = HashMap::new(); - field_configs.insert("name".to_string(), FieldConfig { - encoding_type: Some("DELTA_BYTE_ARRAY".to_string()), // valid for utf8 - ..Default::default() - }); + field_configs.insert( + "name".to_string(), + FieldConfig { + encoding_type: Some("DELTA_BYTE_ARRAY".to_string()), // valid for utf8 + ..Default::default() + }, + ); let config = NativeSettings { field_configs: Some(field_configs), ..Default::default() @@ -524,22 +658,34 @@ mod tests { #[test] fn test_rle_valid_only_for_boolean() { let mut field_configs = HashMap::new(); - field_configs.insert("flag".to_string(), FieldConfig { - encoding_type: Some("RLE".to_string()), + field_configs.insert( + "flag".to_string(), + FieldConfig { + encoding_type: Some("RLE".to_string()), + ..Default::default() + }, + ); + let config = NativeSettings { + field_configs: Some(field_configs.clone()), ..Default::default() - }); - let config = NativeSettings { field_configs: Some(field_configs.clone()), ..Default::default() }; + }; // valid for boolean let schema = schema_with(vec![("flag", ArrowDataType::Boolean)]); assert!(WriterPropertiesBuilder::build(&config, &schema).is_ok()); // invalid for int32 - field_configs.insert("flag".to_string(), FieldConfig { - encoding_type: Some("RLE".to_string()), + field_configs.insert( + "flag".to_string(), + FieldConfig { + encoding_type: Some("RLE".to_string()), + ..Default::default() + }, + ); + let config2 = NativeSettings { + field_configs: Some(field_configs), ..Default::default() - }); - let config2 = NativeSettings { field_configs: Some(field_configs), ..Default::default() }; + }; let schema2 = schema_with(vec![("flag", ArrowDataType::Int32)]); assert!(WriterPropertiesBuilder::build(&config2, &schema2).is_err()); } @@ -547,11 +693,17 @@ mod tests { #[test] fn test_rle_disables_dictionary_for_boolean() { let mut field_configs = HashMap::new(); - field_configs.insert("flag".to_string(), FieldConfig { - encoding_type: Some("RLE".to_string()), + field_configs.insert( + "flag".to_string(), + FieldConfig { + encoding_type: Some("RLE".to_string()), + ..Default::default() + }, + ); + let config = NativeSettings { + field_configs: Some(field_configs), ..Default::default() - }); - let config = NativeSettings { field_configs: Some(field_configs), ..Default::default() }; + }; let schema = schema_with(vec![("flag", ArrowDataType::Boolean)]); let props = WriterPropertiesBuilder::build(&config, &schema).unwrap(); let col_path = parquet::schema::types::ColumnPath::from("flag"); @@ -564,11 +716,17 @@ mod tests { // RLE_DICTIONARY should enable dictionary encoding, not set a column encoding // (setting it as column encoding causes parquet-rs error) let mut field_configs = HashMap::new(); - field_configs.insert("name".to_string(), FieldConfig { - encoding_type: Some("RLE_DICTIONARY".to_string()), + field_configs.insert( + "name".to_string(), + FieldConfig { + encoding_type: Some("RLE_DICTIONARY".to_string()), + ..Default::default() + }, + ); + let config = NativeSettings { + field_configs: Some(field_configs), ..Default::default() - }); - let config = NativeSettings { field_configs: Some(field_configs), ..Default::default() }; + }; let schema = schema_with(vec![("name", ArrowDataType::Utf8)]); let props = WriterPropertiesBuilder::build(&config, &schema).unwrap(); let col_path = parquet::schema::types::ColumnPath::from("name"); @@ -581,11 +739,17 @@ mod tests { #[test] fn test_dictionary_alias_enables_dictionary() { let mut field_configs = HashMap::new(); - field_configs.insert("name".to_string(), FieldConfig { - encoding_type: Some("DICTIONARY".to_string()), + field_configs.insert( + "name".to_string(), + FieldConfig { + encoding_type: Some("DICTIONARY".to_string()), + ..Default::default() + }, + ); + let config = NativeSettings { + field_configs: Some(field_configs), ..Default::default() - }); - let config = NativeSettings { field_configs: Some(field_configs), ..Default::default() }; + }; let schema = schema_with(vec![("name", ArrowDataType::Utf8)]); let props = WriterPropertiesBuilder::build(&config, &schema).unwrap(); let col_path = parquet::schema::types::ColumnPath::from("name"); @@ -596,11 +760,17 @@ mod tests { #[test] fn test_rle_dictionary_invalid_for_boolean() { let mut field_configs = HashMap::new(); - field_configs.insert("flag".to_string(), FieldConfig { - encoding_type: Some("RLE_DICTIONARY".to_string()), + field_configs.insert( + "flag".to_string(), + FieldConfig { + encoding_type: Some("RLE_DICTIONARY".to_string()), + ..Default::default() + }, + ); + let config = NativeSettings { + field_configs: Some(field_configs), ..Default::default() - }); - let config = NativeSettings { field_configs: Some(field_configs), ..Default::default() }; + }; let schema = schema_with(vec![("flag", ArrowDataType::Boolean)]); assert!(WriterPropertiesBuilder::build(&config, &schema).is_err()); } @@ -608,42 +778,65 @@ mod tests { #[test] fn test_plain_encoding_applied() { let mut field_configs = HashMap::new(); - field_configs.insert("name".to_string(), FieldConfig { - encoding_type: Some("PLAIN".to_string()), + field_configs.insert( + "name".to_string(), + FieldConfig { + encoding_type: Some("PLAIN".to_string()), + ..Default::default() + }, + ); + let config = NativeSettings { + field_configs: Some(field_configs), ..Default::default() - }); - let config = NativeSettings { field_configs: Some(field_configs), ..Default::default() }; + }; let schema = schema_with(vec![("name", ArrowDataType::Utf8)]); let props = WriterPropertiesBuilder::build(&config, &schema).unwrap(); let col_path = parquet::schema::types::ColumnPath::from("name"); assert_eq!(props.encoding(&col_path), Some(Encoding::PLAIN)); - assert!(!props.dictionary_enabled(&col_path), - "PLAIN encoding should explicitly disable dictionary"); + assert!( + !props.dictionary_enabled(&col_path), + "PLAIN encoding should explicitly disable dictionary" + ); } #[test] fn test_delta_binary_packed_disables_dictionary() { let mut field_configs = HashMap::new(); - field_configs.insert("age".to_string(), FieldConfig { - encoding_type: Some("DELTA_BINARY_PACKED".to_string()), + field_configs.insert( + "age".to_string(), + FieldConfig { + encoding_type: Some("DELTA_BINARY_PACKED".to_string()), + ..Default::default() + }, + ); + let config = NativeSettings { + field_configs: Some(field_configs), ..Default::default() - }); - let config = NativeSettings { field_configs: Some(field_configs), ..Default::default() }; + }; let schema = schema_with(vec![("age", ArrowDataType::Int32)]); let props = WriterPropertiesBuilder::build(&config, &schema).unwrap(); let col_path = parquet::schema::types::ColumnPath::from("age"); - assert_eq!(props.encoding(&col_path), Some(Encoding::DELTA_BINARY_PACKED)); + assert_eq!( + props.encoding(&col_path), + Some(Encoding::DELTA_BINARY_PACKED) + ); assert!(!props.dictionary_enabled(&col_path)); } #[test] fn test_delta_byte_array_disables_dictionary() { let mut field_configs = HashMap::new(); - field_configs.insert("name".to_string(), FieldConfig { - encoding_type: Some("DELTA_BYTE_ARRAY".to_string()), + field_configs.insert( + "name".to_string(), + FieldConfig { + encoding_type: Some("DELTA_BYTE_ARRAY".to_string()), + ..Default::default() + }, + ); + let config = NativeSettings { + field_configs: Some(field_configs), ..Default::default() - }); - let config = NativeSettings { field_configs: Some(field_configs), ..Default::default() }; + }; let schema = schema_with(vec![("name", ArrowDataType::Utf8)]); let props = WriterPropertiesBuilder::build(&config, &schema).unwrap(); let col_path = parquet::schema::types::ColumnPath::from("name"); @@ -654,26 +847,41 @@ mod tests { #[test] fn test_delta_length_byte_array_on_string() { let mut field_configs = HashMap::new(); - field_configs.insert("name".to_string(), FieldConfig { - encoding_type: Some("DELTA_LENGTH_BYTE_ARRAY".to_string()), + field_configs.insert( + "name".to_string(), + FieldConfig { + encoding_type: Some("DELTA_LENGTH_BYTE_ARRAY".to_string()), + ..Default::default() + }, + ); + let config = NativeSettings { + field_configs: Some(field_configs), ..Default::default() - }); - let config = NativeSettings { field_configs: Some(field_configs), ..Default::default() }; + }; let schema = schema_with(vec![("name", ArrowDataType::Utf8)]); let props = WriterPropertiesBuilder::build(&config, &schema).unwrap(); let col_path = parquet::schema::types::ColumnPath::from("name"); - assert_eq!(props.encoding(&col_path), Some(Encoding::DELTA_LENGTH_BYTE_ARRAY)); + assert_eq!( + props.encoding(&col_path), + Some(Encoding::DELTA_LENGTH_BYTE_ARRAY) + ); assert!(!props.dictionary_enabled(&col_path)); } #[test] fn test_byte_stream_split_on_float() { let mut field_configs = HashMap::new(); - field_configs.insert("score".to_string(), FieldConfig { - encoding_type: Some("BYTE_STREAM_SPLIT".to_string()), + field_configs.insert( + "score".to_string(), + FieldConfig { + encoding_type: Some("BYTE_STREAM_SPLIT".to_string()), + ..Default::default() + }, + ); + let config = NativeSettings { + field_configs: Some(field_configs), ..Default::default() - }); - let config = NativeSettings { field_configs: Some(field_configs), ..Default::default() }; + }; let schema = schema_with(vec![("score", ArrowDataType::Float64)]); let props = WriterPropertiesBuilder::build(&config, &schema).unwrap(); let col_path = parquet::schema::types::ColumnPath::from("score"); @@ -684,11 +892,17 @@ mod tests { #[test] fn test_byte_stream_split_invalid_for_string() { let mut field_configs = HashMap::new(); - field_configs.insert("name".to_string(), FieldConfig { - encoding_type: Some("BYTE_STREAM_SPLIT".to_string()), + field_configs.insert( + "name".to_string(), + FieldConfig { + encoding_type: Some("BYTE_STREAM_SPLIT".to_string()), + ..Default::default() + }, + ); + let config = NativeSettings { + field_configs: Some(field_configs), ..Default::default() - }); - let config = NativeSettings { field_configs: Some(field_configs), ..Default::default() }; + }; let schema = schema_with(vec![("name", ArrowDataType::Utf8)]); assert!(WriterPropertiesBuilder::build(&config, &schema).is_err()); } @@ -704,7 +918,10 @@ mod tests { let schema = schema_with(vec![("age", ArrowDataType::Int64)]); let props = WriterPropertiesBuilder::build(&config, &schema).unwrap(); let col_path = parquet::schema::types::ColumnPath::from("age"); - assert_eq!(props.encoding(&col_path), Some(Encoding::DELTA_BINARY_PACKED)); + assert_eq!( + props.encoding(&col_path), + Some(Encoding::DELTA_BINARY_PACKED) + ); assert!(!props.dictionary_enabled(&col_path)); } @@ -714,10 +931,13 @@ mod tests { let mut type_encoding = HashMap::new(); type_encoding.insert("int64".to_string(), "DELTA_BINARY_PACKED".to_string()); let mut field_configs = HashMap::new(); - field_configs.insert("age".to_string(), FieldConfig { - encoding_type: Some("PLAIN".to_string()), - ..Default::default() - }); + field_configs.insert( + "age".to_string(), + FieldConfig { + encoding_type: Some("PLAIN".to_string()), + ..Default::default() + }, + ); let config = NativeSettings { type_encoding_configs: Some(type_encoding), field_configs: Some(field_configs), @@ -748,10 +968,13 @@ mod tests { let mut type_compression = HashMap::new(); type_compression.insert("utf8".to_string(), "SNAPPY".to_string()); let mut field_configs = HashMap::new(); - field_configs.insert("name".to_string(), FieldConfig { - compression_type: Some("ZSTD".to_string()), - ..Default::default() - }); + field_configs.insert( + "name".to_string(), + FieldConfig { + compression_type: Some("ZSTD".to_string()), + ..Default::default() + }, + ); let config = NativeSettings { type_compression_configs: Some(type_compression), field_configs: Some(field_configs), @@ -791,7 +1014,10 @@ mod tests { let props = WriterPropertiesBuilder::build(&config, &schema).unwrap(); let col_path = parquet::schema::types::ColumnPath::from("test_col"); let bf_props = props.bloom_filter_properties(&col_path); - assert!(bf_props.is_some(), "bloom_filter_properties should be Some when enabled"); + assert!( + bf_props.is_some(), + "bloom_filter_properties should be Some when enabled" + ); } #[test] @@ -802,20 +1028,29 @@ mod tests { let props = WriterPropertiesBuilder::build(&config, &schema).unwrap(); let col_path = parquet::schema::types::ColumnPath::from("test_col"); let bf_props = props.bloom_filter_properties(&col_path); - assert!(bf_props.is_none(), "Default should have bloom filter disabled"); + assert!( + bf_props.is_none(), + "Default should have bloom filter disabled" + ); } #[test] fn test_column_bloom_filter_disabled_overrides_global() { let mut field_configs = HashMap::new(); - field_configs.insert("name".to_string(), FieldConfig { - bloom_filter_enabled: Some(false), - ..Default::default() - }); - field_configs.insert("value".to_string(), FieldConfig { - bloom_filter_enabled: Some(true), - ..Default::default() - }); + field_configs.insert( + "name".to_string(), + FieldConfig { + bloom_filter_enabled: Some(false), + ..Default::default() + }, + ); + field_configs.insert( + "value".to_string(), + FieldConfig { + bloom_filter_enabled: Some(true), + ..Default::default() + }, + ); let config = NativeSettings { bloom_filter_enabled: Some(true), field_configs: Some(field_configs), @@ -831,7 +1066,11 @@ mod tests { let value_path = parquet::schema::types::ColumnPath::from("value"); let name_bf = props.bloom_filter_properties(&name_path); - assert!(name_bf.is_none(), "name should have no bloom filter, got: {:?}", name_bf); + assert!( + name_bf.is_none(), + "name should have no bloom filter, got: {:?}", + name_bf + ); let value_bf = props.bloom_filter_properties(&value_path); assert!(value_bf.is_some(), "value should have bloom filter"); @@ -855,10 +1094,14 @@ mod tests { let name_path = parquet::schema::types::ColumnPath::from("name"); let age_path = parquet::schema::types::ColumnPath::from("age"); - assert!(props.bloom_filter_properties(&name_path).is_some(), - "utf8 type-level enabled should override global disabled"); - assert!(props.bloom_filter_properties(&age_path).is_none(), - "int32 has no type-level setting, should use global disabled"); + assert!( + props.bloom_filter_properties(&name_path).is_some(), + "utf8 type-level enabled should override global disabled" + ); + assert!( + props.bloom_filter_properties(&age_path).is_none(), + "int32 has no type-level setting, should use global disabled" + ); } #[test] @@ -866,10 +1109,13 @@ mod tests { let mut type_bf_enabled = HashMap::new(); type_bf_enabled.insert("utf8".to_string(), true); let mut field_configs = HashMap::new(); - field_configs.insert("name".to_string(), FieldConfig { - bloom_filter_enabled: Some(false), - ..Default::default() - }); + field_configs.insert( + "name".to_string(), + FieldConfig { + bloom_filter_enabled: Some(false), + ..Default::default() + }, + ); let config = NativeSettings { bloom_filter_enabled: Some(false), type_bloom_filter_enabled: Some(type_bf_enabled), @@ -880,8 +1126,10 @@ mod tests { let props = WriterPropertiesBuilder::build(&config, &schema).unwrap(); let name_path = parquet::schema::types::ColumnPath::from("name"); - assert!(props.bloom_filter_properties(&name_path).is_none(), - "field-level disabled should override type-level enabled"); + assert!( + props.bloom_filter_properties(&name_path).is_none(), + "field-level disabled should override type-level enabled" + ); } #[test] @@ -896,16 +1144,22 @@ mod tests { type_bf_ndv.insert("utf8".to_string(), 50_000u64); let mut field_configs = HashMap::new(); - field_configs.insert("name".to_string(), FieldConfig { - bloom_filter_enabled: Some(true), - bloom_filter_fpp: Some(0.01), - bloom_filter_ndv: Some(200_000), - ..Default::default() - }); - field_configs.insert("title".to_string(), FieldConfig { - bloom_filter_enabled: Some(true), - ..Default::default() - }); + field_configs.insert( + "name".to_string(), + FieldConfig { + bloom_filter_enabled: Some(true), + bloom_filter_fpp: Some(0.01), + bloom_filter_ndv: Some(200_000), + ..Default::default() + }, + ); + field_configs.insert( + "title".to_string(), + FieldConfig { + bloom_filter_enabled: Some(true), + ..Default::default() + }, + ); let config = NativeSettings { bloom_filter_enabled: Some(true), @@ -925,18 +1179,36 @@ mod tests { let props = WriterPropertiesBuilder::build(&config, &schema).unwrap(); // "name": field-level fpp=0.01, ndv=200000 (overrides type utf8 fpp=0.05, ndv=50000) - let name_bf = props.bloom_filter_properties(&parquet::schema::types::ColumnPath::from("name")).unwrap(); - assert!((name_bf.fpp - 0.01).abs() < 1e-9, "name fpp should be 0.01, got {}", name_bf.fpp); + let name_bf = props + .bloom_filter_properties(&parquet::schema::types::ColumnPath::from("name")) + .unwrap(); + assert!( + (name_bf.fpp - 0.01).abs() < 1e-9, + "name fpp should be 0.01, got {}", + name_bf.fpp + ); assert_eq!(name_bf.ndv, 200_000, "name ndv should be 200000"); // "title": field-level enabled but no fpp/ndv -> falls to type utf8 fpp=0.05, ndv=50000 - let title_bf = props.bloom_filter_properties(&parquet::schema::types::ColumnPath::from("title")).unwrap(); - assert!((title_bf.fpp - 0.05).abs() < 1e-9, "title fpp should be 0.05, got {}", title_bf.fpp); + let title_bf = props + .bloom_filter_properties(&parquet::schema::types::ColumnPath::from("title")) + .unwrap(); + assert!( + (title_bf.fpp - 0.05).abs() < 1e-9, + "title fpp should be 0.05, got {}", + title_bf.fpp + ); assert_eq!(title_bf.ndv, 50_000, "title ndv should be 50000"); // "age": int32 type-level enabled but no fpp/ndv -> falls to global fpp=0.1, ndv=100000 - let age_bf = props.bloom_filter_properties(&parquet::schema::types::ColumnPath::from("age")).unwrap(); - assert!((age_bf.fpp - 0.1).abs() < 1e-9, "age fpp should be 0.1, got {}", age_bf.fpp); + let age_bf = props + .bloom_filter_properties(&parquet::schema::types::ColumnPath::from("age")) + .unwrap(); + assert!( + (age_bf.fpp - 0.1).abs() < 1e-9, + "age fpp should be 0.1, got {}", + age_bf.fpp + ); assert_eq!(age_bf.ndv, 100_000, "age ndv should be 100000"); } @@ -961,7 +1233,10 @@ mod tests { // "name" (utf8) should have no explicit encoding (uses default) assert_eq!(props.encoding(&name_path), None); // "age" (int64) should have DELTA_BINARY_PACKED - assert_eq!(props.encoding(&age_path), Some(Encoding::DELTA_BINARY_PACKED)); + assert_eq!( + props.encoding(&age_path), + Some(Encoding::DELTA_BINARY_PACKED) + ); } #[test] @@ -1006,7 +1281,10 @@ mod tests { assert_eq!(props.encoding(&name_path), Some(Encoding::DELTA_BYTE_ARRAY)); assert!(!props.dictionary_enabled(&name_path)); - assert_eq!(props.encoding(&age_path), Some(Encoding::DELTA_BINARY_PACKED)); + assert_eq!( + props.encoding(&age_path), + Some(Encoding::DELTA_BINARY_PACKED) + ); assert!(!props.dictionary_enabled(&age_path)); } @@ -1016,10 +1294,13 @@ mod tests { let mut type_encoding = HashMap::new(); type_encoding.insert("int32".to_string(), "DELTA_BINARY_PACKED".to_string()); let mut field_configs = HashMap::new(); - field_configs.insert("age".to_string(), FieldConfig { - encoding_type: Some("PLAIN".to_string()), - ..Default::default() - }); + field_configs.insert( + "age".to_string(), + FieldConfig { + encoding_type: Some("PLAIN".to_string()), + ..Default::default() + }, + ); let config = NativeSettings { type_encoding_configs: Some(type_encoding), field_configs: Some(field_configs), @@ -1029,8 +1310,10 @@ mod tests { let props = WriterPropertiesBuilder::build(&config, &schema).unwrap(); let col_path = parquet::schema::types::ColumnPath::from("age"); assert_eq!(props.encoding(&col_path), Some(Encoding::PLAIN)); - assert!(!props.dictionary_enabled(&col_path), - "PLAIN should disable dictionary even when overriding type-level"); + assert!( + !props.dictionary_enabled(&col_path), + "PLAIN should disable dictionary even when overriding type-level" + ); } #[test] @@ -1051,10 +1334,14 @@ mod tests { let age_path = parquet::schema::types::ColumnPath::from("age"); let name_path = parquet::schema::types::ColumnPath::from("name"); - assert!(matches!(props.compression(&age_path), Compression::ZSTD(_)), - "int32 type-level ZSTD should override global SNAPPY"); - assert!(matches!(props.compression(&name_path), Compression::ZSTD(_)), - "utf8 has type-level set to ZSTD"); + assert!( + matches!(props.compression(&age_path), Compression::ZSTD(_)), + "int32 type-level ZSTD should override global SNAPPY" + ); + assert!( + matches!(props.compression(&name_path), Compression::ZSTD(_)), + "utf8 has type-level set to ZSTD" + ); } #[test] @@ -1072,10 +1359,15 @@ mod tests { fn test_build_with_generation_stamps_both() { let config = NativeSettings::default(); let schema = ArrowSchema::new(Vec::::new()); - let props = WriterPropertiesBuilder::build_with_generation(&config, Some(42), &schema).expect("build failed"); + let props = WriterPropertiesBuilder::build_with_generation(&config, Some(42), &schema) + .expect("build failed"); let kv = props.key_value_metadata().expect("KV metadata missing"); - let has_format = kv.iter().any(|k| k.key == FORMAT_VERSION_KEY && k.value.as_deref() == Some(FORMAT_VERSION)); - let has_gen = kv.iter().any(|k| k.key == WRITER_GENERATION_KEY && k.value.as_deref() == Some("42")); + let has_format = kv + .iter() + .any(|k| k.key == FORMAT_VERSION_KEY && k.value.as_deref() == Some(FORMAT_VERSION)); + let has_gen = kv + .iter() + .any(|k| k.key == WRITER_GENERATION_KEY && k.value.as_deref() == Some("42")); assert!(has_format, "format_version stamp missing"); assert!(has_gen, "writer_generation stamp missing"); } diff --git a/sandbox/plugins/parquet-data-format/src/main/rust/tests/merge_integration_tests.rs b/sandbox/plugins/parquet-data-format/src/main/rust/tests/merge_integration_tests.rs index c860c2d1fa515..2c6390dc65a24 100644 --- a/sandbox/plugins/parquet-data-format/src/main/rust/tests/merge_integration_tests.rs +++ b/sandbox/plugins/parquet-data-format/src/main/rust/tests/merge_integration_tests.rs @@ -11,22 +11,25 @@ use std::path::Path; use std::sync::Arc; use tempfile::tempdir; -use arrow::array::*; use arrow::array::types::TimestampMillisecondType; +use arrow::array::*; use arrow::datatypes::{DataType, Field, Schema}; use opensearch_parquet_format::merge::{merge_sorted, merge_unsorted}; use opensearch_parquet_format::native_settings::NativeSettings; use opensearch_parquet_format::writer::SETTINGS_STORE; -use parquet::arrow::ArrowWriter; use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder; +use parquet::arrow::ArrowWriter; use parquet::file::reader::{FileReader, SerializedFileReader}; /// Register a small merge_batch_size for the given index so the cursor splits into multiple batches. fn register_small_batch_size(index_name: &str, batch_size: usize) { - SETTINGS_STORE.insert(index_name.to_string(), NativeSettings { - merge_batch_size: Some(batch_size), - ..Default::default() - }); + SETTINGS_STORE.insert( + index_name.to_string(), + NativeSettings { + merge_batch_size: Some(batch_size), + ..Default::default() + }, + ); } /// Write a single RecordBatch to a Parquet file. @@ -40,12 +43,17 @@ fn write_parquet(path: &str, batch: &RecordBatch) { /// Read all Int64 values from a column. fn read_all_int64(path: &str, col: &str) -> Vec { let file = File::open(path).unwrap(); - let reader = ParquetRecordBatchReaderBuilder::try_new(file).unwrap().build().unwrap(); + let reader = ParquetRecordBatchReaderBuilder::try_new(file) + .unwrap() + .build() + .unwrap(); let mut vals = Vec::new(); for batch in reader { let batch = batch.unwrap(); let idx = batch.schema().index_of(col).unwrap(); - let arr = batch.column(idx).as_primitive::(); + let arr = batch + .column(idx) + .as_primitive::(); for i in 0..arr.len() { vals.push(arr.value(i)); } @@ -129,13 +137,17 @@ fn verify_row_id_order(path: &str) { let file = File::open(path).unwrap(); let builder = ParquetRecordBatchReaderBuilder::try_new(file).unwrap(); let schema = builder.schema().clone(); - let col_idx = schema.index_of("__row_id__").expect("__row_id__ not in output"); + let col_idx = schema + .index_of("__row_id__") + .expect("__row_id__ not in output"); let reader = builder.build().unwrap(); let mut expected: i64 = 0; for batch in reader { let batch = batch.unwrap(); - let col = batch.column(col_idx).as_any() + let col = batch + .column(col_idx) + .as_any() .downcast_ref::() .expect("__row_id__ should be Int64"); for i in 0..col.len() { @@ -147,7 +159,6 @@ fn verify_row_id_order(path: &str) { println!("Verified __row_id__ is sequential 0..{}", expected); } - #[test] fn test_sorted_merge_real_files() { let Some(input_dir) = input_dir() else { @@ -174,8 +185,16 @@ fn test_sorted_merge_real_files() { let reverse = vec![false]; let nulls_first = vec![false]; - merge_sorted(&files, &output_str, "test-index", &sort_cols, &reverse, &nulls_first, 0) - .unwrap(); + merge_sorted( + &files, + &output_str, + "test-index", + &sort_cols, + &reverse, + &nulls_first, + 0, + ) + .unwrap(); assert!(output.exists(), "Output file was not created"); let actual_rows = count_rows(&output_str); @@ -198,17 +217,24 @@ fn test_sorted_merge_real_files() { for batch in reader { let batch = batch.unwrap(); - let col = batch.column(col_idx).as_any() + let col = batch + .column(col_idx) + .as_any() .downcast_ref::>() .unwrap(); for i in 0..col.len() { - if col.is_null(i) { continue; } + if col.is_null(i) { + continue; + } let val = col.value(i); if let Some(p) = prev { if val < p { out_of_order += 1; if out_of_order <= 5 { - eprintln!("Out of order at row {}: prev={}, cur={}", rows_checked, p, val); + eprintln!( + "Out of order at row {}: prev={}, cur={}", + rows_checked, p, val + ); } } } @@ -217,8 +243,15 @@ fn test_sorted_merge_real_files() { } } - println!("Verified EventDate sort order across {} non-null rows", rows_checked); - assert_eq!(out_of_order, 0, "Found {} out-of-order rows in EventDate", out_of_order); + println!( + "Verified EventDate sort order across {} non-null rows", + rows_checked + ); + assert_eq!( + out_of_order, 0, + "Found {} out-of-order rows in EventDate", + out_of_order + ); } // ─── TIER 2 yield-to-heap tests ───────────────────────────────────────────── @@ -231,9 +264,7 @@ fn test_sorted_merge_real_files() { /// current position, so A must yield to the heap. #[test] fn test_tier2_yield_after_batch_boundary() { - let schema = Arc::new(Schema::new(vec![ - Field::new("v", DataType::Int64, false), - ])); + let schema = Arc::new(Schema::new(vec![Field::new("v", DataType::Int64, false)])); // File A: [1, 2, 3, 10, 11, 12] with batch_size=3 → cursor batches [1,2,3] then [10,11,12] // File B: [5, 6, 7] @@ -247,11 +278,13 @@ fn test_tier2_yield_after_batch_boundary() { let batch_a = RecordBatch::try_new( schema.clone(), vec![Arc::new(Int64Array::from(vec![1, 2, 3, 10, 11, 12]))], - ).unwrap(); + ) + .unwrap(); let batch_b = RecordBatch::try_new( schema.clone(), vec![Arc::new(Int64Array::from(vec![5, 6, 7]))], - ).unwrap(); + ) + .unwrap(); let tmp = tempdir().unwrap(); let file_a = tmp.path().join("a.parquet").to_string_lossy().to_string(); @@ -259,11 +292,21 @@ fn test_tier2_yield_after_batch_boundary() { write_parquet(&file_a, &batch_a); write_parquet(&file_b, &batch_b); - let output = tmp.path().join("merged.parquet").to_string_lossy().to_string(); + let output = tmp + .path() + .join("merged.parquet") + .to_string_lossy() + .to_string(); merge_sorted( - &[file_a, file_b], &output, index, - &["v".into()], &[false], &[false], 0, - ).unwrap(); + &[file_a, file_b], + &output, + index, + &["v".into()], + &[false], + &[false], + 0, + ) + .unwrap(); let vals = read_all_int64(&output, "v"); assert_eq!(vals, vec![1, 2, 3, 5, 6, 7, 10, 11, 12]); @@ -272,9 +315,7 @@ fn test_tier2_yield_after_batch_boundary() { /// Three files with interleaved batch boundaries to force multiple yield events. #[test] fn test_tier2_yield_multiple_cursors() { - let schema = Arc::new(Schema::new(vec![ - Field::new("v", DataType::Int64, false), - ])); + let schema = Arc::new(Schema::new(vec![Field::new("v", DataType::Int64, false)])); // File A: [1, 2, 20, 21] with batch_size=2 → cursor batches [1,2] and [20,21] // File B: [3, 4, 30, 31] with batch_size=2 → cursor batches [3,4] and [30,31] @@ -287,15 +328,18 @@ fn test_tier2_yield_multiple_cursors() { let batch_a = RecordBatch::try_new( schema.clone(), vec![Arc::new(Int64Array::from(vec![1, 2, 20, 21]))], - ).unwrap(); + ) + .unwrap(); let batch_b = RecordBatch::try_new( schema.clone(), vec![Arc::new(Int64Array::from(vec![3, 4, 30, 31]))], - ).unwrap(); + ) + .unwrap(); let batch_c = RecordBatch::try_new( schema.clone(), vec![Arc::new(Int64Array::from(vec![10, 11, 12]))], - ).unwrap(); + ) + .unwrap(); let tmp = tempdir().unwrap(); let file_a = tmp.path().join("a.parquet").to_string_lossy().to_string(); @@ -305,11 +349,21 @@ fn test_tier2_yield_multiple_cursors() { write_parquet(&file_b, &batch_b); write_parquet(&file_c, &batch_c); - let output = tmp.path().join("merged.parquet").to_string_lossy().to_string(); + let output = tmp + .path() + .join("merged.parquet") + .to_string_lossy() + .to_string(); merge_sorted( - &[file_a, file_b, file_c], &output, index, - &["v".into()], &[false], &[false], 0, - ).unwrap(); + &[file_a, file_b, file_c], + &output, + index, + &["v".into()], + &[false], + &[false], + 0, + ) + .unwrap(); let vals = read_all_int64(&output, "v"); assert_eq!(vals, vec![1, 2, 3, 4, 10, 11, 12, 20, 21, 30, 31]); @@ -318,9 +372,7 @@ fn test_tier2_yield_multiple_cursors() { /// Descending sort with yield-to-heap after batch boundary. #[test] fn test_tier2_yield_descending() { - let schema = Arc::new(Schema::new(vec![ - Field::new("v", DataType::Int64, false), - ])); + let schema = Arc::new(Schema::new(vec![Field::new("v", DataType::Int64, false)])); // File A: [12, 11, 10, 3, 2, 1] with batch_size=3 → cursor batches [12,11,10] and [3,2,1] // File B: [7, 6, 5] @@ -334,11 +386,13 @@ fn test_tier2_yield_descending() { let batch_a = RecordBatch::try_new( schema.clone(), vec![Arc::new(Int64Array::from(vec![12, 11, 10, 3, 2, 1]))], - ).unwrap(); + ) + .unwrap(); let batch_b = RecordBatch::try_new( schema.clone(), vec![Arc::new(Int64Array::from(vec![7, 6, 5]))], - ).unwrap(); + ) + .unwrap(); let tmp = tempdir().unwrap(); let file_a = tmp.path().join("a.parquet").to_string_lossy().to_string(); @@ -346,11 +400,21 @@ fn test_tier2_yield_descending() { write_parquet(&file_a, &batch_a); write_parquet(&file_b, &batch_b); - let output = tmp.path().join("merged.parquet").to_string_lossy().to_string(); + let output = tmp + .path() + .join("merged.parquet") + .to_string_lossy() + .to_string(); merge_sorted( - &[file_a, file_b], &output, index, - &["v".into()], &[true], &[false], 0, - ).unwrap(); + &[file_a, file_b], + &output, + index, + &["v".into()], + &[true], + &[false], + 0, + ) + .unwrap(); let vals = read_all_int64(&output, "v"); assert_eq!(vals, vec![12, 11, 10, 7, 6, 5, 3, 2, 1]); @@ -359,9 +423,7 @@ fn test_tier2_yield_descending() { /// Edge case: new batch starts exactly equal to heap top (should NOT yield). #[test] fn test_tier2_no_yield_when_equal_to_heap_top() { - let schema = Arc::new(Schema::new(vec![ - Field::new("v", DataType::Int64, false), - ])); + let schema = Arc::new(Schema::new(vec![Field::new("v", DataType::Int64, false)])); // File A: [1, 2, 5, 6] with batch_size=2 → cursor batches [1,2] and [5,6] // File B: [5, 7, 8] @@ -375,11 +437,13 @@ fn test_tier2_no_yield_when_equal_to_heap_top() { let batch_a = RecordBatch::try_new( schema.clone(), vec![Arc::new(Int64Array::from(vec![1, 2, 5, 6]))], - ).unwrap(); + ) + .unwrap(); let batch_b = RecordBatch::try_new( schema.clone(), vec![Arc::new(Int64Array::from(vec![5, 7, 8]))], - ).unwrap(); + ) + .unwrap(); let tmp = tempdir().unwrap(); let file_a = tmp.path().join("a.parquet").to_string_lossy().to_string(); @@ -387,11 +451,21 @@ fn test_tier2_no_yield_when_equal_to_heap_top() { write_parquet(&file_a, &batch_a); write_parquet(&file_b, &batch_b); - let output = tmp.path().join("merged.parquet").to_string_lossy().to_string(); + let output = tmp + .path() + .join("merged.parquet") + .to_string_lossy() + .to_string(); merge_sorted( - &[file_a, file_b], &output, index, - &["v".into()], &[false], &[false], 0, - ).unwrap(); + &[file_a, file_b], + &output, + index, + &["v".into()], + &[false], + &[false], + 0, + ) + .unwrap(); let vals = read_all_int64(&output, "v"); assert_eq!(vals, vec![1, 2, 5, 5, 6, 7, 8]); @@ -400,9 +474,7 @@ fn test_tier2_no_yield_when_equal_to_heap_top() { /// Stress test: many small batches forcing repeated yield-to-heap transitions. #[test] fn test_tier2_yield_many_small_batches() { - let schema = Arc::new(Schema::new(vec![ - Field::new("v", DataType::Int64, false), - ])); + let schema = Arc::new(Schema::new(vec![Field::new("v", DataType::Int64, false)])); // File A: [1,2, 11,12, 21,22, 31,32, 41,42] with batch_size=2 // File B: [5,6, 15,16, 25,26, 35,36, 45,46] with batch_size=2 @@ -418,11 +490,13 @@ fn test_tier2_yield_many_small_batches() { let batch_a = RecordBatch::try_new( schema.clone(), vec![Arc::new(Int64Array::from(a_vals.clone()))], - ).unwrap(); + ) + .unwrap(); let batch_b = RecordBatch::try_new( schema.clone(), vec![Arc::new(Int64Array::from(b_vals.clone()))], - ).unwrap(); + ) + .unwrap(); let tmp = tempdir().unwrap(); let file_a = tmp.path().join("a.parquet").to_string_lossy().to_string(); @@ -430,11 +504,21 @@ fn test_tier2_yield_many_small_batches() { write_parquet(&file_a, &batch_a); write_parquet(&file_b, &batch_b); - let output = tmp.path().join("merged.parquet").to_string_lossy().to_string(); + let output = tmp + .path() + .join("merged.parquet") + .to_string_lossy() + .to_string(); merge_sorted( - &[file_a, file_b], &output, index, - &["v".into()], &[false], &[false], 0, - ).unwrap(); + &[file_a, file_b], + &output, + index, + &["v".into()], + &[false], + &[false], + 0, + ) + .unwrap(); let vals = read_all_int64(&output, "v"); let mut expected: Vec = a_vals.iter().chain(b_vals.iter()).copied().collect(); @@ -451,32 +535,38 @@ fn test_tier2_yield_many_small_batches() { // - RG boundary: last value of RG0 < first value of RG1 (ascending) // - Exact null count and null positions for nulls_first/nulls_last -const RG_MAX: i64 = 1_000_000; +const RG_MAX: i64 = 1_000_000; const ROWS_PER_FILE: i64 = 700_000; // 2 files → 1_400_000 total → RG0=1_000_000, RG1=400_000 /// Returns (row_group_sizes, per_rg_first_value, per_rg_last_value) for a nullable Int64 column. fn inspect_row_groups(path: &str, col: &str) -> (Vec, Vec>, Vec>) { let reader = SerializedFileReader::new(File::open(path).unwrap()).unwrap(); - let meta = reader.metadata(); - let n_rg = meta.num_row_groups(); + let meta = reader.metadata(); + let n_rg = meta.num_row_groups(); let sizes: Vec = (0..n_rg).map(|i| meta.row_group(i).num_rows()).collect(); // Read all values in order, then slice per RG - let file = File::open(path).unwrap(); + let file = File::open(path).unwrap(); let builder = ParquetRecordBatchReaderBuilder::try_new(file).unwrap(); - let rdr = builder.build().unwrap(); + let rdr = builder.build().unwrap(); let mut all: Vec> = Vec::new(); for batch in rdr { let batch = batch.unwrap(); - let idx = batch.schema().index_of(col).unwrap(); - let arr = batch.column(idx).as_primitive::(); + let idx = batch.schema().index_of(col).unwrap(); + let arr = batch + .column(idx) + .as_primitive::(); for i in 0..arr.len() { - all.push(if arr.is_null(i) { None } else { Some(arr.value(i)) }); + all.push(if arr.is_null(i) { + None + } else { + Some(arr.value(i)) + }); } } let mut firsts = Vec::new(); - let mut lasts = Vec::new(); + let mut lasts = Vec::new(); let mut offset = 0usize; for &sz in &sizes { let end = offset + sz as usize; @@ -490,14 +580,23 @@ fn inspect_row_groups(path: &str, col: &str) -> (Vec, Vec>, Vec /// Read every value of a nullable Int64 column. fn read_col_i64(path: &str, col: &str) -> Vec> { let file = File::open(path).unwrap(); - let rdr = ParquetRecordBatchReaderBuilder::try_new(file).unwrap().build().unwrap(); + let rdr = ParquetRecordBatchReaderBuilder::try_new(file) + .unwrap() + .build() + .unwrap(); let mut vals = Vec::new(); for batch in rdr { let batch = batch.unwrap(); - let idx = batch.schema().index_of(col).unwrap(); - let arr = batch.column(idx).as_primitive::(); + let idx = batch.schema().index_of(col).unwrap(); + let arr = batch + .column(idx) + .as_primitive::(); for i in 0..arr.len() { - vals.push(if arr.is_null(i) { None } else { Some(arr.value(i)) }); + vals.push(if arr.is_null(i) { + None + } else { + Some(arr.value(i)) + }); } } vals @@ -514,15 +613,29 @@ fn test_default_settings_ascending_nulls_last() { let a_vals: Vec = (0..ROWS_PER_FILE).map(|i| i * 2).collect(); let b_vals: Vec = (0..ROWS_PER_FILE).map(|i| i * 2 + 1).collect(); - let tmp = tempdir().unwrap(); + let tmp = tempdir().unwrap(); let file_a = tmp.path().join("a.parquet").to_string_lossy().to_string(); let file_b = tmp.path().join("b.parquet").to_string_lossy().to_string(); - write_parquet(&file_a, &RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(a_vals))]).unwrap()); - write_parquet(&file_b, &RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(b_vals))]).unwrap()); + write_parquet( + &file_a, + &RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(a_vals))]).unwrap(), + ); + write_parquet( + &file_b, + &RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(b_vals))]).unwrap(), + ); let output = tmp.path().join("out.parquet").to_string_lossy().to_string(); - merge_sorted(&[file_a, file_b], &output, "test_default_asc_nulls_last", - &["v".into()], &[false], &[false], 0).unwrap(); + merge_sorted( + &[file_a, file_b], + &output, + "test_default_asc_nulls_last", + &["v".into()], + &[false], + &[false], + 0, + ) + .unwrap(); // ── Row group structure ────────────────────────────────────────────── // With default batch_size=100_000 which divides evenly into RG_MAX=1_000_000, @@ -530,11 +643,14 @@ fn test_default_settings_ascending_nulls_last() { // overshoot (see test_rg_size_overshoots_when_batch_straddles_threshold). let (rg_sizes, rg_firsts, rg_lasts) = inspect_row_groups(&output, "v"); assert_eq!(rg_sizes.len(), 2, "expected exactly 2 row groups"); - assert_eq!(rg_sizes[0], RG_MAX, "RG0 size: exact because batch_size=100_000 divides RG_MAX"); + assert_eq!( + rg_sizes[0], RG_MAX, + "RG0 size: exact because batch_size=100_000 divides RG_MAX" + ); assert_eq!(rg_sizes[1], ROWS_PER_FILE * 2 - RG_MAX, "RG1 size"); // RG boundary: last of RG0 must be strictly less than first of RG1 - assert_eq!(rg_lasts[0], Some(RG_MAX - 1), "RG0 last value"); - assert_eq!(rg_firsts[1], Some(RG_MAX), "RG1 first value"); + assert_eq!(rg_lasts[0], Some(RG_MAX - 1), "RG0 last value"); + assert_eq!(rg_firsts[1], Some(RG_MAX), "RG1 first value"); // ── Exact value check ──────────────────────────────────────────────── let vals = read_col_i64(&output, "v"); @@ -552,19 +668,33 @@ fn test_default_settings_descending_nulls_last() { // RG0 last value = 1_399_999 - (RG_MAX-1) = 400_000 // RG0 first value = 1_399_999 let schema = Arc::new(Schema::new(vec![Field::new("v", DataType::Int64, false)])); - let total = ROWS_PER_FILE * 2; + let total = ROWS_PER_FILE * 2; let a_vals: Vec = (0..ROWS_PER_FILE).rev().map(|i| i * 2).collect(); let b_vals: Vec = (0..ROWS_PER_FILE).rev().map(|i| i * 2 + 1).collect(); - let tmp = tempdir().unwrap(); + let tmp = tempdir().unwrap(); let file_a = tmp.path().join("a.parquet").to_string_lossy().to_string(); let file_b = tmp.path().join("b.parquet").to_string_lossy().to_string(); - write_parquet(&file_a, &RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(a_vals))]).unwrap()); - write_parquet(&file_b, &RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(b_vals))]).unwrap()); + write_parquet( + &file_a, + &RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(a_vals))]).unwrap(), + ); + write_parquet( + &file_b, + &RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(b_vals))]).unwrap(), + ); let output = tmp.path().join("out.parquet").to_string_lossy().to_string(); - merge_sorted(&[file_a, file_b], &output, "test_default_desc_nulls_last", - &["v".into()], &[true], &[false], 0).unwrap(); + merge_sorted( + &[file_a, file_b], + &output, + "test_default_desc_nulls_last", + &["v".into()], + &[true], + &[false], + 0, + ) + .unwrap(); // ── Row group structure ────────────────────────────────────────────── let (rg_sizes, rg_firsts, rg_lasts) = inspect_row_groups(&output, "v"); @@ -572,10 +702,10 @@ fn test_default_settings_descending_nulls_last() { assert_eq!(rg_sizes[0], RG_MAX); assert_eq!(rg_sizes[1], total - RG_MAX); // Descending: RG0 starts at max, ends at total-RG_MAX; RG1 starts one below that - assert_eq!(rg_firsts[0], Some(total - 1), "RG0 first value"); - assert_eq!(rg_lasts[0], Some(total - RG_MAX), "RG0 last value"); + assert_eq!(rg_firsts[0], Some(total - 1), "RG0 first value"); + assert_eq!(rg_lasts[0], Some(total - RG_MAX), "RG0 last value"); assert_eq!(rg_firsts[1], Some(total - RG_MAX - 1), "RG1 first value"); - assert_eq!(rg_lasts[1], Some(0), "RG1 last value"); + assert_eq!(rg_lasts[1], Some(0), "RG1 last value"); // ── Exact value check ──────────────────────────────────────────────── let vals = read_col_i64(&output, "v"); @@ -599,27 +729,43 @@ fn test_default_settings_ascending_nulls_first() { // RG0 = 1_000_000 rows: all 700_000 nulls + first 300_000 non-nulls (0..299_999) // RG1 = 400_000 rows: remaining non-nulls (300_000..699_999) let schema = Arc::new(Schema::new(vec![Field::new("v", DataType::Int64, true)])); - let half = ROWS_PER_FILE / 2; // 350_000 - let total = ROWS_PER_FILE * 2; // 1_400_000 - let total_nulls = half * 2; // 700_000 - let total_nonnulls = half * 2; // 700_000 (values 0..699_999) + let half = ROWS_PER_FILE / 2; // 350_000 + let total = ROWS_PER_FILE * 2; // 1_400_000 + let total_nulls = half * 2; // 700_000 + let total_nonnulls = half * 2; // 700_000 (values 0..699_999) - let a_vals: Vec> = (0..half).map(|_| None) + let a_vals: Vec> = (0..half) + .map(|_| None) .chain((0..half).map(|i| Some(i * 2))) .collect(); - let b_vals: Vec> = (0..half).map(|_| None) + let b_vals: Vec> = (0..half) + .map(|_| None) .chain((0..half).map(|i| Some(i * 2 + 1))) .collect(); - let tmp = tempdir().unwrap(); + let tmp = tempdir().unwrap(); let file_a = tmp.path().join("a.parquet").to_string_lossy().to_string(); let file_b = tmp.path().join("b.parquet").to_string_lossy().to_string(); - write_parquet(&file_a, &RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(a_vals))]).unwrap()); - write_parquet(&file_b, &RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(b_vals))]).unwrap()); + write_parquet( + &file_a, + &RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(a_vals))]).unwrap(), + ); + write_parquet( + &file_b, + &RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(b_vals))]).unwrap(), + ); let output = tmp.path().join("out.parquet").to_string_lossy().to_string(); - merge_sorted(&[file_a, file_b], &output, "test_default_asc_nulls_first", - &["v".into()], &[false], &[true], 0).unwrap(); + merge_sorted( + &[file_a, file_b], + &output, + "test_default_asc_nulls_first", + &["v".into()], + &[false], + &[true], + 0, + ) + .unwrap(); // ── Row group structure ────────────────────────────────────────────── let (rg_sizes, rg_firsts, rg_lasts) = inspect_row_groups(&output, "v"); @@ -632,7 +778,7 @@ fn test_default_settings_ascending_nulls_first() { assert_eq!(rg_lasts[0], Some(rg0_last_nonnull), "RG0 last value"); // RG1: non-nulls (rg0_last_nonnull+1)..total_nonnulls-1 assert_eq!(rg_firsts[1], Some(rg0_last_nonnull + 1), "RG1 first value"); - assert_eq!(rg_lasts[1], Some(total_nonnulls - 1), "RG1 last value"); + assert_eq!(rg_lasts[1], Some(total_nonnulls - 1), "RG1 last value"); // ── Exact value check ──────────────────────────────────────────────── let vals = read_col_i64(&output, "v"); @@ -643,8 +789,12 @@ fn test_default_settings_ascending_nulls_first() { } // Remaining rows must be exactly 0, 1, 2, ..., total_nonnulls-1 for i in 0..total_nonnulls as usize { - assert_eq!(vals[total_nulls as usize + i], Some(i as i64), - "wrong non-null value at row {}", total_nulls as usize + i); + assert_eq!( + vals[total_nulls as usize + i], + Some(i as i64), + "wrong non-null value at row {}", + total_nulls as usize + i + ); } } @@ -660,22 +810,33 @@ fn test_single_large_file_passthrough() { let schema = Arc::new(Schema::new(vec![Field::new("v", DataType::Int64, false)])); let vals: Vec = (0..n).collect(); - let tmp = tempdir().unwrap(); + let tmp = tempdir().unwrap(); let file_a = tmp.path().join("a.parquet").to_string_lossy().to_string(); - write_parquet(&file_a, &RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(vals))]).unwrap()); + write_parquet( + &file_a, + &RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(vals))]).unwrap(), + ); let output = tmp.path().join("out.parquet").to_string_lossy().to_string(); - merge_sorted(&[file_a], &output, "test_single_large_file", - &["v".into()], &[false], &[false], 0).unwrap(); + merge_sorted( + &[file_a], + &output, + "test_single_large_file", + &["v".into()], + &[false], + &[false], + 0, + ) + .unwrap(); let (rg_sizes, rg_firsts, rg_lasts) = inspect_row_groups(&output, "v"); assert_eq!(rg_sizes.len(), 2); assert_eq!(rg_sizes[0], RG_MAX); assert_eq!(rg_sizes[1], n - RG_MAX); assert_eq!(rg_firsts[0], Some(0)); - assert_eq!(rg_lasts[0], Some(RG_MAX - 1)); + assert_eq!(rg_lasts[0], Some(RG_MAX - 1)); assert_eq!(rg_firsts[1], Some(RG_MAX)); - assert_eq!(rg_lasts[1], Some(n - 1)); + assert_eq!(rg_lasts[1], Some(n - 1)); let out_vals = read_col_i64(&output, "v"); assert_eq!(out_vals.len() as i64, n); @@ -693,29 +854,43 @@ fn test_skewed_file_sizes_large_small() { // File B: odds 1, 3, 5, ..., 399_999 (200_000 values, exhausts early) // Merged: 0,1,2,3,...,399_999, 400_000,400_002,...,2_399_998 let large: i64 = 1_200_000; - let small: i64 = 200_000; + let small: i64 = 200_000; let schema = Arc::new(Schema::new(vec![Field::new("v", DataType::Int64, false)])); let a_vals: Vec = (0..large).map(|i| i * 2).collect(); let b_vals: Vec = (0..small).map(|i| i * 2 + 1).collect(); - let tmp = tempdir().unwrap(); + let tmp = tempdir().unwrap(); let file_a = tmp.path().join("a.parquet").to_string_lossy().to_string(); let file_b = tmp.path().join("b.parquet").to_string_lossy().to_string(); - write_parquet(&file_a, &RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(a_vals))]).unwrap()); - write_parquet(&file_b, &RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(b_vals))]).unwrap()); + write_parquet( + &file_a, + &RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(a_vals))]).unwrap(), + ); + write_parquet( + &file_b, + &RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(b_vals))]).unwrap(), + ); let output = tmp.path().join("out.parquet").to_string_lossy().to_string(); - merge_sorted(&[file_a, file_b], &output, "test_skewed_large_small", - &["v".into()], &[false], &[false], 0).unwrap(); + merge_sorted( + &[file_a, file_b], + &output, + "test_skewed_large_small", + &["v".into()], + &[false], + &[false], + 0, + ) + .unwrap(); let total = large + small; let out_vals = read_col_i64(&output, "v"); assert_eq!(out_vals.len() as i64, total); // Build expected: interleaved evens/odds up to 399_999, then remaining evens - let mut expected: Vec = (0..small).map(|i| i * 2).collect(); // evens 0..399_998 - let odds: Vec = (0..small).map(|i| i * 2 + 1).collect(); // odds 1..399_999 + let mut expected: Vec = (0..small).map(|i| i * 2).collect(); // evens 0..399_998 + let odds: Vec = (0..small).map(|i| i * 2 + 1).collect(); // odds 1..399_999 expected.extend(odds); expected.sort(); // After interleaved section: remaining evens from 400_000 to 2_399_998 @@ -736,7 +911,7 @@ fn test_three_files_middle_exhausts_first() { // Merged: 0,1,2,3,4,5,...,29_998,29_999(missing from B),30_000,... // After B exhausts, A and C continue interleaving. let a_count: i64 = 100_000; - let b_count: i64 = 10_000; + let b_count: i64 = 10_000; let c_count: i64 = 100_000; let schema = Arc::new(Schema::new(vec![Field::new("v", DataType::Int64, false)])); @@ -744,23 +919,57 @@ fn test_three_files_middle_exhausts_first() { let b_vals: Vec = (0..b_count).map(|i| i * 3 + 1).collect(); let c_vals: Vec = (0..c_count).map(|i| i * 3 + 2).collect(); - let tmp = tempdir().unwrap(); + let tmp = tempdir().unwrap(); let file_a = tmp.path().join("a.parquet").to_string_lossy().to_string(); let file_b = tmp.path().join("b.parquet").to_string_lossy().to_string(); let file_c = tmp.path().join("c.parquet").to_string_lossy().to_string(); - write_parquet(&file_a, &RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(a_vals.clone()))]).unwrap()); - write_parquet(&file_b, &RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(b_vals.clone()))]).unwrap()); - write_parquet(&file_c, &RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(c_vals.clone()))]).unwrap()); + write_parquet( + &file_a, + &RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int64Array::from(a_vals.clone()))], + ) + .unwrap(), + ); + write_parquet( + &file_b, + &RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int64Array::from(b_vals.clone()))], + ) + .unwrap(), + ); + write_parquet( + &file_c, + &RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int64Array::from(c_vals.clone()))], + ) + .unwrap(), + ); let output = tmp.path().join("out.parquet").to_string_lossy().to_string(); - merge_sorted(&[file_a, file_b, file_c], &output, "test_three_files_middle_exhausts", - &["v".into()], &[false], &[false], 0).unwrap(); + merge_sorted( + &[file_a, file_b, file_c], + &output, + "test_three_files_middle_exhausts", + &["v".into()], + &[false], + &[false], + 0, + ) + .unwrap(); let total = a_count + b_count + c_count; let out_vals = read_col_i64(&output, "v"); assert_eq!(out_vals.len() as i64, total); - let mut expected: Vec = a_vals.iter().chain(b_vals.iter()).chain(c_vals.iter()).copied().collect(); + let mut expected: Vec = a_vals + .iter() + .chain(b_vals.iter()) + .chain(c_vals.iter()) + .copied() + .collect(); expected.sort(); for (i, (got, exp)) in out_vals.iter().zip(expected.iter()).enumerate() { assert_eq!(*got, Some(*exp), "wrong value at row {}", i); @@ -777,16 +986,37 @@ fn test_all_duplicate_sort_keys_large() { let schema = Arc::new(Schema::new(vec![Field::new("v", DataType::Int64, false)])); let vals: Vec = vec![42i64; n as usize]; - let tmp = tempdir().unwrap(); - let files: Vec = (0..3).map(|i| { - let p = tmp.path().join(format!("{}.parquet", i)).to_string_lossy().to_string(); - write_parquet(&p, &RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(vals.clone()))]).unwrap()); - p - }).collect(); + let tmp = tempdir().unwrap(); + let files: Vec = (0..3) + .map(|i| { + let p = tmp + .path() + .join(format!("{}.parquet", i)) + .to_string_lossy() + .to_string(); + write_parquet( + &p, + &RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int64Array::from(vals.clone()))], + ) + .unwrap(), + ); + p + }) + .collect(); let output = tmp.path().join("out.parquet").to_string_lossy().to_string(); - merge_sorted(&files, &output, "test_all_dupes_large", - &["v".into()], &[false], &[false], 0).unwrap(); + merge_sorted( + &files, + &output, + "test_all_dupes_large", + &["v".into()], + &[false], + &[false], + 0, + ) + .unwrap(); let total = n * 3; let (rg_sizes, rg_firsts, rg_lasts) = inspect_row_groups(&output, "v"); @@ -814,15 +1044,37 @@ fn test_non_multiple_of_batch_size() { let a_vals: Vec = (0..a_count).map(|i| i * 2).collect(); let b_vals: Vec = (0..b_count).map(|i| i * 2 + 1).collect(); - let tmp = tempdir().unwrap(); + let tmp = tempdir().unwrap(); let file_a = tmp.path().join("a.parquet").to_string_lossy().to_string(); let file_b = tmp.path().join("b.parquet").to_string_lossy().to_string(); - write_parquet(&file_a, &RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(a_vals.clone()))]).unwrap()); - write_parquet(&file_b, &RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(b_vals.clone()))]).unwrap()); + write_parquet( + &file_a, + &RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int64Array::from(a_vals.clone()))], + ) + .unwrap(), + ); + write_parquet( + &file_b, + &RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int64Array::from(b_vals.clone()))], + ) + .unwrap(), + ); let output = tmp.path().join("out.parquet").to_string_lossy().to_string(); - merge_sorted(&[file_a, file_b], &output, "test_non_multiple_batch", - &["v".into()], &[false], &[false], 0).unwrap(); + merge_sorted( + &[file_a, file_b], + &output, + "test_non_multiple_batch", + &["v".into()], + &[false], + &[false], + 0, + ) + .unwrap(); let total = a_count + b_count; let out_vals = read_col_i64(&output, "v"); @@ -849,10 +1101,13 @@ fn test_rg_size_overshoots_when_batch_straddles_threshold() { // Use a batch_size that doesn't divide evenly into RG_MAX let batch_size: usize = 100_001; let index = "test_rg_overshoot"; - SETTINGS_STORE.insert(index.to_string(), NativeSettings { - merge_batch_size: Some(batch_size), - ..Default::default() - }); + SETTINGS_STORE.insert( + index.to_string(), + NativeSettings { + merge_batch_size: Some(batch_size), + ..Default::default() + }, + ); // One file with 2_000_002 rows (20 full batches of 100_001) // RG0 flushes after 10 batches = 1_000_010 rows (overshoots by 10) @@ -862,30 +1117,47 @@ fn test_rg_size_overshoots_when_batch_straddles_threshold() { let schema = Arc::new(Schema::new(vec![Field::new("v", DataType::Int64, false)])); let vals: Vec = (0..n).collect(); - let tmp = tempdir().unwrap(); + let tmp = tempdir().unwrap(); let file_a = tmp.path().join("a.parquet").to_string_lossy().to_string(); - write_parquet(&file_a, &RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(vals))]).unwrap()); + write_parquet( + &file_a, + &RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(vals))]).unwrap(), + ); let output = tmp.path().join("out.parquet").to_string_lossy().to_string(); - merge_sorted(&[file_a], &output, index, - &["v".into()], &[false], &[false], 0).unwrap(); + merge_sorted( + &[file_a], + &output, + index, + &["v".into()], + &[false], + &[false], + 0, + ) + .unwrap(); let (rg_sizes, rg_firsts, rg_lasts) = inspect_row_groups(&output, "v"); assert_eq!(rg_sizes.len(), 2); // Each RG should be exactly 10 * batch_size = 1_000_010 (overshoots RG_MAX by 10) let expected_rg_size = (batch_size * 10) as i64; // 1_000_010 - assert_eq!(rg_sizes[0], expected_rg_size, + assert_eq!( + rg_sizes[0], + expected_rg_size, "RG0 size should be {} (overshoots RG_MAX={} by {}), got {}.", - expected_rg_size, RG_MAX, expected_rg_size - RG_MAX, rg_sizes[0]); + expected_rg_size, + RG_MAX, + expected_rg_size - RG_MAX, + rg_sizes[0] + ); assert_eq!(rg_sizes[1], expected_rg_size, "RG1 size"); assert_eq!(rg_sizes.iter().sum::(), n); // Exact boundary values assert_eq!(rg_firsts[0], Some(0)); - assert_eq!(rg_lasts[0], Some(expected_rg_size - 1)); + assert_eq!(rg_lasts[0], Some(expected_rg_size - 1)); assert_eq!(rg_firsts[1], Some(expected_rg_size)); - assert_eq!(rg_lasts[1], Some(n - 1)); + assert_eq!(rg_lasts[1], Some(n - 1)); // Exact value check let out_vals = read_col_i64(&output, "v"); @@ -895,29 +1167,38 @@ fn test_rg_size_overshoots_when_batch_straddles_threshold() { } } - // ═══════════════════════════════════════════════════════════════════════════════ // Deferred data loading tests // ═══════════════════════════════════════════════════════════════════════════════ /// Helper: register settings with a specific deferred threshold. fn register_deferred_settings(index_name: &str, batch_size: usize, deferred_threshold: usize) { - SETTINGS_STORE.insert(index_name.to_string(), NativeSettings { - merge_batch_size: Some(batch_size), - merge_deferred_column_threshold: Some(deferred_threshold), - ..Default::default() - }); + SETTINGS_STORE.insert( + index_name.to_string(), + NativeSettings { + merge_batch_size: Some(batch_size), + merge_deferred_column_threshold: Some(deferred_threshold), + ..Default::default() + }, + ); } /// Helper: read all String values from a column. fn read_all_strings(path: &str, col: &str) -> Vec { let file = File::open(path).unwrap(); - let reader = ParquetRecordBatchReaderBuilder::try_new(file).unwrap().build().unwrap(); + let reader = ParquetRecordBatchReaderBuilder::try_new(file) + .unwrap() + .build() + .unwrap(); let mut vals = Vec::new(); for batch in reader { let batch = batch.unwrap(); let idx = batch.schema().index_of(col).unwrap(); - let arr = batch.column(idx).as_any().downcast_ref::().unwrap(); + let arr = batch + .column(idx) + .as_any() + .downcast_ref::() + .unwrap(); for i in 0..arr.len() { vals.push(arr.value(i).to_string()); } @@ -939,18 +1220,26 @@ fn test_deferred_wide_schema_correctness() { let index = "test_deferred_wide_schema_correctness"; register_deferred_settings(index, 3, 0); // threshold=0 → always deferred - let batch_a = RecordBatch::try_new(schema.clone(), vec![ - Arc::new(Int64Array::from(vec![1, 3, 5])), - Arc::new(StringArray::from(vec!["a1", "a3", "a5"])), - Arc::new(StringArray::from(vec!["b1", "b3", "b5"])), - Arc::new(StringArray::from(vec!["c1", "c3", "c5"])), - ]).unwrap(); - let batch_b = RecordBatch::try_new(schema.clone(), vec![ - Arc::new(Int64Array::from(vec![2, 4, 6])), - Arc::new(StringArray::from(vec!["a2", "a4", "a6"])), - Arc::new(StringArray::from(vec!["b2", "b4", "b6"])), - Arc::new(StringArray::from(vec!["c2", "c4", "c6"])), - ]).unwrap(); + let batch_a = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int64Array::from(vec![1, 3, 5])), + Arc::new(StringArray::from(vec!["a1", "a3", "a5"])), + Arc::new(StringArray::from(vec!["b1", "b3", "b5"])), + Arc::new(StringArray::from(vec!["c1", "c3", "c5"])), + ], + ) + .unwrap(); + let batch_b = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int64Array::from(vec![2, 4, 6])), + Arc::new(StringArray::from(vec!["a2", "a4", "a6"])), + Arc::new(StringArray::from(vec!["b2", "b4", "b6"])), + Arc::new(StringArray::from(vec!["c2", "c4", "c6"])), + ], + ) + .unwrap(); let tmp = tempdir().unwrap(); let file_a = tmp.path().join("a.parquet").to_string_lossy().to_string(); @@ -958,11 +1247,21 @@ fn test_deferred_wide_schema_correctness() { write_parquet(&file_a, &batch_a); write_parquet(&file_b, &batch_b); - let output = tmp.path().join("merged.parquet").to_string_lossy().to_string(); + let output = tmp + .path() + .join("merged.parquet") + .to_string_lossy() + .to_string(); merge_sorted( - &[file_a, file_b], &output, index, - &["ts".into()], &[false], &[false], 0, - ).unwrap(); + &[file_a, file_b], + &output, + index, + &["ts".into()], + &[false], + &[false], + 0, + ) + .unwrap(); let ts_vals = read_all_int64(&output, "ts"); assert_eq!(ts_vals, vec![1, 2, 3, 4, 5, 6]); @@ -991,18 +1290,26 @@ fn test_eager_forced_by_high_threshold() { let index = "test_eager_forced_by_high_threshold"; register_deferred_settings(index, 3, 9999); // threshold=9999 → always eager - let batch_a = RecordBatch::try_new(schema.clone(), vec![ - Arc::new(Int64Array::from(vec![1, 3, 5])), - Arc::new(StringArray::from(vec!["a1", "a3", "a5"])), - Arc::new(StringArray::from(vec!["b1", "b3", "b5"])), - Arc::new(StringArray::from(vec!["c1", "c3", "c5"])), - ]).unwrap(); - let batch_b = RecordBatch::try_new(schema.clone(), vec![ - Arc::new(Int64Array::from(vec![2, 4, 6])), - Arc::new(StringArray::from(vec!["a2", "a4", "a6"])), - Arc::new(StringArray::from(vec!["b2", "b4", "b6"])), - Arc::new(StringArray::from(vec!["c2", "c4", "c6"])), - ]).unwrap(); + let batch_a = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int64Array::from(vec![1, 3, 5])), + Arc::new(StringArray::from(vec!["a1", "a3", "a5"])), + Arc::new(StringArray::from(vec!["b1", "b3", "b5"])), + Arc::new(StringArray::from(vec!["c1", "c3", "c5"])), + ], + ) + .unwrap(); + let batch_b = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int64Array::from(vec![2, 4, 6])), + Arc::new(StringArray::from(vec!["a2", "a4", "a6"])), + Arc::new(StringArray::from(vec!["b2", "b4", "b6"])), + Arc::new(StringArray::from(vec!["c2", "c4", "c6"])), + ], + ) + .unwrap(); let tmp = tempdir().unwrap(); let file_a = tmp.path().join("a.parquet").to_string_lossy().to_string(); @@ -1010,11 +1317,21 @@ fn test_eager_forced_by_high_threshold() { write_parquet(&file_a, &batch_a); write_parquet(&file_b, &batch_b); - let output = tmp.path().join("merged.parquet").to_string_lossy().to_string(); + let output = tmp + .path() + .join("merged.parquet") + .to_string_lossy() + .to_string(); merge_sorted( - &[file_a, file_b], &output, index, - &["ts".into()], &[false], &[false], 0, - ).unwrap(); + &[file_a, file_b], + &output, + index, + &["ts".into()], + &[false], + &[false], + 0, + ) + .unwrap(); let ts_vals = read_all_int64(&output, "ts"); assert_eq!(ts_vals, vec![1, 2, 3, 4, 5, 6]); @@ -1038,14 +1355,22 @@ fn test_deferred_multi_batch_sync() { // File A: [1,2,5,6] → batches [1,2] and [5,6] // File B: [3,4,7,8] → batches [3,4] and [7,8] // Expected: 1,2,3,4,5,6,7,8 - let batch_a = RecordBatch::try_new(schema.clone(), vec![ - Arc::new(Int64Array::from(vec![1, 2, 5, 6])), - Arc::new(StringArray::from(vec!["p1", "p2", "p5", "p6"])), - ]).unwrap(); - let batch_b = RecordBatch::try_new(schema.clone(), vec![ - Arc::new(Int64Array::from(vec![3, 4, 7, 8])), - Arc::new(StringArray::from(vec!["p3", "p4", "p7", "p8"])), - ]).unwrap(); + let batch_a = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int64Array::from(vec![1, 2, 5, 6])), + Arc::new(StringArray::from(vec!["p1", "p2", "p5", "p6"])), + ], + ) + .unwrap(); + let batch_b = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int64Array::from(vec![3, 4, 7, 8])), + Arc::new(StringArray::from(vec!["p3", "p4", "p7", "p8"])), + ], + ) + .unwrap(); let tmp = tempdir().unwrap(); let file_a = tmp.path().join("a.parquet").to_string_lossy().to_string(); @@ -1053,11 +1378,21 @@ fn test_deferred_multi_batch_sync() { write_parquet(&file_a, &batch_a); write_parquet(&file_b, &batch_b); - let output = tmp.path().join("merged.parquet").to_string_lossy().to_string(); + let output = tmp + .path() + .join("merged.parquet") + .to_string_lossy() + .to_string(); merge_sorted( - &[file_a, file_b], &output, index, - &["ts".into()], &[false], &[false], 0, - ).unwrap(); + &[file_a, file_b], + &output, + index, + &["ts".into()], + &[false], + &[false], + 0, + ) + .unwrap(); let ts_vals = read_all_int64(&output, "ts"); assert_eq!(ts_vals, vec![1, 2, 3, 4, 5, 6, 7, 8]); @@ -1081,14 +1416,22 @@ fn test_deferred_tier3_interleaved() { // File A: [1, 3, 5, 7] — single batch // File B: [2, 4, 6, 8] — single batch // Fully interleaved → TIER 3 binary search on every pop - let batch_a = RecordBatch::try_new(schema.clone(), vec![ - Arc::new(Int64Array::from(vec![1, 3, 5, 7])), - Arc::new(StringArray::from(vec!["A1", "A3", "A5", "A7"])), - ]).unwrap(); - let batch_b = RecordBatch::try_new(schema.clone(), vec![ - Arc::new(Int64Array::from(vec![2, 4, 6, 8])), - Arc::new(StringArray::from(vec!["B2", "B4", "B6", "B8"])), - ]).unwrap(); + let batch_a = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int64Array::from(vec![1, 3, 5, 7])), + Arc::new(StringArray::from(vec!["A1", "A3", "A5", "A7"])), + ], + ) + .unwrap(); + let batch_b = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int64Array::from(vec![2, 4, 6, 8])), + Arc::new(StringArray::from(vec!["B2", "B4", "B6", "B8"])), + ], + ) + .unwrap(); let tmp = tempdir().unwrap(); let file_a = tmp.path().join("a.parquet").to_string_lossy().to_string(); @@ -1096,11 +1439,21 @@ fn test_deferred_tier3_interleaved() { write_parquet(&file_a, &batch_a); write_parquet(&file_b, &batch_b); - let output = tmp.path().join("merged.parquet").to_string_lossy().to_string(); + let output = tmp + .path() + .join("merged.parquet") + .to_string_lossy() + .to_string(); merge_sorted( - &[file_a, file_b], &output, index, - &["ts".into()], &[false], &[false], 0, - ).unwrap(); + &[file_a, file_b], + &output, + index, + &["ts".into()], + &[false], + &[false], + 0, + ) + .unwrap(); let ts_vals = read_all_int64(&output, "ts"); assert_eq!(ts_vals, vec![1, 2, 3, 4, 5, 6, 7, 8]); @@ -1120,20 +1473,28 @@ fn test_deferred_vs_eager_identical_output() { Field::new("n1", DataType::Int64, false), ])); - let batch_a = RecordBatch::try_new(schema.clone(), vec![ - Arc::new(Int64Array::from(vec![10, 30, 50, 70, 90])), - Arc::new(StringArray::from(vec!["x", "x", "x", "x", "x"])), - Arc::new(StringArray::from(vec!["y", "y", "y", "y", "y"])), - Arc::new(StringArray::from(vec!["z", "z", "z", "z", "z"])), - Arc::new(Int64Array::from(vec![100, 300, 500, 700, 900])), - ]).unwrap(); - let batch_b = RecordBatch::try_new(schema.clone(), vec![ - Arc::new(Int64Array::from(vec![20, 40, 60, 80, 100])), - Arc::new(StringArray::from(vec!["a", "a", "a", "a", "a"])), - Arc::new(StringArray::from(vec!["b", "b", "b", "b", "b"])), - Arc::new(StringArray::from(vec!["c", "c", "c", "c", "c"])), - Arc::new(Int64Array::from(vec![200, 400, 600, 800, 1000])), - ]).unwrap(); + let batch_a = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int64Array::from(vec![10, 30, 50, 70, 90])), + Arc::new(StringArray::from(vec!["x", "x", "x", "x", "x"])), + Arc::new(StringArray::from(vec!["y", "y", "y", "y", "y"])), + Arc::new(StringArray::from(vec!["z", "z", "z", "z", "z"])), + Arc::new(Int64Array::from(vec![100, 300, 500, 700, 900])), + ], + ) + .unwrap(); + let batch_b = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int64Array::from(vec![20, 40, 60, 80, 100])), + Arc::new(StringArray::from(vec!["a", "a", "a", "a", "a"])), + Arc::new(StringArray::from(vec!["b", "b", "b", "b", "b"])), + Arc::new(StringArray::from(vec!["c", "c", "c", "c", "c"])), + Arc::new(Int64Array::from(vec![200, 400, 600, 800, 1000])), + ], + ) + .unwrap(); let tmp = tempdir().unwrap(); let file_a = tmp.path().join("a.parquet").to_string_lossy().to_string(); @@ -1144,20 +1505,40 @@ fn test_deferred_vs_eager_identical_output() { // Run with deferred (threshold=0) let index_deferred = "test_deferred_vs_eager_deferred"; register_deferred_settings(index_deferred, 3, 0); - let output_deferred = tmp.path().join("merged_deferred.parquet").to_string_lossy().to_string(); + let output_deferred = tmp + .path() + .join("merged_deferred.parquet") + .to_string_lossy() + .to_string(); merge_sorted( - &[file_a.clone(), file_b.clone()], &output_deferred, index_deferred, - &["ts".into()], &[false], &[false], 0, - ).unwrap(); + &[file_a.clone(), file_b.clone()], + &output_deferred, + index_deferred, + &["ts".into()], + &[false], + &[false], + 0, + ) + .unwrap(); // Run with eager (threshold=9999) let index_eager = "test_deferred_vs_eager_eager"; register_deferred_settings(index_eager, 3, 9999); - let output_eager = tmp.path().join("merged_eager.parquet").to_string_lossy().to_string(); + let output_eager = tmp + .path() + .join("merged_eager.parquet") + .to_string_lossy() + .to_string(); merge_sorted( - &[file_a, file_b], &output_eager, index_eager, - &["ts".into()], &[false], &[false], 0, - ).unwrap(); + &[file_a, file_b], + &output_eager, + index_eager, + &["ts".into()], + &[false], + &[false], + 0, + ) + .unwrap(); // Compare outputs — must be identical let ts_d = read_all_int64(&output_deferred, "ts"); @@ -1192,14 +1573,22 @@ fn test_deferred_tier1_single_cursor_drain() { // File A: [1, 2] — exhausts quickly // File B: [3, 4, 5, 6, 7, 8] — becomes the sole cursor after A exhausts // TIER 1 should drain B entirely without heap operations - let batch_a = RecordBatch::try_new(schema.clone(), vec![ - Arc::new(Int64Array::from(vec![1, 2])), - Arc::new(StringArray::from(vec!["first", "second"])), - ]).unwrap(); - let batch_b = RecordBatch::try_new(schema.clone(), vec![ - Arc::new(Int64Array::from(vec![3, 4, 5, 6, 7, 8])), - Arc::new(StringArray::from(vec!["t3", "t4", "t5", "t6", "t7", "t8"])), - ]).unwrap(); + let batch_a = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int64Array::from(vec![1, 2])), + Arc::new(StringArray::from(vec!["first", "second"])), + ], + ) + .unwrap(); + let batch_b = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int64Array::from(vec![3, 4, 5, 6, 7, 8])), + Arc::new(StringArray::from(vec!["t3", "t4", "t5", "t6", "t7", "t8"])), + ], + ) + .unwrap(); let tmp = tempdir().unwrap(); let file_a = tmp.path().join("a.parquet").to_string_lossy().to_string(); @@ -1207,17 +1596,30 @@ fn test_deferred_tier1_single_cursor_drain() { write_parquet(&file_a, &batch_a); write_parquet(&file_b, &batch_b); - let output = tmp.path().join("merged.parquet").to_string_lossy().to_string(); + let output = tmp + .path() + .join("merged.parquet") + .to_string_lossy() + .to_string(); merge_sorted( - &[file_a, file_b], &output, index, - &["ts".into()], &[false], &[false], 0, - ).unwrap(); + &[file_a, file_b], + &output, + index, + &["ts".into()], + &[false], + &[false], + 0, + ) + .unwrap(); let ts_vals = read_all_int64(&output, "ts"); assert_eq!(ts_vals, vec![1, 2, 3, 4, 5, 6, 7, 8]); let msg_vals = read_all_strings(&output, "msg"); - assert_eq!(msg_vals, vec!["first", "second", "t3", "t4", "t5", "t6", "t7", "t8"]); + assert_eq!( + msg_vals, + vec!["first", "second", "t3", "t4", "t5", "t6", "t7", "t8"] + ); } /// TIER 1 deferred with multiple batches: the sole remaining cursor spans @@ -1235,14 +1637,22 @@ fn test_deferred_tier1_multi_batch_drain() { // File A: [1] — exhausts immediately // File B: [2, 3, 4, 5, 6, 7] — 3 batches of 2 rows each, all drained by TIER 1 - let batch_a = RecordBatch::try_new(schema.clone(), vec![ - Arc::new(Int64Array::from(vec![1])), - Arc::new(StringArray::from(vec!["a"])), - ]).unwrap(); - let batch_b = RecordBatch::try_new(schema.clone(), vec![ - Arc::new(Int64Array::from(vec![2, 3, 4, 5, 6, 7])), - Arc::new(StringArray::from(vec!["b2", "b3", "b4", "b5", "b6", "b7"])), - ]).unwrap(); + let batch_a = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int64Array::from(vec![1])), + Arc::new(StringArray::from(vec!["a"])), + ], + ) + .unwrap(); + let batch_b = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int64Array::from(vec![2, 3, 4, 5, 6, 7])), + Arc::new(StringArray::from(vec!["b2", "b3", "b4", "b5", "b6", "b7"])), + ], + ) + .unwrap(); let tmp = tempdir().unwrap(); let file_a = tmp.path().join("a.parquet").to_string_lossy().to_string(); @@ -1250,11 +1660,21 @@ fn test_deferred_tier1_multi_batch_drain() { write_parquet(&file_a, &batch_a); write_parquet(&file_b, &batch_b); - let output = tmp.path().join("merged.parquet").to_string_lossy().to_string(); + let output = tmp + .path() + .join("merged.parquet") + .to_string_lossy() + .to_string(); merge_sorted( - &[file_a, file_b], &output, index, - &["ts".into()], &[false], &[false], 0, - ).unwrap(); + &[file_a, file_b], + &output, + index, + &["ts".into()], + &[false], + &[false], + 0, + ) + .unwrap(); let ts_vals = read_all_int64(&output, "ts"); assert_eq!(ts_vals, vec![1, 2, 3, 4, 5, 6, 7]); @@ -1278,14 +1698,24 @@ fn test_deferred_tier2_full_batch_emit() { // File A: [1, 2, 3, 10, 11, 12] → batches [1,2,3] and [10,11,12] // File B: [5, 6, 7] // Batch [1,2,3] from A fits entirely before heap top (B=5) → TIER 2 emits all 3 - let batch_a = RecordBatch::try_new(schema.clone(), vec![ - Arc::new(Int64Array::from(vec![1, 2, 3, 10, 11, 12])), - Arc::new(StringArray::from(vec!["A1", "A2", "A3", "A10", "A11", "A12"])), - ]).unwrap(); - let batch_b = RecordBatch::try_new(schema.clone(), vec![ - Arc::new(Int64Array::from(vec![5, 6, 7])), - Arc::new(StringArray::from(vec!["B5", "B6", "B7"])), - ]).unwrap(); + let batch_a = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int64Array::from(vec![1, 2, 3, 10, 11, 12])), + Arc::new(StringArray::from(vec![ + "A1", "A2", "A3", "A10", "A11", "A12", + ])), + ], + ) + .unwrap(); + let batch_b = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int64Array::from(vec![5, 6, 7])), + Arc::new(StringArray::from(vec!["B5", "B6", "B7"])), + ], + ) + .unwrap(); let tmp = tempdir().unwrap(); let file_a = tmp.path().join("a.parquet").to_string_lossy().to_string(); @@ -1293,17 +1723,30 @@ fn test_deferred_tier2_full_batch_emit() { write_parquet(&file_a, &batch_a); write_parquet(&file_b, &batch_b); - let output = tmp.path().join("merged.parquet").to_string_lossy().to_string(); + let output = tmp + .path() + .join("merged.parquet") + .to_string_lossy() + .to_string(); merge_sorted( - &[file_a, file_b], &output, index, - &["ts".into()], &[false], &[false], 0, - ).unwrap(); + &[file_a, file_b], + &output, + index, + &["ts".into()], + &[false], + &[false], + 0, + ) + .unwrap(); let ts_vals = read_all_int64(&output, "ts"); assert_eq!(ts_vals, vec![1, 2, 3, 5, 6, 7, 10, 11, 12]); let tag_vals = read_all_strings(&output, "tag"); - assert_eq!(tag_vals, vec!["A1", "A2", "A3", "B5", "B6", "B7", "A10", "A11", "A12"]); + assert_eq!( + tag_vals, + vec!["A1", "A2", "A3", "B5", "B6", "B7", "A10", "A11", "A12"] + ); } /// TIER 2 deferred with descending sort — verifies deferred works with reverse order. @@ -1319,14 +1762,24 @@ fn test_deferred_tier2_descending() { // File A: [12, 11, 10, 3, 2, 1] descending → batches [12,11,10] and [3,2,1] // File B: [7, 6, 5] descending - let batch_a = RecordBatch::try_new(schema.clone(), vec![ - Arc::new(Int64Array::from(vec![12, 11, 10, 3, 2, 1])), - Arc::new(StringArray::from(vec!["a12", "a11", "a10", "a3", "a2", "a1"])), - ]).unwrap(); - let batch_b = RecordBatch::try_new(schema.clone(), vec![ - Arc::new(Int64Array::from(vec![7, 6, 5])), - Arc::new(StringArray::from(vec!["b7", "b6", "b5"])), - ]).unwrap(); + let batch_a = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int64Array::from(vec![12, 11, 10, 3, 2, 1])), + Arc::new(StringArray::from(vec![ + "a12", "a11", "a10", "a3", "a2", "a1", + ])), + ], + ) + .unwrap(); + let batch_b = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int64Array::from(vec![7, 6, 5])), + Arc::new(StringArray::from(vec!["b7", "b6", "b5"])), + ], + ) + .unwrap(); let tmp = tempdir().unwrap(); let file_a = tmp.path().join("a.parquet").to_string_lossy().to_string(); @@ -1334,17 +1787,30 @@ fn test_deferred_tier2_descending() { write_parquet(&file_a, &batch_a); write_parquet(&file_b, &batch_b); - let output = tmp.path().join("merged.parquet").to_string_lossy().to_string(); + let output = tmp + .path() + .join("merged.parquet") + .to_string_lossy() + .to_string(); merge_sorted( - &[file_a, file_b], &output, index, - &["ts".into()], &[true], &[false], 0, // reverse=true (descending) - ).unwrap(); + &[file_a, file_b], + &output, + index, + &["ts".into()], + &[true], + &[false], + 0, // reverse=true (descending) + ) + .unwrap(); let ts_vals = read_all_int64(&output, "ts"); assert_eq!(ts_vals, vec![12, 11, 10, 7, 6, 5, 3, 2, 1]); let info_vals = read_all_strings(&output, "info"); - assert_eq!(info_vals, vec!["a12", "a11", "a10", "b7", "b6", "b5", "a3", "a2", "a1"]); + assert_eq!( + info_vals, + vec!["a12", "a11", "a10", "b7", "b6", "b5", "a3", "a2", "a1"] + ); } /// TIER 3 deferred with many cursors — stress test for data reader sync @@ -1363,10 +1829,14 @@ fn test_deferred_tier3_many_cursors() { // A: [1, 5, 9] B: [2, 6, 10] C: [3, 7, 11] D: [4, 8, 12] let make_batch = |vals: Vec, label: &str| { let labels: Vec<&str> = vals.iter().map(|_| label).collect(); - RecordBatch::try_new(schema.clone(), vec![ - Arc::new(Int64Array::from(vals)), - Arc::new(StringArray::from(labels)), - ]).unwrap() + RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int64Array::from(vals)), + Arc::new(StringArray::from(labels)), + ], + ) + .unwrap() }; let tmp = tempdir().unwrap(); @@ -1375,23 +1845,43 @@ fn test_deferred_tier3_many_cursors() { ("b", vec![2, 6, 10]), ("c", vec![3, 7, 11]), ("d", vec![4, 8, 12]), - ].into_iter().map(|(name, vals)| { - let path = tmp.path().join(format!("{}.parquet", name)).to_string_lossy().to_string(); + ] + .into_iter() + .map(|(name, vals)| { + let path = tmp + .path() + .join(format!("{}.parquet", name)) + .to_string_lossy() + .to_string(); write_parquet(&path, &make_batch(vals, name)); path - }).collect(); - - let output = tmp.path().join("merged.parquet").to_string_lossy().to_string(); + }) + .collect(); + + let output = tmp + .path() + .join("merged.parquet") + .to_string_lossy() + .to_string(); merge_sorted( - &files, &output, index, - &["ts".into()], &[false], &[false], 0, - ).unwrap(); + &files, + &output, + index, + &["ts".into()], + &[false], + &[false], + 0, + ) + .unwrap(); let ts_vals = read_all_int64(&output, "ts"); assert_eq!(ts_vals, vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); let src_vals = read_all_strings(&output, "src"); - assert_eq!(src_vals, vec!["a", "b", "c", "d", "a", "b", "c", "d", "a", "b", "c", "d"]); + assert_eq!( + src_vals, + vec!["a", "b", "c", "d", "a", "b", "c", "d", "a", "b", "c", "d"] + ); } /// Merge files with different schemas in deferred mode — verifies that columns @@ -1415,18 +1905,26 @@ fn test_deferred_different_schemas() { let index = "test_deferred_different_schemas"; register_deferred_settings(index, 3, 0); - let batch_a = RecordBatch::try_new(schema_a.clone(), vec![ - Arc::new(Int64Array::from(vec![1, 3, 5])), - Arc::new(StringArray::from(vec![Some("a1"), Some("a3"), Some("a5")])), - Arc::new(StringArray::from(vec![Some("b1"), Some("b3"), Some("b5")])), - Arc::new(StringArray::from(vec![Some("c1"), Some("c3"), Some("c5")])), - ]).unwrap(); - let batch_b = RecordBatch::try_new(schema_b.clone(), vec![ - Arc::new(Int64Array::from(vec![2, 4, 6])), - Arc::new(StringArray::from(vec![Some("b2"), Some("b4"), Some("b6")])), - Arc::new(StringArray::from(vec![Some("d2"), Some("d4"), Some("d6")])), - Arc::new(StringArray::from(vec![Some("e2"), Some("e4"), Some("e6")])), - ]).unwrap(); + let batch_a = RecordBatch::try_new( + schema_a.clone(), + vec![ + Arc::new(Int64Array::from(vec![1, 3, 5])), + Arc::new(StringArray::from(vec![Some("a1"), Some("a3"), Some("a5")])), + Arc::new(StringArray::from(vec![Some("b1"), Some("b3"), Some("b5")])), + Arc::new(StringArray::from(vec![Some("c1"), Some("c3"), Some("c5")])), + ], + ) + .unwrap(); + let batch_b = RecordBatch::try_new( + schema_b.clone(), + vec![ + Arc::new(Int64Array::from(vec![2, 4, 6])), + Arc::new(StringArray::from(vec![Some("b2"), Some("b4"), Some("b6")])), + Arc::new(StringArray::from(vec![Some("d2"), Some("d4"), Some("d6")])), + Arc::new(StringArray::from(vec![Some("e2"), Some("e4"), Some("e6")])), + ], + ) + .unwrap(); let tmp = tempdir().unwrap(); let file_a = tmp.path().join("a.parquet").to_string_lossy().to_string(); @@ -1434,11 +1932,21 @@ fn test_deferred_different_schemas() { write_parquet(&file_a, &batch_a); write_parquet(&file_b, &batch_b); - let output = tmp.path().join("merged.parquet").to_string_lossy().to_string(); + let output = tmp + .path() + .join("merged.parquet") + .to_string_lossy() + .to_string(); merge_sorted( - &[file_a, file_b], &output, index, - &["ts".into()], &[false], &[false], 0, - ).unwrap(); + &[file_a, file_b], + &output, + index, + &["ts".into()], + &[false], + &[false], + 0, + ) + .unwrap(); let ts_vals = read_all_int64(&output, "ts"); assert_eq!(ts_vals, vec![1, 2, 3, 4, 5, 6]); @@ -1449,12 +1957,19 @@ fn test_deferred_different_schemas() { // col_a only in file A — file B rows should be null let file = File::open(&output).unwrap(); - let reader = ParquetRecordBatchReaderBuilder::try_new(file).unwrap().build().unwrap(); + let reader = ParquetRecordBatchReaderBuilder::try_new(file) + .unwrap() + .build() + .unwrap(); let mut col_a_nulls = Vec::new(); for batch in reader { let batch = batch.unwrap(); let idx = batch.schema().index_of("col_a").unwrap(); - let arr = batch.column(idx).as_any().downcast_ref::().unwrap(); + let arr = batch + .column(idx) + .as_any() + .downcast_ref::() + .unwrap(); for i in 0..arr.len() { col_a_nulls.push(arr.is_null(i)); } @@ -1464,12 +1979,19 @@ fn test_deferred_different_schemas() { // col_d only in file B — file A rows should be null let file = File::open(&output).unwrap(); - let reader = ParquetRecordBatchReaderBuilder::try_new(file).unwrap().build().unwrap(); + let reader = ParquetRecordBatchReaderBuilder::try_new(file) + .unwrap() + .build() + .unwrap(); let mut col_d_vals = Vec::new(); for batch in reader { let batch = batch.unwrap(); let idx = batch.schema().index_of("col_d").unwrap(); - let arr = batch.column(idx).as_any().downcast_ref::().unwrap(); + let arr = batch + .column(idx) + .as_any() + .downcast_ref::() + .unwrap(); for i in 0..arr.len() { if arr.is_null(i) { col_d_vals.push("NULL".to_string()); @@ -1504,21 +2026,33 @@ fn test_deferred_three_files_different_schemas() { let index = "test_deferred_three_files_different_schemas"; register_deferred_settings(index, 4, 0); - let batch_1 = RecordBatch::try_new(schema_1.clone(), vec![ - Arc::new(Int64Array::from(vec![1, 4])), - Arc::new(StringArray::from(vec![Some("alice"), Some("dave")])), - ]).unwrap(); - let batch_2 = RecordBatch::try_new(schema_2.clone(), vec![ - Arc::new(Int64Array::from(vec![2, 5])), - Arc::new(StringArray::from(vec![Some("bob"), Some("eve")])), - Arc::new(StringArray::from(vec![Some("NYC"), Some("LA")])), - Arc::new(StringArray::from(vec![Some("x2"), Some("x5")])), - ]).unwrap(); - let batch_3 = RecordBatch::try_new(schema_3.clone(), vec![ - Arc::new(Int64Array::from(vec![3, 6])), - Arc::new(StringArray::from(vec![Some("US"), Some("UK")])), - Arc::new(StringArray::from(vec![Some("x3"), Some("x6")])), - ]).unwrap(); + let batch_1 = RecordBatch::try_new( + schema_1.clone(), + vec![ + Arc::new(Int64Array::from(vec![1, 4])), + Arc::new(StringArray::from(vec![Some("alice"), Some("dave")])), + ], + ) + .unwrap(); + let batch_2 = RecordBatch::try_new( + schema_2.clone(), + vec![ + Arc::new(Int64Array::from(vec![2, 5])), + Arc::new(StringArray::from(vec![Some("bob"), Some("eve")])), + Arc::new(StringArray::from(vec![Some("NYC"), Some("LA")])), + Arc::new(StringArray::from(vec![Some("x2"), Some("x5")])), + ], + ) + .unwrap(); + let batch_3 = RecordBatch::try_new( + schema_3.clone(), + vec![ + Arc::new(Int64Array::from(vec![3, 6])), + Arc::new(StringArray::from(vec![Some("US"), Some("UK")])), + Arc::new(StringArray::from(vec![Some("x3"), Some("x6")])), + ], + ) + .unwrap(); let tmp = tempdir().unwrap(); let file_1 = tmp.path().join("f1.parquet").to_string_lossy().to_string(); @@ -1528,56 +2062,102 @@ fn test_deferred_three_files_different_schemas() { write_parquet(&file_2, &batch_2); write_parquet(&file_3, &batch_3); - let output = tmp.path().join("merged.parquet").to_string_lossy().to_string(); + let output = tmp + .path() + .join("merged.parquet") + .to_string_lossy() + .to_string(); merge_sorted( - &[file_1, file_2, file_3], &output, index, - &["ts".into()], &[false], &[false], 0, - ).unwrap(); + &[file_1, file_2, file_3], + &output, + index, + &["ts".into()], + &[false], + &[false], + 0, + ) + .unwrap(); let ts_vals = read_all_int64(&output, "ts"); assert_eq!(ts_vals, vec![1, 2, 3, 4, 5, 6]); // "name" in files 1 and 2 only let file = File::open(&output).unwrap(); - let reader = ParquetRecordBatchReaderBuilder::try_new(file).unwrap().build().unwrap(); + let reader = ParquetRecordBatchReaderBuilder::try_new(file) + .unwrap() + .build() + .unwrap(); let mut name_vals = Vec::new(); for batch in reader { let batch = batch.unwrap(); let idx = batch.schema().index_of("name").unwrap(); - let arr = batch.column(idx).as_any().downcast_ref::().unwrap(); + let arr = batch + .column(idx) + .as_any() + .downcast_ref::() + .unwrap(); for i in 0..arr.len() { - if arr.is_null(i) { name_vals.push("NULL".to_string()); } - else { name_vals.push(arr.value(i).to_string()); } + if arr.is_null(i) { + name_vals.push("NULL".to_string()); + } else { + name_vals.push(arr.value(i).to_string()); + } } } - assert_eq!(name_vals, vec!["alice", "bob", "NULL", "dave", "eve", "NULL"]); + assert_eq!( + name_vals, + vec!["alice", "bob", "NULL", "dave", "eve", "NULL"] + ); // "country" only in file 3 let file = File::open(&output).unwrap(); - let reader = ParquetRecordBatchReaderBuilder::try_new(file).unwrap().build().unwrap(); + let reader = ParquetRecordBatchReaderBuilder::try_new(file) + .unwrap() + .build() + .unwrap(); let mut country_vals = Vec::new(); for batch in reader { let batch = batch.unwrap(); let idx = batch.schema().index_of("country").unwrap(); - let arr = batch.column(idx).as_any().downcast_ref::().unwrap(); + let arr = batch + .column(idx) + .as_any() + .downcast_ref::() + .unwrap(); for i in 0..arr.len() { - if arr.is_null(i) { country_vals.push("NULL".to_string()); } - else { country_vals.push(arr.value(i).to_string()); } + if arr.is_null(i) { + country_vals.push("NULL".to_string()); + } else { + country_vals.push(arr.value(i).to_string()); + } } } - assert_eq!(country_vals, vec!["NULL", "NULL", "US", "NULL", "NULL", "UK"]); + assert_eq!( + country_vals, + vec!["NULL", "NULL", "US", "NULL", "NULL", "UK"] + ); // "extra" in files 2 and 3 let file = File::open(&output).unwrap(); - let reader = ParquetRecordBatchReaderBuilder::try_new(file).unwrap().build().unwrap(); + let reader = ParquetRecordBatchReaderBuilder::try_new(file) + .unwrap() + .build() + .unwrap(); let mut extra_vals = Vec::new(); for batch in reader { let batch = batch.unwrap(); let idx = batch.schema().index_of("extra").unwrap(); - let arr = batch.column(idx).as_any().downcast_ref::().unwrap(); + let arr = batch + .column(idx) + .as_any() + .downcast_ref::() + .unwrap(); for i in 0..arr.len() { - if arr.is_null(i) { extra_vals.push("NULL".to_string()); } - else { extra_vals.push(arr.value(i).to_string()); } + if arr.is_null(i) { + extra_vals.push("NULL".to_string()); + } else { + extra_vals.push(arr.value(i).to_string()); + } } } assert_eq!(extra_vals, vec!["NULL", "x2", "x3", "NULL", "x5", "x6"]); diff --git a/sandbox/plugins/parquet-data-format/src/main/rust/tests/sort_types_tests.rs b/sandbox/plugins/parquet-data-format/src/main/rust/tests/sort_types_tests.rs index 8163218a388f0..02832ba32f3d8 100644 --- a/sandbox/plugins/parquet-data-format/src/main/rust/tests/sort_types_tests.rs +++ b/sandbox/plugins/parquet-data-format/src/main/rust/tests/sort_types_tests.rs @@ -15,8 +15,8 @@ use std::sync::Arc; use arrow::array::*; use arrow::datatypes::{DataType, Field, Schema}; use opensearch_parquet_format::merge::merge_sorted; -use parquet::arrow::ArrowWriter; use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder; +use parquet::arrow::ArrowWriter; use tempfile::tempdir; /// Write a single RecordBatch to a new Parquet file. @@ -90,27 +90,54 @@ fn count_rows(path: &str) -> usize { #[test] fn test_merge_sort_by_int64() { - let schema = Arc::new(Schema::new(vec![ - Field::new("val", DataType::Int64, false), - ])); + let schema = Arc::new(Schema::new(vec![Field::new("val", DataType::Int64, false)])); // File A: [1, 3, 5] File B: [2, 4, 6] File C: [0, 7, 8] let batches = vec![ - RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(vec![1, 3, 5]))]).unwrap(), - RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(vec![2, 4, 6]))]).unwrap(), - RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(vec![0, 7, 8]))]).unwrap(), + RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int64Array::from(vec![1, 3, 5]))], + ) + .unwrap(), + RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int64Array::from(vec![2, 4, 6]))], + ) + .unwrap(), + RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int64Array::from(vec![0, 7, 8]))], + ) + .unwrap(), ]; let tmp = tempdir().unwrap(); - let files: Vec = batches.iter().enumerate().map(|(i, b)| { - let p = tmp.path().join(format!("input_{}.parquet", i)); - let s = p.to_string_lossy().to_string(); - write_parquet(&s, b); - s - }).collect(); - - let output = tmp.path().join("merged.parquet").to_string_lossy().to_string(); - merge_sorted(&files, &output, "test", &["val".into()], &[false], &[false], 0).unwrap(); + let files: Vec = batches + .iter() + .enumerate() + .map(|(i, b)| { + let p = tmp.path().join(format!("input_{}.parquet", i)); + let s = p.to_string_lossy().to_string(); + write_parquet(&s, b); + s + }) + .collect(); + + let output = tmp + .path() + .join("merged.parquet") + .to_string_lossy() + .to_string(); + merge_sorted( + &files, + &output, + "test", + &["val".into()], + &[false], + &[false], + 0, + ) + .unwrap(); let vals = read_primitive_col::(&output, "val"); let vals: Vec = vals.into_iter().map(|v| v.unwrap()).collect(); @@ -122,26 +149,49 @@ fn test_merge_sort_by_int64() { #[test] fn test_merge_sort_by_int64_with_nulls() { - let schema = Arc::new(Schema::new(vec![ - Field::new("val", DataType::Int64, true), - ])); + let schema = Arc::new(Schema::new(vec![Field::new("val", DataType::Int64, true)])); // Each file pre-sorted: nulls last, then ascending let batches = vec![ - RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(vec![Some(1), Some(5), None]))]).unwrap(), - RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(vec![Some(2), Some(4), None]))]).unwrap(), + RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int64Array::from(vec![Some(1), Some(5), None]))], + ) + .unwrap(), + RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int64Array::from(vec![Some(2), Some(4), None]))], + ) + .unwrap(), ]; let tmp = tempdir().unwrap(); - let files: Vec = batches.iter().enumerate().map(|(i, b)| { - let p = tmp.path().join(format!("input_{}.parquet", i)); - let s = p.to_string_lossy().to_string(); - write_parquet(&s, b); - s - }).collect(); - - let output = tmp.path().join("merged.parquet").to_string_lossy().to_string(); - merge_sorted(&files, &output, "test", &["val".into()], &[false], &[false], 0).unwrap(); + let files: Vec = batches + .iter() + .enumerate() + .map(|(i, b)| { + let p = tmp.path().join(format!("input_{}.parquet", i)); + let s = p.to_string_lossy().to_string(); + write_parquet(&s, b); + s + }) + .collect(); + + let output = tmp + .path() + .join("merged.parquet") + .to_string_lossy() + .to_string(); + merge_sorted( + &files, + &output, + "test", + &["val".into()], + &[false], + &[false], + 0, + ) + .unwrap(); let vals = read_primitive_col::(&output, "val"); assert_eq!(vals, vec![Some(1), Some(2), Some(4), Some(5), None, None]); @@ -151,25 +201,48 @@ fn test_merge_sort_by_int64_with_nulls() { #[test] fn test_merge_sort_by_int32() { - let schema = Arc::new(Schema::new(vec![ - Field::new("val", DataType::Int32, false), - ])); + let schema = Arc::new(Schema::new(vec![Field::new("val", DataType::Int32, false)])); let batches = vec![ - RecordBatch::try_new(schema.clone(), vec![Arc::new(Int32Array::from(vec![10, 30]))]).unwrap(), - RecordBatch::try_new(schema.clone(), vec![Arc::new(Int32Array::from(vec![20, 40]))]).unwrap(), + RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int32Array::from(vec![10, 30]))], + ) + .unwrap(), + RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int32Array::from(vec![20, 40]))], + ) + .unwrap(), ]; let tmp = tempdir().unwrap(); - let files: Vec = batches.iter().enumerate().map(|(i, b)| { - let p = tmp.path().join(format!("input_{}.parquet", i)); - let s = p.to_string_lossy().to_string(); - write_parquet(&s, b); - s - }).collect(); - - let output = tmp.path().join("merged.parquet").to_string_lossy().to_string(); - merge_sorted(&files, &output, "test", &["val".into()], &[false], &[false], 0).unwrap(); + let files: Vec = batches + .iter() + .enumerate() + .map(|(i, b)| { + let p = tmp.path().join(format!("input_{}.parquet", i)); + let s = p.to_string_lossy().to_string(); + write_parquet(&s, b); + s + }) + .collect(); + + let output = tmp + .path() + .join("merged.parquet") + .to_string_lossy() + .to_string(); + merge_sorted( + &files, + &output, + "test", + &["val".into()], + &[false], + &[false], + 0, + ) + .unwrap(); let vals = read_primitive_col::(&output, "val"); let vals: Vec = vals.into_iter().map(|v| v.unwrap()).collect(); @@ -180,25 +253,52 @@ fn test_merge_sort_by_int32() { #[test] fn test_merge_sort_by_float64() { - let schema = Arc::new(Schema::new(vec![ - Field::new("val", DataType::Float64, false), - ])); + let schema = Arc::new(Schema::new(vec![Field::new( + "val", + DataType::Float64, + false, + )])); let batches = vec![ - RecordBatch::try_new(schema.clone(), vec![Arc::new(Float64Array::from(vec![1.1, 3.3, 5.5]))]).unwrap(), - RecordBatch::try_new(schema.clone(), vec![Arc::new(Float64Array::from(vec![2.2, 4.4, 6.6]))]).unwrap(), + RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Float64Array::from(vec![1.1, 3.3, 5.5]))], + ) + .unwrap(), + RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Float64Array::from(vec![2.2, 4.4, 6.6]))], + ) + .unwrap(), ]; let tmp = tempdir().unwrap(); - let files: Vec = batches.iter().enumerate().map(|(i, b)| { - let p = tmp.path().join(format!("input_{}.parquet", i)); - let s = p.to_string_lossy().to_string(); - write_parquet(&s, b); - s - }).collect(); - - let output = tmp.path().join("merged.parquet").to_string_lossy().to_string(); - merge_sorted(&files, &output, "test", &["val".into()], &[false], &[false], 0).unwrap(); + let files: Vec = batches + .iter() + .enumerate() + .map(|(i, b)| { + let p = tmp.path().join(format!("input_{}.parquet", i)); + let s = p.to_string_lossy().to_string(); + write_parquet(&s, b); + s + }) + .collect(); + + let output = tmp + .path() + .join("merged.parquet") + .to_string_lossy() + .to_string(); + merge_sorted( + &files, + &output, + "test", + &["val".into()], + &[false], + &[false], + 0, + ) + .unwrap(); let vals = read_primitive_col::(&output, "val"); let vals: Vec = vals.into_iter().map(|v| v.unwrap()).collect(); @@ -209,53 +309,118 @@ fn test_merge_sort_by_float64() { #[test] fn test_merge_sort_by_float64_with_nulls() { - let schema = Arc::new(Schema::new(vec![ - Field::new("val", DataType::Float64, true), - ])); + let schema = Arc::new(Schema::new(vec![Field::new( + "val", + DataType::Float64, + true, + )])); let batches = vec![ - RecordBatch::try_new(schema.clone(), vec![Arc::new(Float64Array::from(vec![None, Some(1.5), Some(4.0)]))]).unwrap(), - RecordBatch::try_new(schema.clone(), vec![Arc::new(Float64Array::from(vec![None, Some(2.5), Some(3.0)]))]).unwrap(), + RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Float64Array::from(vec![ + None, + Some(1.5), + Some(4.0), + ]))], + ) + .unwrap(), + RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Float64Array::from(vec![ + None, + Some(2.5), + Some(3.0), + ]))], + ) + .unwrap(), ]; let tmp = tempdir().unwrap(); - let files: Vec = batches.iter().enumerate().map(|(i, b)| { - let p = tmp.path().join(format!("input_{}.parquet", i)); - let s = p.to_string_lossy().to_string(); - write_parquet(&s, b); - s - }).collect(); - - let output = tmp.path().join("merged.parquet").to_string_lossy().to_string(); - merge_sorted(&files, &output, "test", &["val".into()], &[false], &[true], 0).unwrap(); + let files: Vec = batches + .iter() + .enumerate() + .map(|(i, b)| { + let p = tmp.path().join(format!("input_{}.parquet", i)); + let s = p.to_string_lossy().to_string(); + write_parquet(&s, b); + s + }) + .collect(); + + let output = tmp + .path() + .join("merged.parquet") + .to_string_lossy() + .to_string(); + merge_sorted( + &files, + &output, + "test", + &["val".into()], + &[false], + &[true], + 0, + ) + .unwrap(); let vals = read_primitive_col::(&output, "val"); - assert_eq!(vals, vec![None, None, Some(1.5), Some(2.5), Some(3.0), Some(4.0)]); + assert_eq!( + vals, + vec![None, None, Some(1.5), Some(2.5), Some(3.0), Some(4.0)] + ); } // ─── Float32 ──────────────────────────────────────────────────────────────── #[test] fn test_merge_sort_by_float32() { - let schema = Arc::new(Schema::new(vec![ - Field::new("val", DataType::Float32, false), - ])); + let schema = Arc::new(Schema::new(vec![Field::new( + "val", + DataType::Float32, + false, + )])); let batches = vec![ - RecordBatch::try_new(schema.clone(), vec![Arc::new(Float32Array::from(vec![1.0f32, 3.0]))]).unwrap(), - RecordBatch::try_new(schema.clone(), vec![Arc::new(Float32Array::from(vec![2.0f32, 4.0]))]).unwrap(), + RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Float32Array::from(vec![1.0f32, 3.0]))], + ) + .unwrap(), + RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Float32Array::from(vec![2.0f32, 4.0]))], + ) + .unwrap(), ]; let tmp = tempdir().unwrap(); - let files: Vec = batches.iter().enumerate().map(|(i, b)| { - let p = tmp.path().join(format!("input_{}.parquet", i)); - let s = p.to_string_lossy().to_string(); - write_parquet(&s, b); - s - }).collect(); - - let output = tmp.path().join("merged.parquet").to_string_lossy().to_string(); - merge_sorted(&files, &output, "test", &["val".into()], &[false], &[false], 0).unwrap(); + let files: Vec = batches + .iter() + .enumerate() + .map(|(i, b)| { + let p = tmp.path().join(format!("input_{}.parquet", i)); + let s = p.to_string_lossy().to_string(); + write_parquet(&s, b); + s + }) + .collect(); + + let output = tmp + .path() + .join("merged.parquet") + .to_string_lossy() + .to_string(); + merge_sorted( + &files, + &output, + "test", + &["val".into()], + &[false], + &[false], + 0, + ) + .unwrap(); let vals = read_primitive_col::(&output, "val"); let vals: Vec = vals.into_iter().map(|v| v.unwrap()).collect(); @@ -266,116 +431,240 @@ fn test_merge_sort_by_float32() { #[test] fn test_merge_sort_by_float32_with_nulls() { - let schema = Arc::new(Schema::new(vec![ - Field::new("val", DataType::Float32, true), - ])); + let schema = Arc::new(Schema::new(vec![Field::new( + "val", + DataType::Float32, + true, + )])); let batches = vec![ - RecordBatch::try_new(schema.clone(), vec![Arc::new(Float32Array::from(vec![Some(1.0f32), Some(3.0), None]))]).unwrap(), - RecordBatch::try_new(schema.clone(), vec![Arc::new(Float32Array::from(vec![Some(2.0f32), None, None]))]).unwrap(), + RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Float32Array::from(vec![ + Some(1.0f32), + Some(3.0), + None, + ]))], + ) + .unwrap(), + RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Float32Array::from(vec![Some(2.0f32), None, None]))], + ) + .unwrap(), ]; let tmp = tempdir().unwrap(); - let files: Vec = batches.iter().enumerate().map(|(i, b)| { - let p = tmp.path().join(format!("input_{}.parquet", i)); - let s = p.to_string_lossy().to_string(); - write_parquet(&s, b); - s - }).collect(); - - let output = tmp.path().join("merged.parquet").to_string_lossy().to_string(); - merge_sorted(&files, &output, "test", &["val".into()], &[false], &[false], 0).unwrap(); + let files: Vec = batches + .iter() + .enumerate() + .map(|(i, b)| { + let p = tmp.path().join(format!("input_{}.parquet", i)); + let s = p.to_string_lossy().to_string(); + write_parquet(&s, b); + s + }) + .collect(); + + let output = tmp + .path() + .join("merged.parquet") + .to_string_lossy() + .to_string(); + merge_sorted( + &files, + &output, + "test", + &["val".into()], + &[false], + &[false], + 0, + ) + .unwrap(); let vals = read_primitive_col::(&output, "val"); - assert_eq!(vals, vec![Some(1.0), Some(2.0), Some(3.0), None, None, None]); + assert_eq!( + vals, + vec![Some(1.0), Some(2.0), Some(3.0), None, None, None] + ); } // ─── Utf8 (String / keyword) ─────────────────────────────────────────────── #[test] fn test_merge_sort_by_string() { - let schema = Arc::new(Schema::new(vec![ - Field::new("val", DataType::Utf8, false), - ])); + let schema = Arc::new(Schema::new(vec![Field::new("val", DataType::Utf8, false)])); let batches = vec![ - RecordBatch::try_new(schema.clone(), vec![Arc::new(StringArray::from(vec!["apple", "cherry", "fig"]))]).unwrap(), - RecordBatch::try_new(schema.clone(), vec![Arc::new(StringArray::from(vec!["banana", "date", "grape"]))]).unwrap(), + RecordBatch::try_new( + schema.clone(), + vec![Arc::new(StringArray::from(vec!["apple", "cherry", "fig"]))], + ) + .unwrap(), + RecordBatch::try_new( + schema.clone(), + vec![Arc::new(StringArray::from(vec!["banana", "date", "grape"]))], + ) + .unwrap(), ]; let tmp = tempdir().unwrap(); - let files: Vec = batches.iter().enumerate().map(|(i, b)| { - let p = tmp.path().join(format!("input_{}.parquet", i)); - let s = p.to_string_lossy().to_string(); - write_parquet(&s, b); - s - }).collect(); - - let output = tmp.path().join("merged.parquet").to_string_lossy().to_string(); - merge_sorted(&files, &output, "test", &["val".into()], &[false], &[false], 0).unwrap(); + let files: Vec = batches + .iter() + .enumerate() + .map(|(i, b)| { + let p = tmp.path().join(format!("input_{}.parquet", i)); + let s = p.to_string_lossy().to_string(); + write_parquet(&s, b); + s + }) + .collect(); + + let output = tmp + .path() + .join("merged.parquet") + .to_string_lossy() + .to_string(); + merge_sorted( + &files, + &output, + "test", + &["val".into()], + &[false], + &[false], + 0, + ) + .unwrap(); let vals = read_string_col(&output, "val"); let vals: Vec = vals.into_iter().map(|v| v.unwrap()).collect(); - assert_eq!(vals, vec!["apple", "banana", "cherry", "date", "fig", "grape"]); + assert_eq!( + vals, + vec!["apple", "banana", "cherry", "date", "fig", "grape"] + ); } // ─── Utf8 with nulls ──────────────────────────────────────────────────────── #[test] fn test_merge_sort_by_string_with_nulls() { - let schema = Arc::new(Schema::new(vec![ - Field::new("val", DataType::Utf8, true), - ])); + let schema = Arc::new(Schema::new(vec![Field::new("val", DataType::Utf8, true)])); let batches = vec![ - RecordBatch::try_new(schema.clone(), vec![ - Arc::new(StringArray::from(vec![None, Some("banana"), Some("fig")])), - ]).unwrap(), - RecordBatch::try_new(schema.clone(), vec![ - Arc::new(StringArray::from(vec![None, Some("apple"), Some("cherry")])), - ]).unwrap(), + RecordBatch::try_new( + schema.clone(), + vec![Arc::new(StringArray::from(vec![ + None, + Some("banana"), + Some("fig"), + ]))], + ) + .unwrap(), + RecordBatch::try_new( + schema.clone(), + vec![Arc::new(StringArray::from(vec![ + None, + Some("apple"), + Some("cherry"), + ]))], + ) + .unwrap(), ]; let tmp = tempdir().unwrap(); - let files: Vec = batches.iter().enumerate().map(|(i, b)| { - let p = tmp.path().join(format!("input_{}.parquet", i)); - let s = p.to_string_lossy().to_string(); - write_parquet(&s, b); - s - }).collect(); - - let output = tmp.path().join("merged.parquet").to_string_lossy().to_string(); - merge_sorted(&files, &output, "test", &["val".into()], &[false], &[true], 0).unwrap(); + let files: Vec = batches + .iter() + .enumerate() + .map(|(i, b)| { + let p = tmp.path().join(format!("input_{}.parquet", i)); + let s = p.to_string_lossy().to_string(); + write_parquet(&s, b); + s + }) + .collect(); + + let output = tmp + .path() + .join("merged.parquet") + .to_string_lossy() + .to_string(); + merge_sorted( + &files, + &output, + "test", + &["val".into()], + &[false], + &[true], + 0, + ) + .unwrap(); let vals = read_string_col(&output, "val"); - assert_eq!(vals, vec![None, None, Some("apple".into()), Some("banana".into()), Some("cherry".into()), Some("fig".into())]); + assert_eq!( + vals, + vec![ + None, + None, + Some("apple".into()), + Some("banana".into()), + Some("cherry".into()), + Some("fig".into()) + ] + ); } // ─── Descending sort ──────────────────────────────────────────────────────── #[test] fn test_merge_sort_descending() { - let schema = Arc::new(Schema::new(vec![ - Field::new("val", DataType::Int64, false), - ])); + let schema = Arc::new(Schema::new(vec![Field::new("val", DataType::Int64, false)])); // Each file sorted descending let batches = vec![ - RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(vec![8, 5, 2]))]).unwrap(), - RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(vec![7, 4, 1]))]).unwrap(), - RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(vec![9, 6, 3]))]).unwrap(), + RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int64Array::from(vec![8, 5, 2]))], + ) + .unwrap(), + RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int64Array::from(vec![7, 4, 1]))], + ) + .unwrap(), + RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int64Array::from(vec![9, 6, 3]))], + ) + .unwrap(), ]; let tmp = tempdir().unwrap(); - let files: Vec = batches.iter().enumerate().map(|(i, b)| { - let p = tmp.path().join(format!("input_{}.parquet", i)); - let s = p.to_string_lossy().to_string(); - write_parquet(&s, b); - s - }).collect(); - - let output = tmp.path().join("merged.parquet").to_string_lossy().to_string(); - merge_sorted(&files, &output, "test", &["val".into()], &[true], &[false], 0).unwrap(); + let files: Vec = batches + .iter() + .enumerate() + .map(|(i, b)| { + let p = tmp.path().join(format!("input_{}.parquet", i)); + let s = p.to_string_lossy().to_string(); + write_parquet(&s, b); + s + }) + .collect(); + + let output = tmp + .path() + .join("merged.parquet") + .to_string_lossy() + .to_string(); + merge_sorted( + &files, + &output, + "test", + &["val".into()], + &[true], + &[false], + 0, + ) + .unwrap(); let vals = read_primitive_col::(&output, "val"); let vals: Vec = vals.into_iter().map(|v| v.unwrap()).collect(); @@ -395,39 +684,61 @@ fn test_merge_sort_multi_column_string_and_int() { // File B: (alpha,2), (beta,2), (beta,3) // Sorted by (category ASC, priority ASC) let batches = vec![ - RecordBatch::try_new(schema.clone(), vec![ - Arc::new(StringArray::from(vec!["alpha", "alpha", "beta"])), - Arc::new(Int64Array::from(vec![1, 3, 1])), - ]).unwrap(), - RecordBatch::try_new(schema.clone(), vec![ - Arc::new(StringArray::from(vec!["alpha", "beta", "beta"])), - Arc::new(Int64Array::from(vec![2, 2, 3])), - ]).unwrap(), + RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(StringArray::from(vec!["alpha", "alpha", "beta"])), + Arc::new(Int64Array::from(vec![1, 3, 1])), + ], + ) + .unwrap(), + RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(StringArray::from(vec!["alpha", "beta", "beta"])), + Arc::new(Int64Array::from(vec![2, 2, 3])), + ], + ) + .unwrap(), ]; let tmp = tempdir().unwrap(); - let files: Vec = batches.iter().enumerate().map(|(i, b)| { - let p = tmp.path().join(format!("input_{}.parquet", i)); - let s = p.to_string_lossy().to_string(); - write_parquet(&s, b); - s - }).collect(); - - let output = tmp.path().join("merged.parquet").to_string_lossy().to_string(); + let files: Vec = batches + .iter() + .enumerate() + .map(|(i, b)| { + let p = tmp.path().join(format!("input_{}.parquet", i)); + let s = p.to_string_lossy().to_string(); + write_parquet(&s, b); + s + }) + .collect(); + + let output = tmp + .path() + .join("merged.parquet") + .to_string_lossy() + .to_string(); merge_sorted( - &files, &output, "test", + &files, + &output, + "test", &["category".into(), "priority".into()], &[false, false], &[false, false], 0, - ).unwrap(); + ) + .unwrap(); let cats = read_string_col(&output, "category"); let cats: Vec = cats.into_iter().map(|v| v.unwrap()).collect(); let pris = read_primitive_col::(&output, "priority"); let pris: Vec = pris.into_iter().map(|v| v.unwrap()).collect(); - assert_eq!(cats, vec!["alpha", "alpha", "alpha", "beta", "beta", "beta"]); + assert_eq!( + cats, + vec!["alpha", "alpha", "alpha", "beta", "beta", "beta"] + ); assert_eq!(pris, vec![1, 2, 3, 1, 2, 3]); } @@ -435,31 +746,50 @@ fn test_merge_sort_multi_column_string_and_int() { #[test] fn test_merge_sort_with_nulls_first() { - let schema = Arc::new(Schema::new(vec![ - Field::new("val", DataType::Int64, true), - ])); + let schema = Arc::new(Schema::new(vec![Field::new("val", DataType::Int64, true)])); // Each file pre-sorted with nulls first, then ascending // File A: [null, 2, 5] File B: [null, 1, 4] let batches = vec![ - RecordBatch::try_new(schema.clone(), vec![ - Arc::new(Int64Array::from(vec![None, Some(2), Some(5)])), - ]).unwrap(), - RecordBatch::try_new(schema.clone(), vec![ - Arc::new(Int64Array::from(vec![None, Some(1), Some(4)])), - ]).unwrap(), + RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int64Array::from(vec![None, Some(2), Some(5)]))], + ) + .unwrap(), + RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int64Array::from(vec![None, Some(1), Some(4)]))], + ) + .unwrap(), ]; let tmp = tempdir().unwrap(); - let files: Vec = batches.iter().enumerate().map(|(i, b)| { - let p = tmp.path().join(format!("input_{}.parquet", i)); - let s = p.to_string_lossy().to_string(); - write_parquet(&s, b); - s - }).collect(); - - let output = tmp.path().join("merged.parquet").to_string_lossy().to_string(); - merge_sorted(&files, &output, "test", &["val".into()], &[false], &[true], 0).unwrap(); + let files: Vec = batches + .iter() + .enumerate() + .map(|(i, b)| { + let p = tmp.path().join(format!("input_{}.parquet", i)); + let s = p.to_string_lossy().to_string(); + write_parquet(&s, b); + s + }) + .collect(); + + let output = tmp + .path() + .join("merged.parquet") + .to_string_lossy() + .to_string(); + merge_sorted( + &files, + &output, + "test", + &["val".into()], + &[false], + &[true], + 0, + ) + .unwrap(); let vals = read_primitive_col::(&output, "val"); // nulls_first=true → nulls come first, then ascending @@ -468,29 +798,48 @@ fn test_merge_sort_with_nulls_first() { #[test] fn test_merge_sort_with_nulls_last() { - let schema = Arc::new(Schema::new(vec![ - Field::new("val", DataType::Int64, true), - ])); + let schema = Arc::new(Schema::new(vec![Field::new("val", DataType::Int64, true)])); let batches = vec![ - RecordBatch::try_new(schema.clone(), vec![ - Arc::new(Int64Array::from(vec![Some(1), Some(3), None])), - ]).unwrap(), - RecordBatch::try_new(schema.clone(), vec![ - Arc::new(Int64Array::from(vec![Some(2), None, None])), - ]).unwrap(), + RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int64Array::from(vec![Some(1), Some(3), None]))], + ) + .unwrap(), + RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int64Array::from(vec![Some(2), None, None]))], + ) + .unwrap(), ]; let tmp = tempdir().unwrap(); - let files: Vec = batches.iter().enumerate().map(|(i, b)| { - let p = tmp.path().join(format!("input_{}.parquet", i)); - let s = p.to_string_lossy().to_string(); - write_parquet(&s, b); - s - }).collect(); - - let output = tmp.path().join("merged.parquet").to_string_lossy().to_string(); - merge_sorted(&files, &output, "test", &["val".into()], &[false], &[false], 0).unwrap(); + let files: Vec = batches + .iter() + .enumerate() + .map(|(i, b)| { + let p = tmp.path().join(format!("input_{}.parquet", i)); + let s = p.to_string_lossy().to_string(); + write_parquet(&s, b); + s + }) + .collect(); + + let output = tmp + .path() + .join("merged.parquet") + .to_string_lossy() + .to_string(); + merge_sorted( + &files, + &output, + "test", + &["val".into()], + &[false], + &[false], + 0, + ) + .unwrap(); let vals = read_primitive_col::(&output, "val"); // nulls_first=false → values ascending, then nulls diff --git a/sandbox/plugins/parquet-data-format/src/main/rust/tests/writer_integration_tests.rs b/sandbox/plugins/parquet-data-format/src/main/rust/tests/writer_integration_tests.rs index a74ccef3f66bb..cc6b41fe94df4 100644 --- a/sandbox/plugins/parquet-data-format/src/main/rust/tests/writer_integration_tests.rs +++ b/sandbox/plugins/parquet-data-format/src/main/rust/tests/writer_integration_tests.rs @@ -32,8 +32,14 @@ fn test_complete_writer_lifecycle() { assert!(file_path.metadata().unwrap().len() > 0); let read_metadata = NativeParquetWriter::get_file_metadata(filename.clone()).unwrap(); - assert_eq!(read_metadata.file_metadata().num_rows(), metadata.metadata.file_metadata().num_rows()); - assert_eq!(read_metadata.file_metadata().version(), metadata.metadata.file_metadata().version()); + assert_eq!( + read_metadata.file_metadata().num_rows(), + metadata.metadata.file_metadata().num_rows() + ); + assert_eq!( + read_metadata.file_metadata().version(), + metadata.metadata.file_metadata().version() + ); } #[test] @@ -49,7 +55,17 @@ fn test_concurrent_writer_creation() { let file_path = temp_dir_path.join(format!("concurrent_{}.parquet", i)); let filename = file_path.to_string_lossy().to_string(); let (_schema, schema_ptr) = create_test_ffi_schema(); - if NativeParquetWriter::create_writer(filename.clone(), "test-index".to_string(), schema_ptr, vec![], vec![], vec![], 0).is_ok() { + if NativeParquetWriter::create_writer( + filename.clone(), + "test-index".to_string(), + schema_ptr, + vec![], + vec![], + vec![], + 0, + ) + .is_ok() + { success_count.fetch_add(1, Ordering::SeqCst); let _ = NativeParquetWriter::finalize_writer(filename); } @@ -126,7 +142,9 @@ fn test_concurrent_writes_different_files() { let mut schema_ptrs = vec![]; for i in 0..file_count { - let file_path = temp_dir.path().join(format!("concurrent_write_{}.parquet", i)); + let file_path = temp_dir + .path() + .join(format!("concurrent_write_{}.parquet", i)); let filename = file_path.to_string_lossy().to_string(); let (_schema, schema_ptr) = create_writer_and_assert_success(&filename); filenames.push(filename); @@ -139,7 +157,9 @@ fn test_concurrent_writes_different_files() { let handle = thread::spawn(move || { for _ in 0..2 { let (array_ptr, data_schema_ptr) = create_test_ffi_data().unwrap(); - if NativeParquetWriter::write_data(filename.clone(), array_ptr, data_schema_ptr).is_ok() { + if NativeParquetWriter::write_data(filename.clone(), array_ptr, data_schema_ptr) + .is_ok() + { success_count.fetch_add(1, Ordering::SeqCst); } cleanup_ffi_data(array_ptr, data_schema_ptr); @@ -172,17 +192,28 @@ fn test_concurrent_complete_writer_lifecycle() { let filename = file_path.to_string_lossy().to_string(); let (_schema, schema_ptr) = create_test_ffi_schema(); - if NativeParquetWriter::create_writer(filename.clone(), "test-index".to_string(), schema_ptr, vec![], vec![], vec![], 0).is_ok() { + if NativeParquetWriter::create_writer( + filename.clone(), + "test-index".to_string(), + schema_ptr, + vec![], + vec![], + vec![], + 0, + ) + .is_ok() + { let (array_ptr, data_schema_ptr) = create_test_ffi_data().unwrap(); - let write_ok = NativeParquetWriter::write_data(filename.clone(), array_ptr, data_schema_ptr).is_ok(); + let write_ok = + NativeParquetWriter::write_data(filename.clone(), array_ptr, data_schema_ptr) + .is_ok(); cleanup_ffi_data(array_ptr, data_schema_ptr); if write_ok { - if let Ok(Some(metadata)) = NativeParquetWriter::finalize_writer(filename.clone()) { - if metadata.metadata.file_metadata().num_rows() == 3 - - && file_path.exists() - { + if let Ok(Some(metadata)) = + NativeParquetWriter::finalize_writer(filename.clone()) + { + if metadata.metadata.file_metadata().num_rows() == 3 && file_path.exists() { success_count.fetch_add(1, Ordering::SeqCst); } } @@ -207,9 +238,15 @@ fn test_ipc_staging_sorted_writer_integration() { let (_schema, schema_ptr) = create_test_ffi_schema(); NativeParquetWriter::create_writer( - filename.clone(), "test-index".to_string(), schema_ptr, - vec!["id".to_string()], vec![false], vec![false], 0 - ).unwrap(); + filename.clone(), + "test-index".to_string(), + schema_ptr, + vec!["id".to_string()], + vec![false], + vec![false], + 0, + ) + .unwrap(); // Write multiple batches with out-of-order data for batch_ids in [vec![50, 30, 10], vec![40, 20, 60]] { @@ -224,7 +261,6 @@ fn test_ipc_staging_sorted_writer_integration() { let metadata = result.unwrap().unwrap(); assert_eq!(metadata.metadata.file_metadata().num_rows(), 6); - let ids = read_parquet_file_sorted_ids(&filename); assert_eq!(ids, vec![10, 20, 30, 40, 50, 60]); @@ -250,21 +286,29 @@ fn test_ipc_staging_concurrent_sorted_lifecycle() { let (_schema, schema_ptr) = create_test_ffi_schema(); if NativeParquetWriter::create_writer( - filename.clone(), "test-index".to_string(), schema_ptr, - vec!["id".to_string()], vec![false], vec![false], 0 - ).is_ok() { + filename.clone(), + "test-index".to_string(), + schema_ptr, + vec!["id".to_string()], + vec![false], + vec![false], + 0, + ) + .is_ok() + { let (ap, sp) = create_test_ffi_data_with_ids( - vec![30, 10, 20], vec![Some("C"), Some("A"), Some("B")] - ).unwrap(); + vec![30, 10, 20], + vec![Some("C"), Some("A"), Some("B")], + ) + .unwrap(); let write_ok = NativeParquetWriter::write_data(filename.clone(), ap, sp).is_ok(); cleanup_ffi_data(ap, sp); if write_ok { - if let Ok(Some(metadata)) = NativeParquetWriter::finalize_writer(filename.clone()) { - if metadata.metadata.file_metadata().num_rows() == 3 - - && file_path.exists() - { + if let Ok(Some(metadata)) = + NativeParquetWriter::finalize_writer(filename.clone()) + { + if metadata.metadata.file_metadata().num_rows() == 3 && file_path.exists() { let ids = read_parquet_file_sorted_ids(&filename); if ids == vec![10, 20, 30] { success_count.fetch_add(1, Ordering::SeqCst); @@ -301,25 +345,44 @@ fn test_ipc_and_parquet_mixed_concurrent_lifecycle() { let filename = file_path.to_string_lossy().to_string(); let (_schema, schema_ptr) = create_test_ffi_schema(); - let sort_cols = if use_sort { vec!["id".to_string()] } else { vec![] }; + let sort_cols = if use_sort { + vec!["id".to_string()] + } else { + vec![] + }; let reverse = if use_sort { vec![false] } else { vec![] }; let nulls = if use_sort { vec![false] } else { vec![] }; if NativeParquetWriter::create_writer( - filename.clone(), "test-index".to_string(), schema_ptr, - sort_cols, reverse, nulls, 0 - ).is_ok() { + filename.clone(), + "test-index".to_string(), + schema_ptr, + sort_cols, + reverse, + nulls, + 0, + ) + .is_ok() + { let (ap, sp) = create_test_ffi_data_with_ids( - vec![30, 10, 20], vec![Some("C"), Some("A"), Some("B")] - ).unwrap(); + vec![30, 10, 20], + vec![Some("C"), Some("A"), Some("B")], + ) + .unwrap(); let write_ok = NativeParquetWriter::write_data(filename.clone(), ap, sp).is_ok(); cleanup_ffi_data(ap, sp); if write_ok { - if let Ok(Some(metadata)) = NativeParquetWriter::finalize_writer(filename.clone()) { + if let Ok(Some(metadata)) = + NativeParquetWriter::finalize_writer(filename.clone()) + { if metadata.metadata.file_metadata().num_rows() == 3 && file_path.exists() { let ids = read_parquet_file_sorted_ids(&filename); - let expected = if use_sort { vec![10, 20, 30] } else { vec![30, 10, 20] }; + let expected = if use_sort { + vec![10, 20, 30] + } else { + vec![30, 10, 20] + }; if ids == expected { success_count.fetch_add(1, Ordering::SeqCst); } @@ -340,8 +403,8 @@ fn test_ipc_and_parquet_mixed_concurrent_lifecycle() { // ===== Two-tier encoding/compression settings tests ===== -use opensearch_parquet_format::native_settings::NativeSettings; use opensearch_parquet_format::field_config::FieldConfig; +use opensearch_parquet_format::native_settings::NativeSettings; use opensearch_parquet_format::SETTINGS_STORE; use parquet::basic::{Compression, Encoding}; use parquet::file::reader::{FileReader, SerializedFileReader}; @@ -380,15 +443,21 @@ fn read_column_encoding(path: &str, col_name: &str) -> Vec { fn test_index_level_field_compression_applied() { let index = "test_index_field_compression"; let mut field_configs = HashMap::new(); - field_configs.insert("id".to_string(), FieldConfig { - compression_type: Some("SNAPPY".to_string()), - ..Default::default() - }); - SETTINGS_STORE.insert(index.to_string(), NativeSettings { - index_name: Some(index.to_string()), - field_configs: Some(field_configs), - ..Default::default() - }); + field_configs.insert( + "id".to_string(), + FieldConfig { + compression_type: Some("SNAPPY".to_string()), + ..Default::default() + }, + ); + SETTINGS_STORE.insert( + index.to_string(), + NativeSettings { + index_name: Some(index.to_string()), + field_configs: Some(field_configs), + ..Default::default() + }, + ); let (_tmp, path) = get_temp_file_path("idx_field_comp.parquet"); let (_schema, schema_ptr) = create_writer_and_assert_success_for_index(&path, index); @@ -398,7 +467,10 @@ fn test_index_level_field_compression_applied() { NativeParquetWriter::finalize_writer(path.clone()).unwrap(); cleanup_ffi_schema(schema_ptr); - assert!(matches!(read_column_compression(&path, "id"), Compression::SNAPPY)); + assert!(matches!( + read_column_compression(&path, "id"), + Compression::SNAPPY + )); SETTINGS_STORE.remove(index); } @@ -408,11 +480,14 @@ fn test_cluster_level_type_compression_fallback() { let index = "test_cluster_type_compression"; let mut type_compression = HashMap::new(); type_compression.insert("int32".to_string(), "SNAPPY".to_string()); - SETTINGS_STORE.insert(index.to_string(), NativeSettings { - index_name: Some(index.to_string()), - type_compression_configs: Some(type_compression), - ..Default::default() - }); + SETTINGS_STORE.insert( + index.to_string(), + NativeSettings { + index_name: Some(index.to_string()), + type_compression_configs: Some(type_compression), + ..Default::default() + }, + ); let (_tmp, path) = get_temp_file_path("cluster_type_comp.parquet"); let (_schema, schema_ptr) = create_writer_and_assert_success_for_index(&path, index); @@ -423,7 +498,10 @@ fn test_cluster_level_type_compression_fallback() { cleanup_ffi_schema(schema_ptr); // "id" is Int32 → should get SNAPPY from cluster type config - assert!(matches!(read_column_compression(&path, "id"), Compression::SNAPPY)); + assert!(matches!( + read_column_compression(&path, "id"), + Compression::SNAPPY + )); SETTINGS_STORE.remove(index); } @@ -432,18 +510,24 @@ fn test_cluster_level_type_compression_fallback() { fn test_index_level_overrides_cluster_level_compression() { let index = "test_index_overrides_cluster_compression"; let mut field_configs = HashMap::new(); - field_configs.insert("id".to_string(), FieldConfig { - compression_type: Some("UNCOMPRESSED".to_string()), - ..Default::default() - }); + field_configs.insert( + "id".to_string(), + FieldConfig { + compression_type: Some("UNCOMPRESSED".to_string()), + ..Default::default() + }, + ); let mut type_compression = HashMap::new(); type_compression.insert("int32".to_string(), "SNAPPY".to_string()); - SETTINGS_STORE.insert(index.to_string(), NativeSettings { - index_name: Some(index.to_string()), - field_configs: Some(field_configs), - type_compression_configs: Some(type_compression), - ..Default::default() - }); + SETTINGS_STORE.insert( + index.to_string(), + NativeSettings { + index_name: Some(index.to_string()), + field_configs: Some(field_configs), + type_compression_configs: Some(type_compression), + ..Default::default() + }, + ); let (_tmp, path) = get_temp_file_path("idx_overrides_cluster_comp.parquet"); let (_schema, schema_ptr) = create_writer_and_assert_success_for_index(&path, index); @@ -454,7 +538,10 @@ fn test_index_level_overrides_cluster_level_compression() { cleanup_ffi_schema(schema_ptr); // Index says UNCOMPRESSED for "id", cluster says SNAPPY for int32 — index wins - assert!(matches!(read_column_compression(&path, "id"), Compression::UNCOMPRESSED)); + assert!(matches!( + read_column_compression(&path, "id"), + Compression::UNCOMPRESSED + )); SETTINGS_STORE.remove(index); } @@ -464,11 +551,14 @@ fn test_cluster_level_type_encoding_fallback() { let index = "test_cluster_type_encoding"; let mut type_encoding = HashMap::new(); type_encoding.insert("int32".to_string(), "DELTA_BINARY_PACKED".to_string()); - SETTINGS_STORE.insert(index.to_string(), NativeSettings { - index_name: Some(index.to_string()), - type_encoding_configs: Some(type_encoding), - ..Default::default() - }); + SETTINGS_STORE.insert( + index.to_string(), + NativeSettings { + index_name: Some(index.to_string()), + type_encoding_configs: Some(type_encoding), + ..Default::default() + }, + ); let (_tmp, path) = get_temp_file_path("cluster_type_enc.parquet"); let (_schema, schema_ptr) = create_writer_and_assert_success_for_index(&path, index); @@ -480,8 +570,11 @@ fn test_cluster_level_type_encoding_fallback() { // "id" is Int32 → should get DELTA_BINARY_PACKED from cluster type config let encodings = read_column_encoding(&path, "id"); - assert!(encodings.contains(&Encoding::DELTA_BINARY_PACKED), - "expected DELTA_BINARY_PACKED in {:?}", encodings); + assert!( + encodings.contains(&Encoding::DELTA_BINARY_PACKED), + "expected DELTA_BINARY_PACKED in {:?}", + encodings + ); SETTINGS_STORE.remove(index); } @@ -490,18 +583,24 @@ fn test_cluster_level_type_encoding_fallback() { fn test_index_level_overrides_cluster_level_encoding() { let index = "test_index_overrides_cluster_encoding"; let mut field_configs = HashMap::new(); - field_configs.insert("id".to_string(), FieldConfig { - encoding_type: Some("PLAIN".to_string()), - ..Default::default() - }); + field_configs.insert( + "id".to_string(), + FieldConfig { + encoding_type: Some("PLAIN".to_string()), + ..Default::default() + }, + ); let mut type_encoding = HashMap::new(); type_encoding.insert("int32".to_string(), "DELTA_BINARY_PACKED".to_string()); - SETTINGS_STORE.insert(index.to_string(), NativeSettings { - index_name: Some(index.to_string()), - field_configs: Some(field_configs), - type_encoding_configs: Some(type_encoding), - ..Default::default() - }); + SETTINGS_STORE.insert( + index.to_string(), + NativeSettings { + index_name: Some(index.to_string()), + field_configs: Some(field_configs), + type_encoding_configs: Some(type_encoding), + ..Default::default() + }, + ); let (_tmp, path) = get_temp_file_path("idx_overrides_cluster_enc.parquet"); let (_schema, schema_ptr) = create_writer_and_assert_success_for_index(&path, index); @@ -513,8 +612,11 @@ fn test_index_level_overrides_cluster_level_encoding() { // Index says PLAIN for "id", cluster says DELTA_BINARY_PACKED for int32 — index wins let encodings = read_column_encoding(&path, "id"); - assert!(!encodings.contains(&Encoding::DELTA_BINARY_PACKED), - "DELTA_BINARY_PACKED should not be present when index-level PLAIN is set, got {:?}", encodings); + assert!( + !encodings.contains(&Encoding::DELTA_BINARY_PACKED), + "DELTA_BINARY_PACKED should not be present when index-level PLAIN is set, got {:?}", + encodings + ); SETTINGS_STORE.remove(index); } @@ -524,19 +626,25 @@ fn test_mixed_index_and_cluster_level_configs() { let index = "test_mixed_index_cluster"; let mut field_configs = HashMap::new(); // "id" (Int32) has explicit index-level compression - field_configs.insert("id".to_string(), FieldConfig { - compression_type: Some("UNCOMPRESSED".to_string()), - ..Default::default() - }); + field_configs.insert( + "id".to_string(), + FieldConfig { + compression_type: Some("UNCOMPRESSED".to_string()), + ..Default::default() + }, + ); let mut type_compression = HashMap::new(); // "utf8" type → "name" column (Utf8) gets SNAPPY from cluster level type_compression.insert("utf8".to_string(), "SNAPPY".to_string()); - SETTINGS_STORE.insert(index.to_string(), NativeSettings { - index_name: Some(index.to_string()), - field_configs: Some(field_configs), - type_compression_configs: Some(type_compression), - ..Default::default() - }); + SETTINGS_STORE.insert( + index.to_string(), + NativeSettings { + index_name: Some(index.to_string()), + field_configs: Some(field_configs), + type_compression_configs: Some(type_compression), + ..Default::default() + }, + ); let (_tmp, path) = get_temp_file_path("mixed_idx_cluster.parquet"); let (_schema, schema_ptr) = create_writer_and_assert_success_for_index(&path, index); @@ -546,53 +654,83 @@ fn test_mixed_index_and_cluster_level_configs() { NativeParquetWriter::finalize_writer(path.clone()).unwrap(); cleanup_ffi_schema(schema_ptr); - assert!(matches!(read_column_compression(&path, "id"), Compression::UNCOMPRESSED)); - assert!(matches!(read_column_compression(&path, "name"), Compression::SNAPPY)); + assert!(matches!( + read_column_compression(&path, "id"), + Compression::UNCOMPRESSED + )); + assert!(matches!( + read_column_compression(&path, "name"), + Compression::SNAPPY + )); SETTINGS_STORE.remove(index); } /// Helper: create a writer for a specific index name (not the default "test-index"). -fn create_writer_and_assert_success_for_index(filename: &str, index: &str) -> (std::sync::Arc, i64) { +fn create_writer_and_assert_success_for_index( + filename: &str, + index: &str, +) -> (std::sync::Arc, i64) { let (schema, schema_ptr) = create_test_ffi_schema(); NativeParquetWriter::create_writer( - filename.to_string(), index.to_string(), schema_ptr, vec![], vec![], vec![], 0 - ).expect("create_writer failed"); + filename.to_string(), + index.to_string(), + schema_ptr, + vec![], + vec![], + vec![], + 0, + ) + .expect("create_writer failed"); (schema, schema_ptr) } /// Helper: create a writer with a 3-column schema: id (Int32), name (Utf8), score (Float64). -fn create_three_col_writer(filename: &str, index: &str) -> (std::sync::Arc, i64) { - use arrow::datatypes::{Field, Schema, DataType}; +fn create_three_col_writer( + filename: &str, + index: &str, +) -> (std::sync::Arc, i64) { + use arrow::datatypes::{DataType, Field, Schema}; use arrow::ffi::FFI_ArrowSchema; let schema = std::sync::Arc::new(Schema::new(vec![ - Field::new("id", DataType::Int32, false), - Field::new("name", DataType::Utf8, true), + Field::new("id", DataType::Int32, false), + Field::new("name", DataType::Utf8, true), Field::new("score", DataType::Float64, true), ])); let ffi_schema = FFI_ArrowSchema::try_from(schema.as_ref()).unwrap(); let schema_ptr = Box::into_raw(Box::new(ffi_schema)) as i64; NativeParquetWriter::create_writer( - filename.to_string(), index.to_string(), schema_ptr, vec![], vec![], vec![], 0 - ).expect("create_writer failed"); + filename.to_string(), + index.to_string(), + schema_ptr, + vec![], + vec![], + vec![], + 0, + ) + .expect("create_writer failed"); (schema, schema_ptr) } /// Helper: write a 3-column batch (id: Int32, name: Utf8, score: Float64). fn write_three_col_data(filename: &str) { - use arrow::array::{Array, Int32Array, StringArray, Float64Array, StructArray}; - use arrow::datatypes::{Field, Schema, DataType}; - use arrow::record_batch::RecordBatch; + use arrow::array::{Array, Float64Array, Int32Array, StringArray, StructArray}; + use arrow::datatypes::{DataType, Field, Schema}; use arrow::ffi::{FFI_ArrowArray, FFI_ArrowSchema}; + use arrow::record_batch::RecordBatch; let schema = std::sync::Arc::new(Schema::new(vec![ - Field::new("id", DataType::Int32, false), - Field::new("name", DataType::Utf8, true), + Field::new("id", DataType::Int32, false), + Field::new("name", DataType::Utf8, true), Field::new("score", DataType::Float64, true), ])); - let batch = RecordBatch::try_new(schema.clone(), vec![ - std::sync::Arc::new(Int32Array::from(vec![1, 2, 3])), - std::sync::Arc::new(StringArray::from(vec!["a", "b", "c"])), - std::sync::Arc::new(Float64Array::from(vec![1.0, 2.0, 3.0])), - ]).unwrap(); + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + std::sync::Arc::new(Int32Array::from(vec![1, 2, 3])), + std::sync::Arc::new(StringArray::from(vec!["a", "b", "c"])), + std::sync::Arc::new(Float64Array::from(vec![1.0, 2.0, 3.0])), + ], + ) + .unwrap(); let struct_array = StructArray::from(batch); let array_data = struct_array.into_data(); let ffi_array = FFI_ArrowArray::new(&array_data); @@ -615,19 +753,25 @@ fn write_three_col_data(filename: &str) { fn test_three_tier_compression_fallback() { let index = "test_three_tier_compression"; let mut field_configs = HashMap::new(); - field_configs.insert("id".to_string(), FieldConfig { - compression_type: Some("UNCOMPRESSED".to_string()), - ..Default::default() - }); + field_configs.insert( + "id".to_string(), + FieldConfig { + compression_type: Some("UNCOMPRESSED".to_string()), + ..Default::default() + }, + ); let mut type_compression = HashMap::new(); type_compression.insert("utf8".to_string(), "SNAPPY".to_string()); - SETTINGS_STORE.insert(index.to_string(), NativeSettings { - index_name: Some(index.to_string()), - compression_type: Some("LZ4_RAW".to_string()), - field_configs: Some(field_configs), - type_compression_configs: Some(type_compression), - ..Default::default() - }); + SETTINGS_STORE.insert( + index.to_string(), + NativeSettings { + index_name: Some(index.to_string()), + compression_type: Some("LZ4_RAW".to_string()), + field_configs: Some(field_configs), + type_compression_configs: Some(type_compression), + ..Default::default() + }, + ); let (_tmp, path) = get_temp_file_path("three_tier_comp.parquet"); let (_schema, schema_ptr) = create_three_col_writer(&path, index); @@ -636,11 +780,20 @@ fn test_three_tier_compression_fallback() { cleanup_ffi_schema(schema_ptr); // index level wins for "id" - assert!(matches!(read_column_compression(&path, "id"), Compression::UNCOMPRESSED)); + assert!(matches!( + read_column_compression(&path, "id"), + Compression::UNCOMPRESSED + )); // cluster level wins for "name" (utf8) - assert!(matches!(read_column_compression(&path, "name"), Compression::SNAPPY)); + assert!(matches!( + read_column_compression(&path, "name"), + Compression::SNAPPY + )); // global fallback for "score" (float64, no index or cluster config) - assert!(matches!(read_column_compression(&path, "score"), Compression::LZ4_RAW)); + assert!(matches!( + read_column_compression(&path, "score"), + Compression::LZ4_RAW + )); SETTINGS_STORE.remove(index); } diff --git a/sandbox/plugins/parquet-data-format/src/test/java/org/opensearch/parquet/ParquetSettingsTests.java b/sandbox/plugins/parquet-data-format/src/test/java/org/opensearch/parquet/ParquetSettingsTests.java index 86ff2e4460049..6a6e0178d7ee8 100644 --- a/sandbox/plugins/parquet-data-format/src/test/java/org/opensearch/parquet/ParquetSettingsTests.java +++ b/sandbox/plugins/parquet-data-format/src/test/java/org/opensearch/parquet/ParquetSettingsTests.java @@ -292,4 +292,94 @@ public void testTypeBloomFilterMultipleTypes() { assertEquals(0.01, fpp.get("utf8"), 0.0001); assertEquals(Long.valueOf(50000L), ndv.get("int64")); } + + // --- RAM-scaled batch-size default tests --- + + private static final long GIB = 1024L * 1024 * 1024; + + public void testMaxRowsPerVsrDefaultScalesWithRam() { + int base = ParquetSettings.DEFAULT_MAX_ROWS_PER_VSR; // 65,536 @ 64 GiB + int floor = ParquetSettings.MAX_ROWS_PER_VSR_FLOOR; // 8,192 + int ceil = ParquetSettings.MAX_ROWS_PER_VSR_CEIL; // 131,072 + // Matrix (total RAM -> VSR rows) + assertEquals(8_192, ParquetSettings.deriveBatchSizeDefault(8 * GIB, base, floor, ceil)); + assertEquals(16_384, ParquetSettings.deriveBatchSizeDefault(16 * GIB, base, floor, ceil)); + assertEquals(32_768, ParquetSettings.deriveBatchSizeDefault(32 * GIB, base, floor, ceil)); + assertEquals(65_536, ParquetSettings.deriveBatchSizeDefault(64 * GIB, base, floor, ceil)); // anchor + assertEquals(131_072, ParquetSettings.deriveBatchSizeDefault(128 * GIB, base, floor, ceil)); + assertEquals(131_072, ParquetSettings.deriveBatchSizeDefault(256 * GIB, base, floor, ceil)); // capped + } + + public void testMergeBatchSizeDefaultScalesWithRam() { + int base = ParquetSettings.DEFAULT_MERGE_BATCH_SIZE; // 100,000 @ 64 GiB + int floor = ParquetSettings.MERGE_BATCH_SIZE_FLOOR; // 12,500 + int ceil = ParquetSettings.MERGE_BATCH_SIZE_CEIL; // 100,000 + assertEquals(12_500, ParquetSettings.deriveBatchSizeDefault(8 * GIB, base, floor, ceil)); + assertEquals(25_000, ParquetSettings.deriveBatchSizeDefault(16 * GIB, base, floor, ceil)); + assertEquals(50_000, ParquetSettings.deriveBatchSizeDefault(32 * GIB, base, floor, ceil)); + assertEquals(100_000, ParquetSettings.deriveBatchSizeDefault(64 * GIB, base, floor, ceil)); // anchor (== ceil) + assertEquals(100_000, ParquetSettings.deriveBatchSizeDefault(128 * GIB, base, floor, ceil)); // capped + assertEquals(100_000, ParquetSettings.deriveBatchSizeDefault(256 * GIB, base, floor, ceil)); // capped + } + + public void testBatchSizeDefaultsClampToFloorAndCeiling() { + int vsrBase = ParquetSettings.DEFAULT_MAX_ROWS_PER_VSR; + int vsrFloor = ParquetSettings.MAX_ROWS_PER_VSR_FLOOR; + int vsrCeil = ParquetSettings.MAX_ROWS_PER_VSR_CEIL; + // Below anchor/8 -> clamps to floor; far above -> clamps to ceiling. + assertEquals(vsrFloor, ParquetSettings.deriveBatchSizeDefault(2 * GIB, vsrBase, vsrFloor, vsrCeil)); + assertEquals(vsrCeil, ParquetSettings.deriveBatchSizeDefault(1024 * GIB, vsrBase, vsrFloor, vsrCeil)); + + int mrgBase = ParquetSettings.DEFAULT_MERGE_BATCH_SIZE; + int mrgFloor = ParquetSettings.MERGE_BATCH_SIZE_FLOOR; + int mrgCeil = ParquetSettings.MERGE_BATCH_SIZE_CEIL; + assertEquals(mrgFloor, ParquetSettings.deriveBatchSizeDefault(2 * GIB, mrgBase, mrgFloor, mrgCeil)); + assertEquals(mrgCeil, ParquetSettings.deriveBatchSizeDefault(1024 * GIB, mrgBase, mrgFloor, mrgCeil)); + } + + public void testSortInMemoryThresholdDefaultScalesWithRam() { + int base = ParquetSettings.SORT_IN_MEMORY_THRESHOLD_BASE_MB; // 32 MiB @ 64 GiB + int floor = ParquetSettings.SORT_IN_MEMORY_THRESHOLD_FLOOR_MB; // 16 MiB + int ceil = ParquetSettings.SORT_IN_MEMORY_THRESHOLD_CEIL_MB; // 64 MiB + // (total RAM -> sort threshold, in MiB) + assertEquals(16, ParquetSettings.deriveBatchSizeDefault(8 * GIB, base, floor, ceil)); // 4 -> floor + assertEquals(16, ParquetSettings.deriveBatchSizeDefault(16 * GIB, base, floor, ceil)); // 8 -> floor + assertEquals(16, ParquetSettings.deriveBatchSizeDefault(32 * GIB, base, floor, ceil)); // 16 + assertEquals(32, ParquetSettings.deriveBatchSizeDefault(64 * GIB, base, floor, ceil)); // anchor + assertEquals(64, ParquetSettings.deriveBatchSizeDefault(128 * GIB, base, floor, ceil)); // 64 (ceil) + assertEquals(64, ParquetSettings.deriveBatchSizeDefault(256 * GIB, base, floor, ceil)); // capped + } + + public void testSortInMemoryThresholdFallsBackToBaseWhenRamUnavailable() { + assertEquals( + ParquetSettings.SORT_IN_MEMORY_THRESHOLD_BASE_MB, + ParquetSettings.deriveBatchSizeDefault( + -1, + ParquetSettings.SORT_IN_MEMORY_THRESHOLD_BASE_MB, + ParquetSettings.SORT_IN_MEMORY_THRESHOLD_FLOOR_MB, + ParquetSettings.SORT_IN_MEMORY_THRESHOLD_CEIL_MB + ) + ); + } + + public void testBatchSizeDefaultFallsBackToBaseWhenRamUnavailable() { + assertEquals( + ParquetSettings.DEFAULT_MAX_ROWS_PER_VSR, + ParquetSettings.deriveBatchSizeDefault( + -1, + ParquetSettings.DEFAULT_MAX_ROWS_PER_VSR, + ParquetSettings.MAX_ROWS_PER_VSR_FLOOR, + ParquetSettings.MAX_ROWS_PER_VSR_CEIL + ) + ); + assertEquals( + ParquetSettings.DEFAULT_MERGE_BATCH_SIZE, + ParquetSettings.deriveBatchSizeDefault( + 0, + ParquetSettings.DEFAULT_MERGE_BATCH_SIZE, + ParquetSettings.MERGE_BATCH_SIZE_FLOOR, + ParquetSettings.MERGE_BATCH_SIZE_CEIL + ) + ); + } } diff --git a/sandbox/plugins/parquet-data-format/src/test/java/org/opensearch/parquet/stats/ParquetIngestPoolStatsTests.java b/sandbox/plugins/parquet-data-format/src/test/java/org/opensearch/parquet/stats/ParquetIngestPoolStatsTests.java new file mode 100644 index 0000000000000..e0422436a7add --- /dev/null +++ b/sandbox/plugins/parquet-data-format/src/test/java/org/opensearch/parquet/stats/ParquetIngestPoolStatsTests.java @@ -0,0 +1,83 @@ +/* + * 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.parquet.stats; + +import org.opensearch.common.io.stream.BytesStreamOutput; +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.common.xcontent.XContentHelper; +import org.opensearch.common.xcontent.json.JsonXContent; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.test.OpenSearchTestCase; +import org.opensearch.threadpool.ThreadPoolStats; + +import java.util.Map; + +/** Unit tests for {@link ParquetIngestPoolStats}: XContent shape, stream round-trip, factory. */ +public class ParquetIngestPoolStatsTests extends OpenSearchTestCase { + + public void testToXContentShape() { + ParquetIngestPoolStats stats = new ParquetIngestPoolStats(4, 12, 3, 7L, 100L); + Map json = toMap(stats); + @SuppressWarnings("unchecked") + Map pool = (Map) json.get("native_ingest_pool"); + assertNotNull("native_ingest_pool block must be present", pool); + assertEquals(4, ((Number) pool.get("threads")).intValue()); + assertEquals(12, ((Number) pool.get("queue_depth")).intValue()); + assertEquals(3, ((Number) pool.get("active")).intValue()); + assertEquals(7L, ((Number) pool.get("rejected")).longValue()); + assertEquals(100L, ((Number) pool.get("completed")).longValue()); + } + + public void testStreamRoundTrip() throws Exception { + ParquetIngestPoolStats original = new ParquetIngestPoolStats(2, 5, 1, 9L, 42L); + BytesStreamOutput out = new BytesStreamOutput(); + original.writeTo(out); + try (StreamInput in = out.bytes().streamInput()) { + ParquetIngestPoolStats restored = new ParquetIngestPoolStats(in); + assertEquals(toMap(original), toMap(restored)); + } + } + + public void testFromThreadPoolStats() { + ThreadPoolStats.Stats s = new ThreadPoolStats.Stats.Builder().name("parquet_native_write") + .threads(4) + .queue(12) + .active(3) + .rejected(7L) + .largest(20) + .completed(100L) + .build(); + ParquetIngestPoolStats stats = ParquetIngestPoolStats.from(s); + assertNotNull(stats); + Map pool = poolBlock(stats); + assertEquals(12, ((Number) pool.get("queue_depth")).intValue()); + assertEquals(7L, ((Number) pool.get("rejected")).longValue()); + } + + public void testFromNullReturnsNull() { + assertNull(ParquetIngestPoolStats.from(null)); + } + + private static Map toMap(ParquetIngestPoolStats stats) { + try { + var builder = XContentFactory.jsonBuilder().startObject(); + stats.toXContent(builder, ToXContent.EMPTY_PARAMS); + builder.endObject(); + return XContentHelper.convertToMap(JsonXContent.jsonXContent, builder.toString(), true); + } catch (Exception e) { + throw new AssertionError(e); + } + } + + @SuppressWarnings("unchecked") + private static Map poolBlock(ParquetIngestPoolStats stats) { + return (Map) toMap(stats).get("native_ingest_pool"); + } +} diff --git a/sandbox/plugins/parquet-data-format/src/test/java/org/opensearch/parquet/vsr/VSRManagerTests.java b/sandbox/plugins/parquet-data-format/src/test/java/org/opensearch/parquet/vsr/VSRManagerTests.java index 6cd2ca71ec720..17bcbd9f543ec 100644 --- a/sandbox/plugins/parquet-data-format/src/test/java/org/opensearch/parquet/vsr/VSRManagerTests.java +++ b/sandbox/plugins/parquet-data-format/src/test/java/org/opensearch/parquet/vsr/VSRManagerTests.java @@ -22,7 +22,6 @@ import org.opensearch.index.engine.dataformat.DataFormat; import org.opensearch.index.engine.dataformat.DocumentInput; import org.opensearch.index.mapper.KeywordFieldMapper; -import org.opensearch.index.mapper.MapperParsingException; import org.opensearch.index.mapper.NumberFieldMapper; import org.opensearch.parquet.ParquetBaseTests; import org.opensearch.parquet.ParquetDataFormatPlugin; @@ -585,29 +584,6 @@ public void testContinuousAddDocumentAcrossMultipleRotationsWithoutWaiting() thr assertEquals(totalDocs, metadata.numRows()); } - public void testRejectsDuplicateFieldInSingleDocument() throws Exception { - List fields = new ArrayList<>(); - fields.addAll(metadataFields()); - fields.add(new Field("val", FieldType.nullable(new ArrowType.Int(32, true)), null)); - schema = new Schema(fields); - - String filePath = createTempDir().resolve("dedup.parquet").toString(); - VSRManager manager = new VSRManager(filePath, indexSettings, schema, bufferPool, 50000, threadPool, 0L); - - NumberFieldMapper.NumberFieldType valField = new NumberFieldMapper.NumberFieldType("val", NumberFieldMapper.NumberType.INTEGER); - assignTestCapabilities(valField, PARQUET_FORMAT); - - ParquetDocumentInput doc = new ParquetDocumentInput(); - populateMetadataFields(doc); - doc.addField(valField, 10); - doc.addField(valField, 20); // same field instance — multi-value - - doc.setRowId(DocumentInput.ROW_ID_FIELD, 0); - MapperParsingException e = expectThrows(MapperParsingException.class, () -> manager.addDocument(doc)); - assertTrue(e.getMessage().contains("Cannot accept multiple values for field: [val]")); - manager.close(); - } - public void testAllowsDistinctFieldsInSingleDocument() throws Exception { List fields = new ArrayList<>(); fields.addAll(metadataFields()); diff --git a/sandbox/plugins/parquet-data-format/src/test/java/org/opensearch/parquet/writer/ParquetDocumentInputTests.java b/sandbox/plugins/parquet-data-format/src/test/java/org/opensearch/parquet/writer/ParquetDocumentInputTests.java index 8fb9f82a3ae2d..0fd5ec545326b 100644 --- a/sandbox/plugins/parquet-data-format/src/test/java/org/opensearch/parquet/writer/ParquetDocumentInputTests.java +++ b/sandbox/plugins/parquet-data-format/src/test/java/org/opensearch/parquet/writer/ParquetDocumentInputTests.java @@ -12,6 +12,7 @@ import org.opensearch.index.engine.dataformat.DocumentInput; import org.opensearch.index.mapper.KeywordFieldMapper; import org.opensearch.index.mapper.MappedFieldType; +import org.opensearch.index.mapper.MapperParsingException; import org.opensearch.index.mapper.NumberFieldMapper; import org.opensearch.parquet.ParquetBaseTests; import org.opensearch.parquet.engine.ParquetDataFormat; @@ -74,4 +75,15 @@ public void testCloseClearsState() { input.close(); assertTrue(input.getFinalInput().isEmpty()); } + + public void testRejectsDuplicateFieldInSingleDocument() throws Exception { + ParquetDocumentInput input = new ParquetDocumentInput(); + populateMetadataFields(input); + + NumberFieldMapper.NumberFieldType valField = new NumberFieldMapper.NumberFieldType("val", NumberFieldMapper.NumberType.INTEGER); + assignTestCapabilities(valField, PARQUET_FORMAT); + + input.addField(valField, 10); + expectThrows(MapperParsingException.class, () -> input.addField(valField, 20)); + } } diff --git a/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/cancellation/AnalyticsQueryTaskCleanupIT.java b/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/cancellation/AnalyticsQueryTaskCleanupIT.java index 221fdaa1bcc6e..832b79c308024 100644 --- a/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/cancellation/AnalyticsQueryTaskCleanupIT.java +++ b/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/cancellation/AnalyticsQueryTaskCleanupIT.java @@ -10,12 +10,15 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.opensearch.ExceptionsHelper; import org.opensearch.Version; +import org.opensearch.OpenSearchException; import org.opensearch.action.admin.cluster.node.tasks.cancel.CancelTasksResponse; import org.opensearch.action.admin.cluster.node.tasks.list.ListTasksResponse; import org.opensearch.action.admin.indices.create.CreateIndexResponse; import org.opensearch.analytics.AnalyticsPlugin; import org.opensearch.analytics.exec.action.AnalyticsQueryAction; +import org.opensearch.analytics.exec.action.FetchByRowIdsAction; import org.opensearch.analytics.exec.action.FragmentExecutionAction; import org.opensearch.arrow.allocator.ArrowBasePlugin; import org.opensearch.arrow.flight.transport.FlightStreamPlugin; @@ -25,6 +28,8 @@ import org.opensearch.common.unit.TimeValue; import org.opensearch.common.util.FeatureFlags; import org.opensearch.composite.CompositeDataFormatPlugin; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.core.tasks.TaskCancelledException; import org.opensearch.index.engine.dataformat.stub.MockCommitterEnginePlugin; import org.opensearch.parquet.ParquetOnlyDataFormatPlugin; import org.opensearch.plugins.Plugin; @@ -36,6 +41,8 @@ import org.opensearch.test.OpenSearchIntegTestCase; import org.opensearch.test.transport.MockTransportService; import org.opensearch.transport.TransportService; +import org.opensearch.transport.stream.StreamErrorCode; +import org.opensearch.transport.stream.StreamException; import java.util.Collection; import java.util.Collections; @@ -47,6 +54,7 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; /** * Verifies the framework-task lifecycle for the analytics query action @@ -121,6 +129,7 @@ protected Settings nodeSettings(int nodeOrdinal) { .put(super.nodeSettings(nodeOrdinal)) .put(FeatureFlags.PLUGGABLE_DATAFORMAT_EXPERIMENTAL_FLAG, true) .put(FeatureFlags.STREAM_TRANSPORT, true) + .put("datafusion.spill_directory", createTempDir().toString()) .build(); } @@ -162,6 +171,50 @@ private void createAndSeedIndex() { } } + // ---- QTF (query-then-fetch / late-materialization) index ---- + // A `sortkey` to order by + a fetch-only `payload` (index=false) projected ABOVE the sort anchor. + // `source = QTF_INDEX | sort sortkey | head N | fields payload` makes the LateMaterialization + // rewriter fire: the query phase emits row-ids, the fetch phase (FetchByRowIdsAction) materializes + // `payload`. Multi-shard so the rewriter engages. + private static final String QTF_INDEX = "analytics_qtf_cleanup_idx"; + private static final int QTF_DOCS = 200; + + private void createAndSeedQtfIndex() throws Exception { + Settings indexSettings = Settings.builder() + .put(IndexMetadata.SETTING_NUMBER_OF_SHARDS, NUM_SHARDS) + .put(IndexMetadata.SETTING_NUMBER_OF_REPLICAS, 0) + .put("index.pluggable.dataformat.enabled", true) + .put("index.pluggable.dataformat", "composite") + .put("index.composite.primary_data_format", "parquet") + .putList("index.composite.secondary_data_formats") + .build(); + assertTrue( + client().admin() + .indices() + .prepareCreate(QTF_INDEX) + .setSettings(indexSettings) + .setMapping("sortkey", "type=integer", "payload", "type=keyword,index=false") + .get() + .isAcknowledged() + ); + ensureGreen(QTF_INDEX); + for (int i = 0; i < QTF_DOCS; i++) { + client().prepareIndex(QTF_INDEX).setSource("sortkey", i, "payload", "row-" + i).get(); + } + client().admin().indices().prepareRefresh(QTF_INDEX).get(); + client().admin().indices().prepareFlush(QTF_INDEX).get(); + // Force the parquet commit visible before measuring. + assertBusy(() -> { + PPLResponse r = executePPL("source = " + QTF_INDEX + " | stats count() as c"); + assertEquals("QTF seed not yet visible", (long) QTF_DOCS, ((Number) r.getRows().get(0)[r.getColumns().indexOf("c")]).longValue()); + }, 30, TimeUnit.SECONDS); + } + + /** The QTF query: sort by sortkey, head N, project the fetch-only payload column → fires late materialization. */ + private static String qtfQuery() { + return "source = " + QTF_INDEX + " | sort sortkey | head 20 | fields payload"; + } + private PPLResponse executePPL(String ppl) { return client().execute(UnifiedPPLExecuteAction.INSTANCE, new PPLRequest(ppl)).actionGet(); } @@ -170,6 +223,16 @@ private PPLResponse executePPL(String ppl, TimeValue timeout) { return client().execute(UnifiedPPLExecuteAction.INSTANCE, new PPLRequest(ppl)).actionGet(timeout); } + /** Returns {@code target} if any OpenSearchException in {@code t}'s cause chain reports it, else null. */ + private static RestStatus firstStatus(Throwable t, RestStatus target) { + for (Throwable c = t; c != null && c != c.getCause(); c = c.getCause()) { + if (c instanceof OpenSearchException ose && ose.status() == target) { + return ose.status(); + } + } + return null; + } + private void assertNoResidualTasks(String action) throws Exception { assertBusy(() -> { ListTasksResponse tasks = client().admin().cluster().prepareListTasks().setActions(action).get(); @@ -197,6 +260,202 @@ public void testSuccessfulQueryLeavesNoResidualAnalyticsTask() throws Exception assertNoResidualTasks(FragmentExecutionAction.NAME); } + /** + * The REAL native pool-exhaustion path (no injection): squeeze {@code datafusion.memory_pool_limit_bytes} + * to 1 so a GROUP BY aggregation trips the native memory pool on the shard. The native error + * ("Failed to allocate …") is NOT a {@code CircuitBreakingException} object — it is converted to one on + * the data node by the backend's {@code convertException} SPI ({@code NativeErrorConverter}) before it + * crosses transport, then tagged RESOURCE_EXHAUSTED ({@code AnalyticsTransportErrors.toWireError}) and + * rebuilt into a breaker at the coordinator ({@code AnalyticsTransportErrors.fromWireError}). Without the + * data-node conversion the raw native error crosses as a typeless INTERNAL error and surfaces as HTTP 500 + * ("Stage 0 failed"). This is the path {@code MemoryGuardIT} exercises; here we assert the unwrapped + * 429 and clean teardown. + */ + public void testRealNativePoolExhaustionSurfacesAs429AndCleansUp() throws Exception { + createAndSeedIndex(); + // Squeeze the native pool so any aggregation allocation trips it. + assertTrue( + client().admin() + .cluster() + .prepareUpdateSettings() + .setTransientSettings(Settings.builder().put("datafusion.memory_pool_limit_bytes", 1L)) + .get() + .isAcknowledged() + ); + try { + Throwable failure = null; + PPLResponse response = null; + try { + response = executePPL("source = " + INDEX + " | stats count() by value", QUERY_TIMEOUT); + } catch (Throwable t) { + failure = t; + } + assertNotNull("native pool exhaustion must fail the query, not return (response=" + response + ")", failure); + // The contract is the STATUS (429), not a specific class: a native pool/admission trip is + // converted on the data node to either a CircuitBreakingException or an OpenSearchStatusException + // (admission-rejected), both of which report TOO_MANY_REQUESTS. Find the 429-bearing exception + // anywhere in the surfaced cause chain. + RestStatus status = firstStatus(failure, RestStatus.TOO_MANY_REQUESTS); + assertEquals( + "a native memory-pool trip must surface as HTTP 429 (got " + failure.getClass().getName() + ": " + failure + .getMessage() + ")", + RestStatus.TOO_MANY_REQUESTS, + status + ); + assertNoNativeLeak(failure); + } finally { + // Reset so other tests / teardown aren't starved. + client().admin() + .cluster() + .prepareUpdateSettings() + .setTransientSettings(Settings.builder().putNull("datafusion.memory_pool_limit_bytes")) + .get(); + } + assertNoResidualTasks(AnalyticsQueryAction.NAME); + assertNoResidualTasks(FragmentExecutionAction.NAME); + } + + /** + * Real native pool exhaustion through a SORT/LIMIT (TopK) query on a MULTI-SHARD index — the exact + * staging shape (high-q07..q10). The shard fragment trips the pool, the breaker crosses stream + * transport, and must surface to the user as HTTP 429 with NO leaked native allocator dump + * ("top memory consumers / query_untracked(...) consumed N MB"). Exercises BOTH the shard→coordinator + * transport path and the cause sanitization. + */ + public void testShardSortPoolExhaustionSurfacesAs429WithoutLeak() throws Exception { + createAndSeedIndex(); + assertTrue( + client().admin() + .cluster() + .prepareUpdateSettings() + .setTransientSettings(Settings.builder().put("datafusion.memory_pool_limit_bytes", 1L)) + .get() + .isAcknowledged() + ); + try { + Throwable failure = null; + PPLResponse response = null; + try { + response = executePPL("source = " + INDEX + " | sort value | head 50", QUERY_TIMEOUT); + } catch (Throwable t) { + failure = t; + } + assertNotNull("shard sort pool exhaustion must fail the query (response=" + response + ")", failure); + assertEquals( + "a shard-side native pool trip must surface as HTTP 429 (got " + failure.getClass().getName() + ": " + failure + .getMessage() + ")", + RestStatus.TOO_MANY_REQUESTS, + firstStatus(failure, RestStatus.TOO_MANY_REQUESTS) + ); + assertNoNativeLeak(failure); + } finally { + client().admin() + .cluster() + .prepareUpdateSettings() + .setTransientSettings(Settings.builder().putNull("datafusion.memory_pool_limit_bytes")) + .get(); + } + assertNoResidualTasks(AnalyticsQueryAction.NAME); + assertNoResidualTasks(FragmentExecutionAction.NAME); + } + + /** Flattens an exception's cause chain to a single string (class:message per level) for substring checks. */ + private static String renderedChain(Throwable t) { + StringBuilder sb = new StringBuilder(); + for (Throwable c = t; c != null && c != c.getCause(); c = c.getCause()) { + sb.append(c.getClass().getName()).append(':').append(c.getMessage()).append('\n'); + } + return sb.toString(); + } + + /** Asserts no raw native allocator internals leak into the rendered exception chain shown to the user. */ + private static void assertNoNativeLeak(Throwable t) { + StringBuilder sb = new StringBuilder(); + for (Throwable c = t; c != null && c != c.getCause(); c = c.getCause()) { + sb.append(c.getClass().getName()).append(':').append(c.getMessage()).append('\n'); + } + String rendered = sb.toString(); + for (String leak : new String[] { + "top memory consumers", + "query_untracked", + "can spill", + "already reserved", + "GroupedHashAggregateStream", + "RepartitionExec", + "batch_size", + "avg_row_bytes" }) { + assertFalse("native detail '" + leak + "' must not leak to the user, got: " + rendered, rendered.contains(leak)); + } + } + + /** + * A shard fragment returning a {@link TaskCancelledException} over stream transport — exactly what + * Search BackPressure does when it cancels a leaf {@code AnalyticsShardTask} (the bottom-up cancel + * that does NOT touch the coordinator action) — must tear down the whole query cleanly: the + * coordinator query task AND the fragment tasks must both unregister. Without the + * StageExecution attachChildren CANCELLED-propagation fix, a cancelled shard stage strands its + * parent and the coordinator task leaks (phantom). This is the closest IT analogue of the + * production SBP-cancel path. + */ + public void testShardTaskCancelledExceptionTearsDownQueryAndCleansUp() throws Exception { + createAndSeedIndex(); + + // Inject on every data node: with 2 shards / 0 replicas across 2 nodes, allocation may place both + // shards on one node, so a single-victim injection can miss the fragment and the query succeeds (flake). + List mtsList = new java.util.ArrayList<>(); + for (String node : internalCluster().getDataNodeNames()) { + MockTransportService mts = (MockTransportService) internalCluster().getInstance(TransportService.class, node); + mts.addRequestHandlingBehavior(FragmentExecutionAction.NAME, (handler, request, channel, task) -> { + // Replacing the handler bypasses the production classify seam (channelResponseHandler.onFailure + // → AnalyticsTransportErrors.toWireError), so we inject the post-classification wire form: a + // StreamException(CANCELLED). This exercises the half that crosses the wire + the coordinator's + // fromWireError, mirroring what production produces when SBP cancels a shard fragment. + channel.sendResponse( + new StreamException(StreamErrorCode.CANCELLED, "task cancelled by search backpressure on " + node) + ); + }); + mtsList.add(mts); + } + try { + Throwable failure = null; + PPLResponse response = null; + try { + response = executePPL("source = " + INDEX + " | stats sum(value) as total", QUERY_TIMEOUT); + } catch (Throwable t) { + failure = t; + } + // Either it surfaces as a failure (expected) or returns — but it must NOT hang past the + // timeout, and the surfaced error must reflect cancellation, not a generic 500/hang. + logger.info( + "[sbp-cancel] shard TaskCancelledException -> coordinator surfaced: response={}, failure={}", + response, + failure == null ? "none" : failure.getClass().getName() + ": " + failure.getMessage() + ); + assertNotNull("a cancelled shard must fail the query, not silently return a result (response=" + response + ")", failure); + // Contract: the shard cancellation must reach the coordinator as a recognizable cancellation, + // NOT the old bare RuntimeException("Stage N failed") ISE a client would retry. There are two + // valid race outcomes depending on which shard's failure wins: + // 1. the cancel propagates as a TaskCancelledException (fromWireError rebuilds it), or + // 2. with multiple shards, the failure cascade cancels a sibling's gRPC stream before its + // typed CANCELLED error is flushed (sendError no-ops on an already-cancelled channel), so + // that stream surfaces gRPC's generic cancellation teardown ("Internal error [task_id=N]"). + // Both are acceptable; a plain "Stage N failed" with no cancellation signal is the bug. + boolean isTaskCancelled = ExceptionsHelper.unwrap(failure, TaskCancelledException.class) != null; + boolean isGrpcCancelTeardown = renderedChain(failure).contains("Internal error [task_id="); + assertTrue( + "shard cancellation must surface as TaskCancelledException or a gRPC cancellation teardown, not a generic " + + "'Stage N failed' ISE (got chain: " + renderedChain(failure) + ")", + isTaskCancelled || isGrpcCancelTeardown + ); + } finally { + mtsList.forEach(MockTransportService::clearAllRules); + } + // The contract that matters for the stuck-task issue: NO phantom coordinator query task, no + // residual fragment tasks, after a bottom-up shard cancel. + assertNoResidualTasks(AnalyticsQueryAction.NAME); + assertNoResidualTasks(FragmentExecutionAction.NAME); + } + /** * Cancelling the framework {@code AnalyticsQueryAction} task terminates the in-flight query * (no hang) and leaves no residual analytics/query or fragment tasks. This only works because @@ -206,18 +465,22 @@ public void testSuccessfulQueryLeavesNoResidualAnalyticsTask() throws Exception public void testCancelAnalyticsQueryTaskTerminatesQueryAndCleansUp() throws Exception { createAndSeedIndex(); - // Block one data node's shard handler so cancellation lands while the query is in-flight. - String victim = randomFrom(internalCluster().getDataNodeNames()); - MockTransportService mts = (MockTransportService) internalCluster().getInstance(TransportService.class, victim); + // Block every data node's shard handler so cancellation lands while the query is in-flight, + // regardless of which node(s) the 2 shards were allocated to. CountDownLatch released = new CountDownLatch(1); - mts.addRequestHandlingBehavior(FragmentExecutionAction.NAME, (handler, request, channel, task) -> { - try { - released.await(QUERY_TIMEOUT.seconds(), TimeUnit.SECONDS); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - } - handler.messageReceived(request, channel, task); - }); + List mtsList = new java.util.ArrayList<>(); + for (String node : internalCluster().getDataNodeNames()) { + MockTransportService mts = (MockTransportService) internalCluster().getInstance(TransportService.class, node); + mts.addRequestHandlingBehavior(FragmentExecutionAction.NAME, (handler, request, channel, task) -> { + try { + released.await(QUERY_TIMEOUT.seconds(), TimeUnit.SECONDS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + handler.messageReceived(request, channel, task); + }); + mtsList.add(mts); + } ExecutorService exec = Executors.newSingleThreadExecutor(); try { @@ -248,7 +511,7 @@ public void testCancelAnalyticsQueryTaskTerminatesQueryAndCleansUp() throws Exce } } finally { released.countDown(); - mts.clearAllRules(); + mtsList.forEach(MockTransportService::clearAllRules); exec.shutdownNow(); exec.awaitTermination(5, TimeUnit.SECONDS); } @@ -256,4 +519,108 @@ public void testCancelAnalyticsQueryTaskTerminatesQueryAndCleansUp() throws Exce assertNoResidualTasks(AnalyticsQueryAction.NAME); assertNoResidualTasks(FragmentExecutionAction.NAME); } + + // ---------------------------------------------------------------- QTF (query-then-fetch) tests + // A QTF query has THREE stage levels (shard fragment → late-materialization → reduce) and a second + // round of shard work: the fetch phase ({@link FetchByRowIdsAction}). The phantom-task strand the + // StageExecution attachChildren fix addresses is reachable here in production: a cancelled shard + // stage (SBP) or a breaker mid-fetch leaves the LateMaterialization stage draining with a cancelled + // child, stranding the parent unless CANCELLED is propagated. These tests inject failures on BOTH + // the query phase (FragmentExecutionAction) and the fetch phase (FetchByRowIdsAction), and assert no + // residual tasks of any of the three actions. + + /** + * QTF sanity + cleanup. Confirms the late-materialization rewriter actually fires on this index + + * query shape (we flip a flag from a pass-through {@code FetchByRowIdsAction} behavior — if the + * rewriter didn't engage, no fetch is dispatched and the assertion fails, telling us the QTF tests + * below are testing the wrong thing). A successful QTF query must leave no residual query, fragment, + * or fetch tasks. + */ + public void testQtfQueryFiresFetchPhaseAndLeavesNoResidualTasks() throws Exception { + createAndSeedQtfIndex(); + + AtomicBoolean fetchDispatched = new AtomicBoolean(false); + List mtsList = new java.util.ArrayList<>(); + for (String node : internalCluster().getDataNodeNames()) { + MockTransportService mts = (MockTransportService) internalCluster().getInstance(TransportService.class, node); + mts.addRequestHandlingBehavior(FetchByRowIdsAction.NAME, (handler, request, channel, task) -> { + fetchDispatched.set(true); + handler.messageReceived(request, channel, task); + }); + mtsList.add(mts); + } + try { + PPLResponse response = executePPL(qtfQuery(), QUERY_TIMEOUT); + assertFalse("QTF query must return rows", response.getRows().isEmpty()); + assertTrue( + "late-materialization rewriter must fire (FetchByRowIdsAction dispatched) — otherwise the QTF " + + "cancel/breaker tests below exercise a non-QTF plan", + fetchDispatched.get() + ); + } finally { + mtsList.forEach(MockTransportService::clearAllRules); + } + assertNoResidualTasks(AnalyticsQueryAction.NAME); + assertNoResidualTasks(FragmentExecutionAction.NAME); + assertNoResidualTasks(FetchByRowIdsAction.NAME); + } + + /** SBP stand-in on the QTF QUERY phase: a cancelled shard fragment must tear down the whole 3-level QTF tree. */ + public void testQtfFragmentCancelTearsDownAndCleansUp() throws Exception { + createAndSeedQtfIndex(); + assertQtfInjectedFailureSurfacesAndCleansUp( + FragmentExecutionAction.NAME, + (channel, victim) -> channel.sendResponse(new TaskCancelledException("sbp cancel (qtf query phase) on " + victim)) + ); + } + + /** SBP stand-in on the QTF FETCH phase: a cancelled fetch must tear down the whole 3-level QTF tree. */ + public void testQtfFetchCancelTearsDownAndCleansUp() throws Exception { + createAndSeedQtfIndex(); + assertQtfInjectedFailureSurfacesAndCleansUp( + FetchByRowIdsAction.NAME, + (channel, victim) -> channel.sendResponse(new TaskCancelledException("sbp cancel (qtf fetch phase) on " + victim)) + ); + } + + /** Injects {@code injector} on {@code action} of a QTF query, asserts it fails, and verifies no residual tasks. */ + private void assertQtfInjectedFailureSurfacesAndCleansUp(String action, FailureInjector injector) throws Exception { + // Inject on EVERY data node, not a single random victim: with 2 shards / 0 replicas across 2 nodes, + // allocation may place both shards on one node, so a single-victim injection can miss every fragment + // (the query then succeeds and the test flakes). Injecting everywhere fails the fragment wherever it runs. + List mtsList = new java.util.ArrayList<>(); + for (String node : internalCluster().getDataNodeNames()) { + MockTransportService mts = (MockTransportService) internalCluster().getInstance(TransportService.class, node); + mts.addRequestHandlingBehavior(action, (handler, request, channel, task) -> injector.inject(channel, node)); + mtsList.add(mts); + } + try { + Throwable failure = null; + PPLResponse response = null; + try { + response = executePPL(qtfQuery(), QUERY_TIMEOUT); + } catch (Throwable t) { + failure = t; + } + logger.info( + "[qtf-inject] action={} -> response={}, failure={}", + action, + response, + failure == null ? "none" : failure.getClass().getName() + ": " + failure.getMessage() + ); + assertNotNull("injected failure on " + action + " must fail the QTF query, not return (response=" + response + ")", failure); + } finally { + mtsList.forEach(MockTransportService::clearAllRules); + } + // The phantom-task contract: no stranded coordinator query task, no residual fragment/fetch tasks. + assertNoResidualTasks(AnalyticsQueryAction.NAME); + assertNoResidualTasks(FragmentExecutionAction.NAME); + assertNoResidualTasks(FetchByRowIdsAction.NAME); + } + + /** Sends an injected error on a transport channel for the named victim node. */ + @FunctionalInterface + private interface FailureInjector { + void inject(org.opensearch.transport.TransportChannel channel, String victim) throws Exception; + } } diff --git a/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/resilience/CoordinatorResilienceIT.java b/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/resilience/CoordinatorResilienceIT.java index f46de7df3b550..2e1e0e63979f8 100644 --- a/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/resilience/CoordinatorResilienceIT.java +++ b/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/resilience/CoordinatorResilienceIT.java @@ -280,7 +280,7 @@ private void createAndSeedIndex() { ensureGreen(INDEX); for (int i = 0; i < TOTAL_DOCS; i++) { - client().prepareIndex(INDEX).setId(String.valueOf(i)).setSource("value", VALUE).get(); + client().prepareIndex(INDEX).setSource("value", VALUE).get(); } client().admin().indices().prepareRefresh(INDEX).get(); client().admin().indices().prepareFlush(INDEX).get(); @@ -324,7 +324,7 @@ private void createSingleShardIndex(String name, int docs) { assertTrue("index creation must be acknowledged", response.isAcknowledged()); ensureGreen(name); for (int i = 0; i < docs; i++) { - client().prepareIndex(name).setId(String.valueOf(i)).setSource("value", VALUE).get(); + client().prepareIndex(name).setSource("value", VALUE).get(); } client().admin().indices().prepareRefresh(name).get(); client().admin().indices().prepareFlush(name).get(); @@ -1070,7 +1070,7 @@ private void createSingleShardIndexOnNode(String name, int docs, String nodeName assertTrue("index creation must be acknowledged", response.isAcknowledged()); ensureGreen(name); for (int i = 0; i < docs; i++) { - client().prepareIndex(name).setId(String.valueOf(i)).setSource("value", VALUE).get(); + client().prepareIndex(name).setSource("value", VALUE).get(); } client().admin().indices().prepareRefresh(name).get(); } diff --git a/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/resilience/CoordinatorTopologyTestBase.java b/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/resilience/CoordinatorTopologyTestBase.java index 8fa20072a87c7..858425fecb7f8 100644 --- a/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/resilience/CoordinatorTopologyTestBase.java +++ b/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/resilience/CoordinatorTopologyTestBase.java @@ -119,7 +119,7 @@ protected void runSumOverSeededIndex(String index, int numShards, int docs) thro ensureGreen(index); for (int i = 0; i < docs; i++) { - client().prepareIndex(index).setId(String.valueOf(i)).setSource("value", VALUE).get(); + client().prepareIndex(index).setSource("value", VALUE).get(); } client().admin().indices().prepareRefresh(index).get(); client().admin().indices().prepareFlush(index).get(); diff --git a/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/resilience/CoordinatorTransportStressIT.java b/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/resilience/CoordinatorTransportStressIT.java index 609d802aea052..b69da993165f4 100644 --- a/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/resilience/CoordinatorTransportStressIT.java +++ b/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/resilience/CoordinatorTransportStressIT.java @@ -226,7 +226,7 @@ private void createSingleShardIndex(String indexName) { .get(); assertTrue("create must be acknowledged", response.isAcknowledged()); ensureGreen(indexName); - client().prepareIndex(indexName).setId("0").setSource("value", VALUE).get(); + client().prepareIndex(indexName).setSource("value", VALUE).get(); client().admin().indices().prepareRefresh(indexName).get(); client().admin().indices().prepareFlush(indexName).get(); } @@ -254,7 +254,7 @@ private void createThreeShardIndex() { assertTrue("create must be acknowledged", response.isAcknowledged()); ensureGreen(INDEX_3); for (int i = 0; i < 9; i++) { - client().prepareIndex(INDEX_3).setId(String.valueOf(i)).setSource("value", VALUE).get(); + client().prepareIndex(INDEX_3).setSource("value", VALUE).get(); } client().admin().indices().prepareRefresh(INDEX_3).get(); client().admin().indices().prepareFlush(INDEX_3).get(); diff --git a/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/resilience/MaxShardsPerQueryIT.java b/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/resilience/MaxShardsPerQueryIT.java index 97368fd327194..300c0b456fba0 100644 --- a/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/resilience/MaxShardsPerQueryIT.java +++ b/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/resilience/MaxShardsPerQueryIT.java @@ -260,7 +260,7 @@ private void createIndexWithAlias(String indexName, int shardCount, String alias ensureGreen(indexName); for (int i = 0; i < shardCount; i++) { - client().prepareIndex(indexName).setId(indexName + "-" + i).setSource("val", i + 1).get(); + client().prepareIndex(indexName).setSource("val", i + 1).get(); } client().admin().indices().prepareRefresh(indexName).get(); client().admin().indices().prepareFlush(indexName).get(); @@ -290,7 +290,7 @@ private void createSingleIndex(String indexName, int shardCount) { ensureGreen(indexName); for (int i = 0; i < shardCount; i++) { - client().prepareIndex(indexName).setId(String.valueOf(i)).setSource("val", i + 1).get(); + client().prepareIndex(indexName).setSource("val", i + 1).get(); } client().admin().indices().prepareRefresh(indexName).get(); client().admin().indices().prepareFlush(indexName).get(); diff --git a/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/resilience/MemoryGuardIT.java b/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/resilience/MemoryGuardIT.java index 0387954e75e7a..0463fcd95984b 100644 --- a/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/resilience/MemoryGuardIT.java +++ b/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/resilience/MemoryGuardIT.java @@ -8,6 +8,7 @@ package org.opensearch.analytics.resilience; +import org.opensearch.OpenSearchException; import org.opensearch.Version; import org.opensearch.action.admin.cluster.node.stats.NodeStats; import org.opensearch.action.admin.cluster.node.stats.NodesStatsResponse; @@ -174,6 +175,24 @@ public void testQuerySucceedsAtNormalPoolLimit() throws Exception { assertNotNull(response); } + /** True if {@code t}'s cause chain carries a 429 OpenSearchException or a known memory-pressure message marker. */ + private static boolean hasMemoryPressureSignal(Throwable t) { + for (Throwable c = t; c != null && c != c.getCause(); c = c.getCause()) { + if (c instanceof OpenSearchException ose && ose.status() == org.opensearch.core.rest.RestStatus.TOO_MANY_REQUESTS) { + return true; + } + String msg = c.getMessage(); + if (msg != null + && (msg.contains("CircuitBreakingException") + || msg.contains("Resources exhausted") + || msg.contains("analytics_backend_datafusion") + || msg.contains("insufficient memory budget"))) { + return true; + } + } + return false; + } + public void testQueryRejectedWhenPoolExhausted() throws Exception { createIndexAndIngest(); @@ -189,11 +208,13 @@ public void testQueryRejectedWhenPoolExhausted() throws Exception { Exception.class, () -> executePPL("source = " + INDEX_NAME + " | stats count() by url") ); + // The shard fragment wraps the failure as "Stage N failed", so the memory-pressure signal lives + // in the CAUSE CHAIN, not the top-level message. The native trip is converted on the data node + // (NativeErrorConverter) to a 429-bearing OpenSearchException; walk the chain for that 429 (or + // the legacy message markers as a fallback). assertTrue( - "Should contain CircuitBreakingException or ResourcesExhausted in message, got: " + ex.getMessage(), - ex.getMessage() != null && (ex.getMessage().contains("CircuitBreakingException") - || ex.getMessage().contains("Resources exhausted") - || ex.getMessage().contains("analytics_backend_datafusion")) + "Memory-pool exhaustion must surface as HTTP 429 somewhere in the failure chain, got: " + ex, + hasMemoryPressureSignal(ex) ); } finally { // Reset so cluster teardown doesn't fail diff --git a/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/resilience/ShardFailoverIT.java b/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/resilience/ShardFailoverIT.java index 2b65c9f21420c..d6b476e26fc68 100644 --- a/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/resilience/ShardFailoverIT.java +++ b/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/resilience/ShardFailoverIT.java @@ -166,7 +166,7 @@ public void testQuerySucceedsAfterPrimaryNodeIsolated() throws Exception { } for (int i = 0; i < DOCS; i++) { - client().prepareIndex(INDEX).setId(String.valueOf(i)).setSource("value", VALUE).get(); + client().prepareIndex(INDEX).setSource("value", VALUE).get(); } client().admin().indices().prepareRefresh(INDEX).get(); client().admin().indices().prepareFlush(INDEX).get(); diff --git a/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/sql/AnalyticsSearchSlowLogIT.java b/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/sql/AnalyticsSearchSlowLogIT.java index 77d36a93c3d09..664eb7f53eda8 100644 --- a/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/sql/AnalyticsSearchSlowLogIT.java +++ b/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/sql/AnalyticsSearchSlowLogIT.java @@ -238,7 +238,7 @@ private void createAndSeedIndex() { ensureGreen(INDEX); for (int i = 0; i < TOTAL_DOCS; i++) { - client().prepareIndex(INDEX).setId(String.valueOf(i)).setSource("val", i + 1).get(); + client().prepareIndex(INDEX).setSource("val", i + 1).get(); } client().admin().indices().prepareRefresh(INDEX).get(); client().admin().indices().prepareFlush(INDEX).get(); diff --git a/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/sql/ValuesSqlIT.java b/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/sql/ValuesSqlIT.java index 81ae1c7b9cd8a..1231605f8bf9f 100644 --- a/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/sql/ValuesSqlIT.java +++ b/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/analytics/sql/ValuesSqlIT.java @@ -208,7 +208,7 @@ private void createAndSeedIndex(int shardCount) { // Three distinct vals, each repeated → 1, 2, 3, 1, 2, 3. for (int i = 0; i < TOTAL_DOCS; i++) { int val = (i % 3) + 1; - client().prepareIndex(INDEX).setId(String.valueOf(i)).setSource("val", val).get(); + client().prepareIndex(INDEX).setSource("val", val).get(); } client().admin().indices().prepareRefresh(INDEX).get(); client().admin().indices().prepareFlush(INDEX).get(); @@ -235,7 +235,7 @@ private void createAndSeedHttpLogsIndex(int shardCount) { Object[][] docs = { { "GET", 100 }, { "POST", 50 }, { "GET", 200 }, { "GET", 300 } }; for (int i = 0; i < docs.length; i++) { - client().prepareIndex("http_logs").setId(String.valueOf(i)).setSource("verb", docs[i][0], "size", docs[i][1]).get(); + client().prepareIndex("http_logs").setSource("verb", docs[i][0], "size", docs[i][1]).get(); } client().admin().indices().prepareRefresh("http_logs").get(); client().admin().indices().prepareFlush("http_logs").get(); diff --git a/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/be/datafusion/IndexSortPropagationIT.java b/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/be/datafusion/IndexSortPropagationIT.java index 5dac8b8c1eb0b..e0dd17f8a2c5a 100644 --- a/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/be/datafusion/IndexSortPropagationIT.java +++ b/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/be/datafusion/IndexSortPropagationIT.java @@ -229,7 +229,6 @@ private void seed(String index) { // CounterID: 1..DOC_COUNT, EventDate spans 2026-05-01..2026-05-(1 + i mod 10). // Same content for both indices — only physical layout differs because of index.sort.field. client().prepareIndex(index) - .setId(String.valueOf(i)) .setSource( "URL", "https://example.com/page" + i, diff --git a/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/be/datafusion/QtfDerivedAboveProjectIT.java b/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/be/datafusion/QtfDerivedAboveProjectIT.java index 9b86183dc87e9..805cd32dcef23 100644 --- a/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/be/datafusion/QtfDerivedAboveProjectIT.java +++ b/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/be/datafusion/QtfDerivedAboveProjectIT.java @@ -206,7 +206,6 @@ private void createAndSeedIndex(int shardCount) { // CounterID is monotonically increasing so `WHERE CounterID > 0` matches all rows. for (int i = 0; i < 10; i++) { client().prepareIndex(INDEX) - .setId(String.valueOf(i)) .setSource( "URL", "https://example.com/page" + i, diff --git a/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/be/datafusion/SortReverseQtfIT.java b/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/be/datafusion/SortReverseQtfIT.java index 5219e89a4985e..4e2b19a4613c9 100644 --- a/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/be/datafusion/SortReverseQtfIT.java +++ b/sandbox/qa/analytics-engine-coordinator/src/internalClusterTest/java/org/opensearch/be/datafusion/SortReverseQtfIT.java @@ -311,7 +311,6 @@ private void seedDocs() { for (int i = 0; i < DOCS_PER_FLUSH; i++) { long ts = (long) batch * DOCS_PER_FLUSH + i; client().prepareIndex(INDEX) - .setId(String.valueOf(ts)) .setSource( "ts", ts, diff --git a/sandbox/qa/analytics-engine-rest/build.gradle b/sandbox/qa/analytics-engine-rest/build.gradle index 68ed98ddcc648..bed91a032f488 100644 --- a/sandbox/qa/analytics-engine-rest/build.gradle +++ b/sandbox/qa/analytics-engine-rest/build.gradle @@ -105,30 +105,44 @@ def configureAnalyticsCluster = { cluster -> // wildcards, or comma-lists). cluster.setting 'cluster.pluggable.dataformat', 'composite' - // Override default block-list to empty for integration tests so all predicate shapes - // are exercised with delegation enabled. Production clusters retain the default - // block-list (IS_NULL, IS_NOT_NULL, LIKE) until the dc() local execution fix lands. - cluster.setting 'analytics.delegation.lucene.blocked_predicates', '[]' + // Enable liquid cache feature flag (in-memory decoded-batch cache) + cluster.systemProperty 'opensearch.experimental.feature.liquid_cache.enabled', 'true' // analytics-engine requires the streaming transport — fragment dispatch is streaming-only. cluster.systemProperty 'opensearch.experimental.feature.transport.stream.enabled', 'true' } // ── Default integTest cluster ──────────────────────────────────────────────── -// TODO: enable numberOfNodes = 2 once partial aggs is handled testClusters.integTest { numberOfNodes = 2 configureAnalyticsCluster(delegate) + // Override block-list to empty so FilterDelegationGoldenIT exercises all predicate shapes. + setting 'analytics.delegation.lucene.blocked_predicates', '[]' } integTest { systemProperty 'tests.security.manager', 'false' + // Forward plan-shape harness props to the forked test JVM (see PlanShapeGoldenTestBase): + // -Dplan.generate=write capture + write q{N}.plan.yaml to the source tree + // -Dplan.generate=print capture + log the block (read-only peek, no write) + // (flag absent = assertion mode: compare capture vs golden) + // -Dplan.combos=... override which named combos run (else combos.yaml defaults) + ['plan.generate', 'plan.combos'].each { k -> + if (System.getProperty(k) != null) { systemProperty k, System.getProperty(k) } + } + // Source resources dir, so -Dplan.generate writes goldens to the source tree (not build/). + systemProperty 'plan.resourcesDir', file('src/test/resources').absolutePath + testLogging { + exceptionFormat = 'full' + } exclude '**/CoordinatorReduceMemtableIT.class' exclude '**/StreamingCoordinatorReduceIT.class' exclude '**/QueryCacheIT.class' exclude '**/SpillStatsEnabledIT.class' exclude '**/SpillCleanupOnBootIT.class' exclude '**/YmlOversamplingIT.class' + exclude '**/*NoMergeIT.class' + exclude '**/planshape/*IT.class' // Note: parallel forks against the same 2-node testCluster slow the suite down // (cluster is the bottleneck, not JVM startup) and cause cross-fork data races @@ -136,6 +150,29 @@ integTest { // defaults (single fork, no maxParallelForks override). } +// ── Plan-shape variant: 2 nodes, production-default blocked_predicates ─────── +// Runs plan-shape golden ITs with the production delegation blocklist (NOT the +// empty override used by the default integTest cluster for FilterDelegationGoldenIT). +task integTestPlanShape(type: RestIntegTestTask) { + description = 'Runs plan-shape golden ITs with production-default delegation blocklist' + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + filter { + includeTestsMatching 'org.opensearch.analytics.qa.planshape.*IT' + } + systemProperty 'tests.security.manager', 'false' + ['plan.generate', 'plan.combos'].each { k -> + if (System.getProperty(k) != null) { systemProperty k, System.getProperty(k) } + } + systemProperty 'plan.resourcesDir', file('src/test/resources').absolutePath +} +check.dependsOn(integTestPlanShape) + +testClusters.integTestPlanShape { + numberOfNodes = 2 + configureAnalyticsCluster(delegate) +} + // ── Memtable variant: 2 nodes, datafusion.reduce.input_mode=memtable ───────── task integTestMemtable(type: RestIntegTestTask) { description = 'Runs coordinator-reduce tests with memtable sink mode' @@ -189,6 +226,30 @@ testClusters.integTestStreaming { configureAnalyticsCluster(delegate) } +// ── No-merge variant: 2 nodes, parquet segment merge disabled ──────────────── +// Runs every *NoMergeIT with the dataformat merge gate turned off. The gate +// (DataFormatAwareEngine.MERGE_ENABLED_PROPERTY) defaults to ENABLED, so background +// merges would otherwise collapse per-flush parquet segments. Tests that need each +// flush to persist as its own segment (e.g. to exercise multi-segment / schema-drift +// behavior) belong here — name them with the *NoMergeIT suffix. They are excluded from +// the default integTest above so they only run under this merge-disabled cluster. +task integTestNoMerge(type: RestIntegTestTask) { + description = 'Runs *NoMergeIT tests with parquet segment merge disabled' + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + filter { + includeTestsMatching 'org.opensearch.analytics.qa.*NoMergeIT' + } + systemProperty 'tests.security.manager', 'false' +} +check.dependsOn(integTestNoMerge) + +testClusters.integTestNoMerge { + numberOfNodes = 2 + configureAnalyticsCluster(delegate) + systemProperty 'opensearch.pluggable.dataformat.merge.enabled', 'false' +} + // ── Spill-enabled variant: 2 nodes, datafusion.spill_directory + spill_memory_limit ── // Use the Gradle build directory so each clean build starts with a fresh empty spill // directory and no stale state can leak between runs (`./gradlew clean` wipes build/). diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/ConcatVariadicIT.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/ConcatVariadicIT.java index 7775998408ded..489fa24232bfb 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/ConcatVariadicIT.java +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/ConcatVariadicIT.java @@ -19,6 +19,16 @@ * VARCHAR / FixedChar operand list (the natural shape when fields and short string * literals appear together) trips isthmus' consistency check. The adapter pre-casts * every non-VARCHAR operand to VARCHAR so substrait emits a uniform-typed call. + * + *

    Operand-shape coverage matters here: a concat operand can be a field reference + * ({@code RexInputRef}), a string literal ({@code RexLiteral}), or a nested scalar-function + * call ({@code RexCall}, e.g. {@code substring(...)}). These travel different conversion + * paths, so the tests below exercise concat over scalar-function operands in + * addition to the field/literal shapes — the natural form of queries like + * {@code concat(substring(url, 1, 10), '|', substring(referer, 1, 10))}. + * + *

    Fixture row 0 ({@code key00}) of calcs: str0="FURNITURE", str1="CLAMP ON LAMPS", + * str2="one", str3="e". */ public class ConcatVariadicIT extends AnalyticsRestTestCase { @@ -52,6 +62,50 @@ public void testConcatFieldAndLiteralAndField() throws IOException { ); } + public void testConcatSubstringLiteralSubstringWithFilter() throws IOException { + // 3-arg concat where the outer operands are SUBSTRING scalar-function calls (RexCall, not + // field refs) and the middle operand is a short string literal, above a WHERE filter on the + // same fields. Row 0 of calcs has str0=`FURNITURE` and str1=`CLAMP ON LAMPS`, so + // substring(_,1,3) yields `FUR` and `CLA` → `FUR|CLA`. + assertRowsEqual( + "source=" + + DATASET.indexName + + " | where str0 != '' and str1 != ''" + + " | eval r = concat(substring(str0, 1, 3), '|', substring(str1, 1, 3))" + + " | fields r | head 1", + row("FUR|CLA") + ); + } + + public void testConcatTwoSubstringsNoLiteral() throws IOException { + // 2-arg concat of two SUBSTRING calls, no middle literal — every operand is an already + // VARCHAR-typed scalar call, so the variadic adapter makes no change. `FUR` + `CLA`. + assertRowsEqual( + "source=" + DATASET.indexName + " | eval r = concat(substring(str0, 1, 3), substring(str1, 1, 3)) | fields r | head 1", + row("FURCLA") + ); + } + + public void testConcatUpperAndLowerScalarFns() throws IOException { + // concat of two different scalar-function operands, no literal: upper(str3)=`E`, + // lower(str0)=`furniture` → `Efurniture`. Confirms the adapter's no-op path holds when + // operands are RexCalls (not field refs) typed VARCHAR. + assertRowsEqual( + "source=" + DATASET.indexName + " | eval r = concat(upper(str3), lower(str0)) | fields r | head 1", + row("Efurniture") + ); + } + + public void testConcatScalarFnLiteralScalarFnDistinctFns() throws IOException { + // 3-arg concat(scalarFn, literal, scalarFn) with two distinct functions around a FixedChar + // literal: left(str0,3)=`FUR`, right(str0,3)=`URE` → `FUR#URE`. Exercises the cast path on + // the middle literal while both outer operands are scalar-function calls. + assertRowsEqual( + "source=" + DATASET.indexName + " | eval r = concat(left(str0, 3), '#', right(str0, 3)) | fields r | head 1", + row("FUR#URE") + ); + } + private static List row(Object... values) { return Arrays.asList(values); } diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/CoordinatorReduceIT.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/CoordinatorReduceIT.java index 40307bae8c501..5b3750e0cca55 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/CoordinatorReduceIT.java +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/CoordinatorReduceIT.java @@ -157,7 +157,7 @@ public void testDistinctCountCrossShardOverlap() throws Exception { int total = 800; StringBuilder bulk = new StringBuilder(); for (int i = 0; i < total; i++) { - bulk.append("{\"index\": {\"_id\": \"o").append(i).append("\"}}\n"); + bulk.append("{\"index\": {}}\n"); bulk.append("{\"value\": ").append((i % distinct) + 1).append("}\n"); } bulkAndRefresh(index, bulk.toString()); @@ -208,7 +208,7 @@ public void testDistinctCountCrossShardOverlapKeyword() throws Exception { int total = 800; StringBuilder bulk = new StringBuilder(); for (int i = 0; i < total; i++) { - bulk.append("{\"index\": {\"_id\": \"k").append(i).append("\"}}\n"); + bulk.append("{\"index\": {}}\n"); bulk.append("{\"label\": \"lbl").append(i % distinct).append("\"}\n"); } bulkAndRefresh(index, bulk.toString()); @@ -674,7 +674,7 @@ private void indexStringGroupDocs() throws Exception { StringBuilder bulk = new StringBuilder(); int total = NUM_SHARDS * DOCS_PER_SHARD; for (int i = 0; i < total; i++) { - bulk.append("{\"index\": {\"_id\": \"w").append(i).append("\"}}\n"); + bulk.append("{\"index\": {}}\n"); bulk.append("{\"category\": \"\", \"value\": ").append(i + 1).append("}\n"); } bulkAndRefresh(STRING_GROUP_INDEX, bulk.toString()); @@ -745,7 +745,7 @@ private void indexConstantValueDocs(String indexName) throws Exception { StringBuilder bulk = new StringBuilder(); int total = NUM_SHARDS * DOCS_PER_SHARD; for (int i = 0; i < total; i++) { - bulk.append("{\"index\": {\"_id\": \"").append(i).append("\"}}\n"); + bulk.append("{\"index\": {}}\n"); bulk.append("{\"value\": ").append(VALUE).append("}\n"); } bulkAndRefresh(indexName, bulk.toString()); @@ -797,7 +797,7 @@ private void createSingleShardParquetBackedIndex(String indexName) throws Except private void indexSequentialValueDocsSingleShard(String indexName) throws Exception { StringBuilder bulk = new StringBuilder(); for (int i = 0; i < DOCS_PER_SHARD; i++) { - bulk.append("{\"index\": {\"_id\": \"s").append(i).append("\"}}\n"); + bulk.append("{\"index\": {}}\n"); bulk.append("{\"value\": ").append(i + 1).append("}\n"); } bulkAndRefresh(indexName, bulk.toString()); @@ -812,7 +812,7 @@ private void indexVaryingValueDocs(String indexName) throws Exception { StringBuilder bulk = new StringBuilder(); int total = NUM_SHARDS * DOCS_PER_SHARD; for (int i = 0; i < total; i++) { - bulk.append("{\"index\": {\"_id\": \"v").append(i).append("\"}}\n"); + bulk.append("{\"index\": {}}\n"); bulk.append("{\"value\": ").append(i + 1).append("}\n"); } bulkAndRefresh(indexName, bulk.toString()); @@ -826,7 +826,7 @@ private void indexVaryingValueDocs(String indexName) throws Exception { private void indexDuplicateValueDocsSingleShard(String indexName) throws Exception { StringBuilder bulk = new StringBuilder(); for (int i = 0; i < DOCS_PER_SHARD; i++) { - bulk.append("{\"index\": {\"_id\": \"d").append(i).append("\"}}\n"); + bulk.append("{\"index\": {}}\n"); bulk.append("{\"value\": ").append((i % 5) + 1).append("}\n"); } bulkAndRefresh(indexName, bulk.toString()); @@ -842,7 +842,7 @@ private void indexDuplicateValueDocs(String indexName) throws Exception { StringBuilder bulk = new StringBuilder(); int total = NUM_SHARDS * DOCS_PER_SHARD; for (int i = 0; i < total; i++) { - bulk.append("{\"index\": {\"_id\": \"d").append(i).append("\"}}\n"); + bulk.append("{\"index\": {}}\n"); bulk.append("{\"value\": ").append((i % 10) + 1).append("}\n"); } bulkAndRefresh(indexName, bulk.toString()); diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/CoordinatorReduceMemtableIT.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/CoordinatorReduceMemtableIT.java index 55b31501ead99..16b02fc443635 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/CoordinatorReduceMemtableIT.java +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/CoordinatorReduceMemtableIT.java @@ -90,7 +90,7 @@ private void indexDeterministicDocs() throws Exception { int total = NUM_SHARDS * DOCS_PER_SHARD; StringBuilder bulk = new StringBuilder(); for (int i = 0; i < total; i++) { - bulk.append("{\"index\": {\"_id\": \"").append(i).append("\"}}\n"); + bulk.append("{\"index\": {}}\n"); bulk.append("{\"value\": ").append(VALUE).append("}\n"); } diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/DataFusionCacheClearIT.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/DataFusionCacheClearIT.java new file mode 100644 index 0000000000000..79a0a7bccf4db --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/DataFusionCacheClearIT.java @@ -0,0 +1,122 @@ +/* + * 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.analytics.qa; + +import org.opensearch.client.Request; +import org.opensearch.client.Response; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** + * Integration tests for {@code POST /_plugins/_analytics_backend_datafusion/cache/_clear}. + * + *

    Tests verify: + *

      + *
    • Clear all: no params — returns 200, {@code acknowledged=true}, non-empty {@code cleared_nodes}
    • + *
    • Selective clear: {@code ?footer=true}, {@code ?column=true}, {@code ?offset=true} — each returns 200
    • + *
    • Combined params: {@code ?column=true&offset=true} — returns 200
    • + *
    • Unknown param: ignored (backward compat) — still returns 200
    • + *
    + * + *

    The cache contents themselves are not observable via REST (stats endpoint is PR 1), + * so these tests verify only that the action completes successfully and returns a + * well-formed response. + */ +public class DataFusionCacheClearIT extends AnalyticsRestTestCase { + + private static final String CLEAR_ENDPOINT = "/_plugins/_analytics_backend_datafusion/cache/_clear"; + + // ── clear all ──────────────────────────────────────────────────────────── + + public void testClearAllCachesReturns200WithAcknowledged() throws IOException { + Map body = clearCache(""); + assertEquals(true, body.get("acknowledged")); + assertClearedNodes(body); + } + + // ── selective: single param ─────────────────────────────────────────────── + + public void testClearFooterCacheOnly() throws IOException { + Map body = clearCache("?footer=true"); + assertEquals(true, body.get("acknowledged")); + assertClearedNodes(body); + } + + public void testClearColumnIndexCacheOnly() throws IOException { + Map body = clearCache("?column=true"); + assertEquals(true, body.get("acknowledged")); + assertClearedNodes(body); + } + + public void testClearOffsetIndexCacheOnly() throws IOException { + Map body = clearCache("?offset=true"); + assertEquals(true, body.get("acknowledged")); + assertClearedNodes(body); + } + + // ── selective: combined params ──────────────────────────────────────────── + + public void testClearColumnAndOffsetTogether() throws IOException { + Map body = clearCache("?column=true&offset=true"); + assertEquals(true, body.get("acknowledged")); + assertClearedNodes(body); + } + + public void testClearAllThreeExplicitly() throws IOException { + Map body = clearCache("?footer=true&column=true&offset=true"); + assertEquals(true, body.get("acknowledged")); + assertClearedNodes(body); + } + + // ── idempotency ─────────────────────────────────────────────────────────── + + public void testClearIsIdempotent() throws IOException { + // Second clear of an already-empty cache must still return 200. + clearCache(""); + Map body = clearCache(""); + assertEquals(true, body.get("acknowledged")); + assertClearedNodes(body); + } + + // ── response shape ──────────────────────────────────────────────────────── + + @SuppressWarnings("unchecked") + public void testResponseIncludesNodesAndClusterName() throws IOException { + Request request = new Request("POST", CLEAR_ENDPOINT); + Response response = client().performRequest(request); + Map body = assertOkAndParse(response, "cache/_clear"); + + // NodesResponseRestListener wraps with _nodes + cluster_name + assertNotNull("_nodes header present", body.get("_nodes")); + assertNotNull("cluster_name present", body.get("cluster_name")); + + Map nodesHeader = (Map) body.get("_nodes"); + int total = ((Number) nodesHeader.get("total")).intValue(); + int successful = ((Number) nodesHeader.get("successful")).intValue(); + assertTrue("at least one node total", total >= 1); + assertTrue("all nodes successful", successful == total); + } + + // ── helpers ─────────────────────────────────────────────────────────────── + + @SuppressWarnings("unchecked") + private void assertClearedNodes(Map body) { + List clearedNodes = (List) body.get("cleared_nodes"); + assertNotNull("cleared_nodes present", clearedNodes); + assertFalse("at least one node cleared", clearedNodes.isEmpty()); + } + + private Map clearCache(String queryParams) throws IOException { + Request request = new Request("POST", CLEAR_ENDPOINT + queryParams); + Response response = client().performRequest(request); + return assertOkAndParse(response, "cache/_clear" + queryParams); + } +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/DatasetProvisioner.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/DatasetProvisioner.java index 04d7dc6239917..8a0fa2b319af0 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/DatasetProvisioner.java +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/DatasetProvisioner.java @@ -19,6 +19,8 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; import java.util.stream.Collectors; import static org.junit.Assert.assertEquals; @@ -38,16 +40,53 @@ public final class DatasetProvisioner { private static final Logger logger = LogManager.getLogger(DatasetProvisioner.class); + /** + * How the dataset's documents are laid out into parquet segments per shard — a controlled axis + * for plan-shape tests, where the shard DataFusion physical plan can legitimately differ with + * segment count (e.g. the scan's {@code input_partitions}). The {@code suffix} disambiguates the + * per-layout index name. + */ + public enum SegmentLayout { + /** Exactly one segment per shard: single bulk + flush, then force-merge to one segment. */ + SINGLE_SEGMENT("1seg"), + /** + * Exactly {@link #MULTI_SEGMENT_COUNT} segments per shard: bulk in that many flushed parts. + * Parquet flush→segment is 1:1, so N parts give exactly N segments — deterministically pinning + * the scan's {@code input_partitions}. No force-merge: it caps "at most N" and could collapse + * tiny segments to 1; the default TieredMergePolicy won't auto-merge so few either. + */ + MULTI_SEGMENT("nseg"); + + /** Short tag for the per-layout index name (e.g. {@code parquet_hits_2s_1seg}). */ + public final String suffix; + + SegmentLayout(String suffix) { + this.suffix = suffix; + } + } + + /** The per-shard segment count produced by {@link SegmentLayout#MULTI_SEGMENT} (one flush each). */ + public static final int MULTI_SEGMENT_COUNT = 2; + private DatasetProvisioner() { // utility class } /** - * Provision the dataset into the cluster with parquet as the primary data format. + * Provision the dataset into the cluster with parquet as the primary data format. Segment layout + * is left to the engine (single bulk + flush); pass a {@link SegmentLayout} to control it. */ public static void provision(RestClient client, Dataset dataset, int numberOfShards) throws IOException { + provision(client, dataset, numberOfShards, null); + } + + /** + * Provision the dataset, optionally pinning the per-shard segment layout via {@code layout} + * ({@code null} = single bulk + flush, engine-decided segment count). + */ + public static void provision(RestClient client, Dataset dataset, int numberOfShards, SegmentLayout layout) throws IOException { for (String indexName : dataset.indexNames) { - provisionIndex(client, dataset, indexName, numberOfShards); + provisionIndex(client, dataset, indexName, numberOfShards, layout); } } @@ -56,11 +95,12 @@ public static void provision(RestClient client, Dataset dataset) throws IOExcept } /** - * Provision the dataset with {@code numberOfShards} overriding the value in the mapping. - * Pass {@code 0} to keep the mapping's value. Used by tests that need multi-shard - * coverage of planner paths (exchange insertion, sort split, etc.). + * Provision one index. {@code numberOfShards} overrides the mapping's value ({@code 0} keeps it). + * {@code layout} pins the per-shard segment layout ({@code null} = single bulk + flush, engine- + * decided). Used by tests needing multi-shard / multi-segment coverage of planner paths. */ - private static void provisionIndex(RestClient client, Dataset dataset, String indexName, int numberOfShards) throws IOException { + private static void provisionIndex(RestClient client, Dataset dataset, String indexName, int numberOfShards, SegmentLayout layout) + throws IOException { // Delete if exists try { client.performRequest(new Request("DELETE", "/" + indexName)); @@ -81,28 +121,29 @@ private static void provisionIndex(RestClient client, Dataset dataset, String in createIndex.setJsonEntity(indexBody); client.performRequest(createIndex); - // Bulk ingest + // Bulk ingest. The segment layout decides how the rows are committed into parquet segments. String bulkPath = dataset.indexNames.size() == 1 ? dataset.bulkResourcePath() : "datasets/" + dataset.name + "/bulk_" + indexName + ".json"; String bulkBody = loadResource(bulkPath); - Request bulkRequest = new Request("POST", "/" + indexName + "/_bulk"); - bulkRequest.setJsonEntity(bulkBody); - bulkRequest.addParameter("refresh", "true"); - bulkRequest.setOptions( - bulkRequest.getOptions().toBuilder().addHeader("Content-Type", "application/x-ndjson").build() - ); - Response bulkResponse = client.performRequest(bulkRequest); - assertEquals("Bulk insert failed", 200, bulkResponse.getStatusLine().getStatusCode()); - - // Log bulk response for debugging - String responseBody = new String(bulkResponse.getEntity().getContent().readAllBytes(), StandardCharsets.UTF_8); - logger.info("Bulk response for index [{}]: {}", indexName, responseBody); - // Flush to commit parquet files to disk - Request flushRequest = new Request("POST", "/" + indexName + "/_flush"); - flushRequest.addParameter("force", "true"); - client.performRequest(flushRequest); + if (layout == SegmentLayout.MULTI_SEGMENT) { + // Split the ndjson into MULTI_SEGMENT_COUNT parts at action/source boundaries; flush + // after each. Each flush is one parquet segment (1:1), so every shard ends up with + // exactly that many segments. No force-merge — it would only risk collapsing them + // (see SegmentLayout.MULTI_SEGMENT). Background merge leaves so few segments alone. + for (String part : splitNdjson(bulkBody, MULTI_SEGMENT_COUNT)) { + bulkAndFlush(client, indexName, part); + } + } else { + bulkAndFlush(client, indexName, bulkBody); + if (layout == SegmentLayout.SINGLE_SEGMENT) { + // Collapse every shard to exactly one parquet segment so the shard physical plan is + // deterministic (no per-shard divergence from differing segment counts). + forceMergeAndFlush(client, indexName, 1); + } + // layout == null: leave segment count to the engine (legacy non-plan-shape callers). + } // Wait for index health. wait_for_status=yellow only guarantees primaries are assigned, not // that every shard copy is active and done initializing — on a multi-node cluster a search @@ -119,6 +160,82 @@ private static void provisionIndex(RestClient client, Dataset dataset, String in logger.info("Dataset [{}] provisioned into index [{}]", dataset.name, indexName); } + /** Bulk-ingest one ndjson body (refresh=true) and force a flush so its segment is committed. */ + private static void bulkAndFlush(RestClient client, String indexName, String ndjson) throws IOException { + Request bulkRequest = new Request("POST", "/" + indexName + "/_bulk"); + bulkRequest.setJsonEntity(ndjson); + bulkRequest.addParameter("refresh", "true"); + bulkRequest.setOptions( + bulkRequest.getOptions().toBuilder().addHeader("Content-Type", "application/x-ndjson").build() + ); + Response bulkResponse = client.performRequest(bulkRequest); + assertEquals("Bulk insert failed", 200, bulkResponse.getStatusLine().getStatusCode()); + String responseBody = new String(bulkResponse.getEntity().getContent().readAllBytes(), StandardCharsets.UTF_8); + logger.info("Bulk response for index [{}]: {}", indexName, responseBody); + + Request flushRequest = new Request("POST", "/" + indexName + "/_flush"); + flushRequest.addParameter("force", "true"); + client.performRequest(flushRequest); + } + + /** Force-merge every shard to exactly {@code maxSegments} parquet segments, then flush. */ + private static void forceMergeAndFlush(RestClient client, String indexName, int maxSegments) throws IOException { + Request merge = new Request("POST", "/" + indexName + "/_forcemerge"); + merge.addParameter("max_num_segments", Integer.toString(maxSegments)); + client.performRequest(merge); + Request flush = new Request("POST", "/" + indexName + "/_flush"); + flush.addParameter("force", "true"); + client.performRequest(flush); + } + + /** + * Split an ndjson bulk body into {@code parts} non-empty chunks at action/source line + * boundaries. The bulk format alternates an action line ({@code {"index":{}}}) and a source + * line, so every cut must land on an even document boundary to keep each chunk self-contained. + * Each chunk, flushed on its own, becomes one parquet segment. + */ + private static List splitNdjson(String ndjson, int parts) { + List docLines = new ArrayList<>(); + for (String line : ndjson.split("\n")) { + if (!line.isEmpty()) { + docLines.add(line); + } + } + int pairCount = docLines.size() / 2; // (action, source) pairs + // Each part is flushed into its own segment, so we need at least one doc per part — otherwise + // we'd silently produce fewer segments than requested and the shard plan's input_partitions + // wouldn't match the golden. Fail loudly instead. + if (pairCount < parts) { + throw new IllegalArgumentException( + "dataset has " + pairCount + " doc(s), too few for a " + parts + "-segment layout (need >= " + parts + ")" + ); + } + int pairsPerPart = Math.max(1, (int) Math.ceil((double) pairCount / parts)); + List chunks = new ArrayList<>(); + StringBuilder chunk = new StringBuilder(); + int pairsInChunk = 0; + for (int i = 0; i < docLines.size(); i += 2) { + chunk.append(docLines.get(i)).append('\n'); + if (i + 1 < docLines.size()) { + chunk.append(docLines.get(i + 1)).append('\n'); + } + if (++pairsInChunk == pairsPerPart && chunks.size() < parts - 1) { + chunks.add(chunk.toString()); + chunk = new StringBuilder(); + pairsInChunk = 0; + } + } + if (chunk.length() > 0) { + chunks.add(chunk.toString()); + } + return chunks; + } + + // TODO(plan-shape): both this and injectParquetSettings mutate the index settings by string/regex + // rewriting the raw mapping JSON — brittle (depends on the literal "number_of_shards" token) and + // it means a combo's SettingsCombo.indexSettings map can't drive arbitrary index knobs. Replace + // with: parse mapping JSON -> merge a settings map (mapping defaults + parquet + combo.indexSettings) + // -> re-serialize. Shared by ~15 ITs, so do it as its own change and re-verify them. /** * Replace the {@code number_of_shards} value in the mapping body. Matches the form * {@code "number_of_shards": } produced by the canonical dataset mappings. diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/DateNanosUDTPrecisionIT.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/DateNanosUDTPrecisionIT.java index 55f023027752c..8148d2c962213 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/DateNanosUDTPrecisionIT.java +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/DateNanosUDTPrecisionIT.java @@ -87,13 +87,13 @@ private void createParquetBackedIndex() throws Exception { private void indexDocs() throws Exception { // 4 docs, two distinct dates, spread across shards by id parity. basic_date is yyyymmdd. - String bulk = "{\"index\":{\"_index\":\"" + INDEX + "\",\"_id\":\"1\"}}\n" + String bulk = "{\"index\":{\"_index\":\"" + INDEX + "\"}}\n" + "{\"d\":\"20240101\"}\n" - + "{\"index\":{\"_index\":\"" + INDEX + "\",\"_id\":\"2\"}}\n" + + "{\"index\":{\"_index\":\"" + INDEX + "\"}}\n" + "{\"d\":\"20240102\"}\n" - + "{\"index\":{\"_index\":\"" + INDEX + "\",\"_id\":\"3\"}}\n" + + "{\"index\":{\"_index\":\"" + INDEX + "\"}}\n" + "{\"d\":\"20240103\"}\n" - + "{\"index\":{\"_index\":\"" + INDEX + "\",\"_id\":\"4\"}}\n" + + "{\"index\":{\"_index\":\"" + INDEX + "\"}}\n" + "{\"d\":\"20240104\"}\n"; Request bulkReq = new Request("POST", "/_bulk"); bulkReq.addParameter("refresh", "true"); diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/DistinctCountIT.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/DistinctCountIT.java new file mode 100644 index 0000000000000..abe040d1b8324 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/DistinctCountIT.java @@ -0,0 +1,150 @@ +/* + * 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.analytics.qa; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** + * dc()/distinct_count() correctness across all field types on a 2-shard clickbench index. + * Compares HLL result against GROUP BY count (ground truth) with 5% tolerance. + */ +public class DistinctCountIT extends AnalyticsRestTestCase { + + private static final Dataset CLICKBENCH = ClickBenchTestHelper.DATASET; + private static final String IDX = CLICKBENCH.indexName; + private static volatile boolean provisioned = false; + + @Override + protected void onBeforeQuery() throws IOException { + if (provisioned == false) { + DatasetProvisioner.provision(client(), CLICKBENCH, 2); + provisioned = true; + } + } + + // ── keyword fields ────────────────────────────────────────────────────────── + + public void testDc_keyword_Title() throws Exception { + assertDcAccurate("Title"); + } + + public void testDc_keyword_BrowserCountry() throws Exception { + assertDcAccurate("BrowserCountry"); + } + + public void testDc_keyword_BrowserLanguage() throws Exception { + assertDcAccurate("BrowserLanguage"); + } + + // ── short (tinyint/smallint) fields ───────────────────────────────────────── + + public void testDc_short_OS() throws Exception { + assertDcAccurate("OS"); + } + + public void testDc_short_Age() throws Exception { + assertDcAccurate("Age"); + } + + public void testDc_short_Income() throws Exception { + assertDcAccurate("Income"); + } + + public void testDc_short_IsMobile() throws Exception { + assertDcAccurate("IsMobile"); + } + + // ── integer fields ────────────────────────────────────────────────────────── + + public void testDc_integer_RegionID() throws Exception { + assertDcAccurate("RegionID"); + } + + // ── long fields ───────────────────────────────────────────────────────────── + + public void testDc_long_RefererHash() throws Exception { + assertDcAccurate("RefererHash"); + } + + // ── with filter (exercises delegation + partial aggregate interaction) ─────── + + public void testDc_keyword_withFilter() throws Exception { + assertDcWithFilterAccurate("Title", "GoodEvent = 1"); + } + + public void testDc_short_withFilter() throws Exception { + assertDcWithFilterAccurate("OS", "Age > 0"); + } + + public void testDc_integer_withFilter() throws Exception { + assertDcWithFilterAccurate("RegionID", "GoodEvent = 1"); + } + + // ── grouped dc ────────────────────────────────────────────────────────────── + + public void testDc_grouped() throws Exception { + @SuppressWarnings("unchecked") + Map result = executePpl( + "source=" + IDX + " | stats distinct_count(Title) as dc by OS" + ); + @SuppressWarnings("unchecked") + List> rows = (List>) result.get("datarows"); + assertNotNull(rows); + assertFalse("grouped dc must return rows", rows.isEmpty()); + for (List row : rows) { + int dc = ((Number) row.get(0)).intValue(); + assertTrue("per-group dc must be > 0", dc > 0); + } + } + + // ── helpers ───────────────────────────────────────────────────────────────── + + @SuppressWarnings("unchecked") + private void assertDcAccurate(String field) throws Exception { + Map grouped = executePpl("source=" + IDX + " | stats count() by " + field); + int truth = ((List>) grouped.get("datarows")).size(); + + Map dc = executePpl("source=" + IDX + " | stats distinct_count(" + field + ") as dc"); + int dcVal = ((Number) ((List>) dc.get("datarows")).get(0).get(0)).intValue(); + + logger.info("dc({}): result={}, truth={}", field, dcVal, truth); + assertWithinTolerance(field, dcVal, truth); + } + + @SuppressWarnings("unchecked") + private void assertDcWithFilterAccurate(String field, String filter) throws Exception { + Map grouped = executePpl( + "source=" + IDX + " | where " + filter + " | stats count() by " + field + ); + int truth = ((List>) grouped.get("datarows")).size(); + + Map dc = executePpl( + "source=" + IDX + " | where " + filter + " | stats distinct_count(" + field + ") as dc" + ); + int dcVal = ((Number) ((List>) dc.get("datarows")).get(0).get(0)).intValue(); + + logger.info("dc({}) with filter '{}': result={}, truth={}", field, filter, dcVal, truth); + assertWithinTolerance(field + " (filtered)", dcVal, truth); + } + + private void assertWithinTolerance(String label, int dcVal, int truth) { + if (truth <= 20) { + assertEquals("dc(" + label + ") must be exact at low cardinality", truth, dcVal); + } else { + double error = Math.abs(dcVal - truth) / (double) truth; + assertTrue( + "dc(" + label + ") error " + String.format(java.util.Locale.ROOT, "%.1f%%", error * 100) + + " exceeds 5% (dc=" + dcVal + ", truth=" + truth + ")", + error <= 0.05 + ); + } + } +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/FilterDelegationGoldenIT.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/FilterDelegationGoldenIT.java index 4e3557b00c632..87c038db5aaf9 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/FilterDelegationGoldenIT.java +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/FilterDelegationGoldenIT.java @@ -266,7 +266,12 @@ private Map shardFragmentStage(Map response) { throw new AssertionError("No 'profile' block in response — request must set profile=true"); } @SuppressWarnings("unchecked") - List> stages = (List>) profile.get("stages"); + Map plan = (Map) profile.get("plan"); + if (plan == null) { + throw new AssertionError("No 'plan' block in profile: " + profile); + } + @SuppressWarnings("unchecked") + List> stages = (List>) plan.get("stages"); for (Map stage : stages) { if ("SHARD_FRAGMENT".equals(stage.get("execution_type"))) return stage; } diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/GroupedListAggregateMultiShardIT.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/GroupedListAggregateMultiShardIT.java index a4095c22cb9a9..7e374cef4d966 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/GroupedListAggregateMultiShardIT.java +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/GroupedListAggregateMultiShardIT.java @@ -84,13 +84,13 @@ private void provision() throws Exception { client().performRequest(health); // 4 docs distributed across the 2 shards by _id hash. grp ∈ {1,2}, str_value ∈ {x,y}. - String bulk = "{\"index\":{\"_id\":\"1\"}}\n" + String bulk = "{\"index\":{}}\n" + "{\"num_value\":10,\"grp\":1,\"str_value\":\"x\"}\n" - + "{\"index\":{\"_id\":\"2\"}}\n" + + "{\"index\":{}}\n" + "{\"num_value\":20,\"grp\":2,\"str_value\":\"y\"}\n" - + "{\"index\":{\"_id\":\"3\"}}\n" + + "{\"index\":{}}\n" + "{\"num_value\":30,\"grp\":1,\"str_value\":\"x\"}\n" - + "{\"index\":{\"_id\":\"4\"}}\n" + + "{\"index\":{}}\n" + "{\"num_value\":40,\"grp\":2,\"str_value\":\"y\"}\n"; Request bulkRequest = new Request("POST", "/" + INDEX + "/_bulk"); diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/LateMaterializationDateNanosIT.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/LateMaterializationDateNanosIT.java index 4540c87aa412e..2bfb6835602f5 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/LateMaterializationDateNanosIT.java +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/LateMaterializationDateNanosIT.java @@ -85,17 +85,17 @@ private void createParquetBackedIndex() throws Exception { private void indexDocs() throws Exception { String bulk = - "{\"index\":{\"_index\":\"" + INDEX + "\",\"_id\":\"1\"}}\n" + "{\"index\":{\"_index\":\"" + INDEX + "\"}}\n" + "{\"ts\":\"2025-09-23T00:01:01.123456Z\",\"service\":\"checkout\",\"severity\":\"ERROR\",\"body\":\"failed order: payment declined\"}\n" - + "{\"index\":{\"_index\":\"" + INDEX + "\",\"_id\":\"2\"}}\n" + + "{\"index\":{\"_index\":\"" + INDEX + "\"}}\n" + "{\"ts\":\"2025-09-23T00:02:01.234567Z\",\"service\":\"checkout\",\"severity\":\"ERROR\",\"body\":\"failed order due to expired session\"}\n" - + "{\"index\":{\"_index\":\"" + INDEX + "\",\"_id\":\"3\"}}\n" + + "{\"index\":{\"_index\":\"" + INDEX + "\"}}\n" + "{\"ts\":\"2025-09-23T00:03:01.345678Z\",\"service\":\"checkout\",\"severity\":\"WARN\",\"body\":\"failed order: inventory check\"}\n" - + "{\"index\":{\"_index\":\"" + INDEX + "\",\"_id\":\"4\"}}\n" + + "{\"index\":{\"_index\":\"" + INDEX + "\"}}\n" + "{\"ts\":\"2025-09-23T00:04:01.456789Z\",\"service\":\"checkout\",\"severity\":\"ERROR\",\"body\":\"failed gateway\"}\n" - + "{\"index\":{\"_index\":\"" + INDEX + "\",\"_id\":\"5\"}}\n" + + "{\"index\":{\"_index\":\"" + INDEX + "\"}}\n" + "{\"ts\":\"2025-09-23T00:05:00.000000Z\",\"service\":\"frontend\",\"severity\":\"INFO\",\"body\":\"ok\"}\n" - + "{\"index\":{\"_index\":\"" + INDEX + "\",\"_id\":\"6\"}}\n" + + "{\"index\":{\"_index\":\"" + INDEX + "\"}}\n" + "{\"ts\":\"2025-09-23T00:06:00.000000Z\",\"service\":\"checkout\",\"severity\":\"INFO\",\"body\":\"successful\"}\n"; Request bulkReq = new Request("POST", "/_bulk"); bulkReq.addParameter("refresh", "true"); diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/LiquidCacheBenchmarkIT.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/LiquidCacheBenchmarkIT.java new file mode 100644 index 0000000000000..7509c44829f57 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/LiquidCacheBenchmarkIT.java @@ -0,0 +1,228 @@ +/* + * 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.analytics.qa; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.opensearch.client.Request; +import org.opensearch.client.Response; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.concurrent.TimeUnit; + +/** + * Benchmark comparing query latency with and without Liquid Cache on a + * scaled-down ClickBench-like dataset (~100K rows). + * + * Run with: + * ./gradlew :sandbox:qa:analytics-engine-rest:integTest \ + * --tests "org.opensearch.analytics.qa.LiquidCacheBenchmarkIT" \ + * -Dsandbox.enabled=true + * + * Results are printed to stdout — compare "LC OFF" vs "LC ON" latencies. + */ +@SuppressWarnings("unchecked") +public class LiquidCacheBenchmarkIT extends AnalyticsRestTestCase { + + private static final Logger logger = LogManager.getLogger(LiquidCacheBenchmarkIT.class); + private static final String INDEX_NAME = "liquid_cache_bench"; + private static final int NUM_DOCS = 100_000; + private static final int BULK_BATCH_SIZE = 5000; + private static final int WARMUP_ITERATIONS = 3; + private static final int MEASURE_ITERATIONS = 10; + + private static final String[] QUERIES = { + // Q1: COUNT with equality filter (keyword) + "source=" + INDEX_NAME + " | where status = '200' | stats count() as cnt", + // Q2: Aggregation with range filter (numeric) + "source=" + INDEX_NAME + " | where response_time > 500 | stats avg(response_time) as avg_rt", + // Q3: GROUP BY low-cardinality keyword + "source=" + INDEX_NAME + " | stats count() as cnt by status", + // Q4: GROUP BY + range filter (time-range pattern) + "source=" + INDEX_NAME + " | where event_time > 1700000000000 | stats count() as cnt by region", + // Q5: Multi-column aggregation + "source=" + INDEX_NAME + " | where status = '500' | stats avg(response_time) as avg_rt, max(response_time) as max_rt", + // Q6: High selectivity filter + count + "source=" + INDEX_NAME + " | where user_id = 'user_42' | stats count() as cnt", }; + + public void testLiquidCacheBenchmark() throws Exception { + setupIndex(); + + logger.info("=== Liquid Cache Benchmark: {} docs, {} queries ===", NUM_DOCS, QUERIES.length); + + // Phase 1: LC OFF — baseline + updateSetting("datafusion.liquid_cache.enabled", "false"); + long[] baselineLatencies = runBenchmark("LC OFF"); + + // Phase 2: LC ON — first run (cold cache, populates cache) + updateSetting("datafusion.liquid_cache.enabled", "true"); + long[] coldCacheLatencies = runBenchmark("LC ON (cold)"); + + // Phase 3: LC ON — second run (warm cache, should be faster) + long[] warmCacheLatencies = runBenchmark("LC ON (warm)"); + + // Report + logger.info("=== RESULTS (median of {} iterations, ms) ===", MEASURE_ITERATIONS); + logger.info(String.format("%-60s %10s %10s %10s %10s", "Query", "Baseline", "Cold", "Warm", "Speedup")); + for (int i = 0; i < QUERIES.length; i++) { + String q = QUERIES[i].length() > 58 ? QUERIES[i].substring(0, 58) + ".." : QUERIES[i]; + double speedup = baselineLatencies[i] > 0 ? (double) baselineLatencies[i] / warmCacheLatencies[i] : 0; + logger.info( + String.format( + "%-60s %8dms %8dms %8dms %8.1fx", + q, + baselineLatencies[i], + coldCacheLatencies[i], + warmCacheLatencies[i], + speedup + ) + ); + } + } + + private long[] runBenchmark(String label) throws Exception { + long[] medians = new long[QUERIES.length]; + for (int q = 0; q < QUERIES.length; q++) { + // Warmup + for (int i = 0; i < WARMUP_ITERATIONS; i++) { + executePplRaw(QUERIES[q]); + } + // Measure + List latencies = new ArrayList<>(); + for (int i = 0; i < MEASURE_ITERATIONS; i++) { + long start = System.nanoTime(); + executePplRaw(QUERIES[q]); + long elapsed = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); + latencies.add(elapsed); + } + latencies.sort(Long::compareTo); + medians[q] = latencies.get(latencies.size() / 2); + logger.info( + "[{}] Q{}: median={}ms, min={}ms, max={}ms", + label, + q + 1, + medians[q], + latencies.get(0), + latencies.get(latencies.size() - 1) + ); + } + return medians; + } + + private void setupIndex() throws Exception { + deleteIndexIfExists(INDEX_NAME); + createIndex(); + ingestData(); + flush(); + logger.info("Index created with {} docs", NUM_DOCS); + } + + private void createIndex() throws Exception { + Request request = new Request("PUT", "/" + INDEX_NAME); + request.setJsonEntity( + "{" + + "\"settings\": {" + + " \"number_of_shards\": 1," + + " \"number_of_replicas\": 0," + + " \"refresh_interval\": \"-1\"," + + " \"index.pluggable.dataformat.enabled\": true," + + " \"index.pluggable.dataformat\": \"composite\"," + + " \"index.composite.primary_data_format\": \"parquet\"" + + "}," + + "\"mappings\": {" + + " \"properties\": {" + + " \"event_time\": {\"type\": \"long\"}," + + " \"status\": {\"type\": \"keyword\"}," + + " \"region\": {\"type\": \"keyword\"}," + + " \"user_id\": {\"type\": \"keyword\"}," + + " \"response_time\": {\"type\": \"integer\"}," + + " \"bytes_sent\": {\"type\": \"long\"}," + + " \"url\": {\"type\": \"keyword\"}" + + " }" + + "}" + + "}" + ); + assertEquals(200, client().performRequest(request).getStatusLine().getStatusCode()); + } + + private void ingestData() throws Exception { + Random rng = new Random(42); // deterministic seed + String[] statuses = { "200", "301", "404", "500" }; + String[] regions = { "us-east-1", "us-west-2", "eu-west-1", "ap-southeast-1" }; + + int ingested = 0; + while (ingested < NUM_DOCS) { + int batchSize = Math.min(BULK_BATCH_SIZE, NUM_DOCS - ingested); + StringBuilder bulk = new StringBuilder(); + for (int i = 0; i < batchSize; i++) { + bulk.append("{\"index\":{}}\n"); + bulk.append("{") + .append("\"event_time\":") + .append(1700000000000L + rng.nextInt(86400000)) + .append(",") + .append("\"status\":\"") + .append(statuses[rng.nextInt(statuses.length)]) + .append("\",") + .append("\"region\":\"") + .append(regions[rng.nextInt(regions.length)]) + .append("\",") + .append("\"user_id\":\"user_") + .append(rng.nextInt(10000)) + .append("\",") + .append("\"response_time\":") + .append(50 + rng.nextInt(2000)) + .append(",") + .append("\"bytes_sent\":") + .append(100 + rng.nextInt(50000)) + .append(",") + .append("\"url\":\"/api/v") + .append(rng.nextInt(3) + 1) + .append("/resource/") + .append(rng.nextInt(1000)) + .append("\"") + .append("}\n"); + } + Request request = new Request("POST", "/" + INDEX_NAME + "/_bulk"); + request.setJsonEntity(bulk.toString()); + Response response = client().performRequest(request); + assertEquals(200, response.getStatusLine().getStatusCode()); + ingested += batchSize; + if (ingested % 20000 == 0) { + logger.info("Ingested {}/{} docs", ingested, NUM_DOCS); + } + } + } + + private void flush() throws Exception { + client().performRequest(new Request("POST", "/" + INDEX_NAME + "/_refresh")); + client().performRequest(new Request("POST", "/" + INDEX_NAME + "/_flush?force=true")); + } + + private void executePplRaw(String query) throws Exception { + Request request = new Request("POST", "/_plugins/_ppl"); + request.setJsonEntity("{\"query\":\"" + query + "\"}"); + client().performRequest(request); + } + + private void updateSetting(String key, String value) throws Exception { + Request request = new Request("PUT", "/_cluster/settings"); + request.setJsonEntity("{\"transient\":{\"" + key + "\":\"" + value + "\"}}"); + client().performRequest(request); + } + + private void deleteIndexIfExists(String name) throws Exception { + try { + client().performRequest(new Request("DELETE", "/" + name)); + } catch (Exception e) { + // ignore — index may not exist + } + } +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/LiquidCacheIT.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/LiquidCacheIT.java new file mode 100644 index 0000000000000..c6d41d90089a1 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/LiquidCacheIT.java @@ -0,0 +1,190 @@ +/* + * 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.analytics.qa; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.opensearch.client.Request; +import org.opensearch.client.Response; + +import java.util.List; +import java.util.Map; + +/** + * Integration tests for Liquid Cache functionality within the analytics engine. + *

    + * Validates: + *

      + *
    • Composite parquet index creation and data ingestion
    • + *
    • PPL query execution through DataFusion with numeric predicates
    • + *
    • Dynamic enable/disable of liquid cache via cluster settings
    • + *
    • Dynamic resize of memory budget at runtime
    • + *
    • Query correctness across all cache states
    • + *
    + *

    + * Requires feature flags: + * {@code opensearch.experimental.feature.pluggable.dataformat.enabled=true}, + * {@code opensearch.experimental.feature.liquid_cache.enabled=true} + */ +@SuppressWarnings("unchecked") +public class LiquidCacheIT extends AnalyticsRestTestCase { + + private static final Logger logger = LogManager.getLogger(LiquidCacheIT.class); + private static final String INDEX_NAME = "liquid_cache_integ"; + private static final String PPL_ENDPOINT = "/_analytics/ppl"; + private static final long EXPECTED_SUM_AGE_GT_25 = 300000L; + + /** + * End-to-end test: index lifecycle, query execution, dynamic toggle, and budget resize. + */ + public void testLiquidCacheEndToEnd() throws Exception { + setupIndex(); + + verifyQueryReturnsExpectedResult(); + verifyDynamicDisableAndReenable(); + verifyDynamicBudgetResize(); + } + + private void verifyQueryReturnsExpectedResult() throws Exception { + logger.info("Executing PPL query with numeric predicate (age > 25)"); + long latency = executePplAndAssert( + "source=" + INDEX_NAME + " | where age > 25 | stats sum(salary) as total", + EXPECTED_SUM_AGE_GT_25 + ); + logger.info("Query latency: {}ms", latency); + } + + private void verifyDynamicDisableAndReenable() throws Exception { + updateSetting("datafusion.liquid_cache.enabled", "false"); + logger.info("Liquid cache disabled via cluster settings"); + + long disabledLatency = executePplAndAssert( + "source=" + INDEX_NAME + " | where age > 25 | stats sum(salary) as total", + EXPECTED_SUM_AGE_GT_25 + ); + logger.info("Query latency (LC disabled): {}ms", disabledLatency); + + updateSetting("datafusion.liquid_cache.enabled", "true"); + logger.info("Liquid cache re-enabled via cluster settings"); + + long reenabledLatency = executePplAndAssert( + "source=" + INDEX_NAME + " | where age > 25 | stats sum(salary) as total", + EXPECTED_SUM_AGE_GT_25 + ); + logger.info("Query latency (LC re-enabled): {}ms", reenabledLatency); + } + + private void verifyDynamicBudgetResize() throws Exception { + long newMemory = 512L * 1024 * 1024; + + updateSetting("datafusion.liquid_cache.size_bytes", String.valueOf(newMemory)); + + Response response = client().performRequest(new Request("GET", "/_cluster/settings?flat_settings=true&include_defaults=false")); + Map settings = entityAsMap(response); + Map transient_ = (Map) settings.get("transient"); + + assertEquals("Memory budget not updated", String.valueOf(newMemory), transient_.get("datafusion.liquid_cache.size_bytes")); + logger.info("Budget resize verified: memory={}MB", newMemory / (1024 * 1024)); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private void setupIndex() throws Exception { + deleteIndexIfExists(INDEX_NAME); + createCompositeParquetIndex(); + bulkIngestTestData(); + flushAndForceMerge(); + verifyParquetFormat(); + } + + private void createCompositeParquetIndex() throws Exception { + Request request = new Request("PUT", "/" + INDEX_NAME); + request.setJsonEntity( + "{" + + "\"settings\": {" + + " \"number_of_shards\": 1," + + " \"number_of_replicas\": 0," + + " \"index.pluggable.dataformat.enabled\": true," + + " \"index.pluggable.dataformat\": \"composite\"," + + " \"index.composite.primary_data_format\": \"parquet\"" + + "}," + + "\"mappings\": {" + + " \"properties\": {" + + " \"name\": {\"type\": \"keyword\"}," + + " \"age\": {\"type\": \"integer\"}," + + " \"salary\": {\"type\": \"long\"}" + + " }" + + "}" + + "}" + ); + assertEquals(200, client().performRequest(request).getStatusLine().getStatusCode()); + } + + private void bulkIngestTestData() throws Exception { + Request request = new Request("POST", "/" + INDEX_NAME + "/_bulk"); + request.addParameter("refresh", "true"); + request.setJsonEntity( + "{\"index\":{}}\n{\"name\":\"Alice\",\"age\":30,\"salary\":75000}\n" + + "{\"index\":{}}\n{\"name\":\"Bob\",\"age\":25,\"salary\":60000}\n" + + "{\"index\":{}}\n{\"name\":\"Charlie\",\"age\":35,\"salary\":90000}\n" + + "{\"index\":{}}\n{\"name\":\"Diana\",\"age\":28,\"salary\":70000}\n" + + "{\"index\":{}}\n{\"name\":\"Eve\",\"age\":35,\"salary\":65000}\n" + ); + assertEquals(200, client().performRequest(request).getStatusLine().getStatusCode()); + } + + private void flushAndForceMerge() throws Exception { + client().performRequest(new Request("POST", "/" + INDEX_NAME + "/_flush?force=true")); + Request merge = new Request("POST", "/" + INDEX_NAME + "/_forcemerge"); + merge.addParameter("max_num_segments", "1"); + client().performRequest(merge); + Thread.sleep(5000); + } + + private void verifyParquetFormat() throws Exception { + Response response = client().performRequest(new Request("GET", "/" + INDEX_NAME + "/_settings?flat_settings=true")); + Map settings = entityAsMap(response); + Map indexSettings = (Map) ((Map) settings.get(INDEX_NAME)).get("settings"); + assertEquals("parquet", indexSettings.get("index.composite.primary_data_format")); + } + + private long executePplAndAssert(String pplQuery, long expectedValue) throws Exception { + long start = System.currentTimeMillis(); + Request request = new Request("POST", PPL_ENDPOINT); + request.setJsonEntity("{\"query\": \"" + pplQuery + "\"}"); + Response response = client().performRequest(request); + long elapsed = System.currentTimeMillis() - start; + + assertEquals(200, response.getStatusLine().getStatusCode()); + Map body = entityAsMap(response); + logger.info("PPL response: {}", body); + assertNotNull("Response body should not be null", body); + + @SuppressWarnings("unchecked") + List> rows = (List>) body.get("rows"); + assertNotNull("Response should contain rows", rows); + assertFalse("Rows should not be empty", rows.isEmpty()); + Number actual = (Number) rows.get(0).get(0); + assertEquals("Query result mismatch", expectedValue, actual.longValue()); + + return elapsed; + } + + private void deleteIndexIfExists(String index) { + try { + client().performRequest(new Request("DELETE", "/" + index)); + } catch (Exception ignored) {} + } + + private void updateSetting(String key, String value) throws Exception { + Request request = new Request("PUT", "/_cluster/settings"); + request.setJsonEntity("{\"transient\":{\"" + key + "\":\"" + value + "\"}}"); + assertEquals(200, client().performRequest(request).getStatusLine().getStatusCode()); + } +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/ListAggregateMultiShardIT.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/ListAggregateMultiShardIT.java index dce436c986e8c..0543e99adae68 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/ListAggregateMultiShardIT.java +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/ListAggregateMultiShardIT.java @@ -160,7 +160,7 @@ private void provision() throws Exception { // FULL_TEXT_SEARCH" — so we rely on default _id routing.) The cross-shard reduce path is // exercised because the SINGLE list/values aggregate is gathered from all shards; the // assertions sort the merged list so they hold regardless of the exact 1/1 vs 2/0 split. - String bulk = "{\"index\":{\"_id\":\"1\"}}\n" + String bulk = "{\"index\":{}}\n" + "{" + "\"byte_value\":4,\"short_value\":3,\"integer_value\":2,\"long_value\":1," + "\"float_value\":6.2,\"double_value\":5.1," @@ -168,7 +168,7 @@ private void provision() throws Exception { + "\"binary_value\":\"YWFh\"," + "\"date_value\":\"2020-10-13 13:00:00\",\"ip_value\":\"127.0.0.1\"" + "}\n" - + "{\"index\":{\"_id\":\"2\"}}\n" + + "{\"index\":{}}\n" + "{" + "\"byte_value\":40,\"short_value\":30,\"integer_value\":20,\"long_value\":10," + "\"float_value\":60.2,\"double_value\":50.1," diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/ListAggregateMultiTypeIT.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/ListAggregateMultiTypeIT.java index ae6500d5cb1b2..c3145aed2e8a3 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/ListAggregateMultiTypeIT.java +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/ListAggregateMultiTypeIT.java @@ -153,7 +153,7 @@ private void provision() throws Exception { health.addParameter("timeout", "30s"); client().performRequest(health); - String bulk = "{\"index\":{\"_id\":\"1\"}}\n" + String bulk = "{\"index\":{}}\n" + "{" + "\"boolean_value\":true," + "\"byte_value\":4," diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/ListValuesRenderingIT.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/ListValuesRenderingIT.java index 950da6bdfd6b7..2ea0776b149ae 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/ListValuesRenderingIT.java +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/ListValuesRenderingIT.java @@ -120,13 +120,13 @@ private void provision(int shards) throws Exception { // num: 2, 10, 2, null → list drops null (10,2,2); values distinct+sorted ("10","2") // flag: true, false, true, (absent) → list (true,false,true); values ("false","true") - String bulk = "{\"index\":{\"_id\":\"1\"}}\n" + String bulk = "{\"index\":{}}\n" + "{\"num\":2,\"flag\":true}\n" - + "{\"index\":{\"_id\":\"2\"}}\n" + + "{\"index\":{}}\n" + "{\"num\":10,\"flag\":false}\n" - + "{\"index\":{\"_id\":\"3\"}}\n" + + "{\"index\":{}}\n" + "{\"num\":2,\"flag\":true}\n" - + "{\"index\":{\"_id\":\"4\"}}\n" + + "{\"index\":{}}\n" + "{}\n"; Request bulkRequest = new Request("POST", "/" + INDEX + "/_bulk"); bulkRequest.setJsonEntity(bulk); diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/LocalRecoveryIT.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/LocalRecoveryIT.java index 2555c28146c7a..a4584473bc53a 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/LocalRecoveryIT.java +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/LocalRecoveryIT.java @@ -74,11 +74,11 @@ private void ensureIndexProvisioned() throws IOException { // Bulk index documents StringBuilder bulk = new StringBuilder(); - bulk.append("{\"index\": {\"_id\": \"1\"}}\n{\"name\": \"alice\", \"age\": 30, \"score\": 95.5}\n"); - bulk.append("{\"index\": {\"_id\": \"2\"}}\n{\"name\": \"bob\", \"age\": 25, \"score\": 88.0}\n"); - bulk.append("{\"index\": {\"_id\": \"3\"}}\n{\"name\": \"carol\", \"age\": 35, \"score\": 92.3}\n"); - bulk.append("{\"index\": {\"_id\": \"4\"}}\n{\"name\": \"dave\", \"age\": 28, \"score\": 76.8}\n"); - bulk.append("{\"index\": {\"_id\": \"5\"}}\n{\"name\": \"eve\", \"age\": 32, \"score\": 91.0}\n"); + bulk.append("{\"index\": {}}\n{\"name\": \"alice\", \"age\": 30, \"score\": 95.5}\n"); + bulk.append("{\"index\": {}}\n{\"name\": \"bob\", \"age\": 25, \"score\": 88.0}\n"); + bulk.append("{\"index\": {}}\n{\"name\": \"carol\", \"age\": 35, \"score\": 92.3}\n"); + bulk.append("{\"index\": {}}\n{\"name\": \"dave\", \"age\": 28, \"score\": 76.8}\n"); + bulk.append("{\"index\": {}}\n{\"name\": \"eve\", \"age\": 32, \"score\": 91.0}\n"); Request bulkRequest = new Request("POST", "/" + INDEX_NAME + "/_bulk"); bulkRequest.setJsonEntity(bulk.toString()); @@ -190,7 +190,7 @@ public void testQueryResultsIdenticalAfterForceMergeAndRestart() throws IOExcept for (int batch = 0; batch < numBatches; batch++) { StringBuilder bulk = new StringBuilder(); for (int i = 0; i < docsPerBatch; i++) { - bulk.append("{\"index\": {\"_id\": \"").append(docId).append("\"}}\n"); + bulk.append("{\"index\": {}}\n"); bulk.append("{\"name\": \"user_") .append(docId) .append("\", \"age\": ") diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/PartialAggregateModeIT.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/PartialAggregateModeIT.java index d035074c12948..50443fb05319b 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/PartialAggregateModeIT.java +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/PartialAggregateModeIT.java @@ -138,4 +138,38 @@ private void setOversampling(double factor) throws IOException { req.setJsonEntity("{\"transient\":{\"analytics.shard_bucket_oversampling_factor\":" + factor + "}}"); client().performRequest(req); } + + /** Regression guard: TopK with high-cardinality group keys must produce valid counts. */ + @SuppressWarnings("unchecked") + public void testTopK_highCardinalityGroupBy_correctness() throws Exception { + ensureProvisioned(); + setConcurrentSearchMode("all", 4); + setOversampling(2.0); + + String idx = CLICKBENCH.indexName; + + Map total = executePpl("source=" + idx + " | stats count()"); + long totalCount = ((Number) ((List>) total.get("datarows")).get(0).get(0)).longValue(); + + Map topk = executePpl( + "source=" + idx + " | stats count() as cnt by RegionID | sort - cnt | head 5" + ); + List> rows = (List>) topk.get("datarows"); + assertNotNull(rows); + assertFalse("TopK must return rows", rows.isEmpty()); + + long sumTopK = rows.stream().mapToLong(r -> ((Number) r.get(0)).longValue()).sum(); + assertTrue( + "Sum of top-K counts (" + sumTopK + ") must not exceed total (" + totalCount + ")", + sumTopK <= totalCount + ); + + for (List row : rows) { + long cnt = ((Number) row.get(0)).longValue(); + assertTrue("Each group count must be > 0", cnt > 0); + } + + setConcurrentSearchMode("none", 0); + setOversampling(0.0); + } } diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/PplClickBenchIT.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/PplClickBenchIT.java index 04650162fb259..072d62fb17c20 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/PplClickBenchIT.java +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/PplClickBenchIT.java @@ -35,14 +35,12 @@ public class PplClickBenchIT extends AnalyticsRestTestCase { * resources/datasets/clickbench/ppl/. Individual queries can be excluded via * {@link #SKIP_QUERIES} when a feature is genuinely missing rather than broken. */ - // Queries skipped: - // - Q29: Substrait's default aggregate catalog has no min/max binding for string - // types. Isthmus fails with "Unable to find binding for call MIN($1)" when PPL - // emits `min(Referer)` on a VARCHAR column. DataFusion can execute min(Utf8) - // natively — the gap is purely in the Substrait serialization layer. A fix - // would register additional min/max impls covering string types in the loaded - // SimpleExtension.ExtensionCollection at plugin init. - private static final Set SKIP_QUERIES = Set.of(29); + // Q9 and Q10 both compute dc(UserID) (distinct count / HyperLogLog) grouped by RegionID. At the + // 2-shard clickbench layout the HLL sketches merge wrong across shards, so the distinct-count value + // for some groups is off; because each query then sorts by that value (sort -u / sort -c) and takes + // head 10, the wrong groups win the top-10 and the grouping column no longer lines up with expected. + // This is the pre-existing cross-shard HLL merge bug, not a regression here — mute until it's fixed. + private static final Set SKIP_QUERIES = Set.of(9, 10); private static boolean dataProvisioned = false; diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/SchemaDriftPruneNoMergeIT.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/SchemaDriftPruneNoMergeIT.java new file mode 100644 index 0000000000000..e833033d3ec4a --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/SchemaDriftPruneNoMergeIT.java @@ -0,0 +1,286 @@ +/* + * 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.analytics.qa; + +import org.opensearch.client.Request; +import org.opensearch.client.Response; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** + * Regression test for parquet row-group / page statistics pruning under dynamic-mapping + * schema drift across segments. Automates the manual cluster reproduction that + * surfaced the bug (24 segments, wide dynamic schemas, merges off). + * + *

    The bug. {@code StatisticsConverter} resolves a column name to a parquet leaf + * positionally: it finds the column's ordinal in the Arrow schema it is handed and + * uses that ordinal to index into the parquet file (parquet crate {@code parquet_column}). + * The stats-pruning paths ({@code eval_leaf} / {@code PagePruner::prune_rg} in + * {@code page_pruner.rs}, and {@code rg_provably_excluded} in {@code dynamic_filter.rs}) used + * to pass the union table schema. That schema's field order is first-seen across + * segments, but each individual segment's parquet column order follows the Java mapper's + * {@code HashMap} iteration order for that segment's own field set. When segments + * have different-sized field sets (dynamic mapping adding fields over time), {@code severity} + * lands at a different ordinal in each segment than it has in the union schema. The positional + * lookup then reads the wrong leaf — or finds none — yielding all-null stats, and the + * always-true residual {@code severity >= 0} wrongly prunes those row groups / segments, + * silently dropping matching rows. The fix resolves stats against each segment's own + * arrow schema (derived from its parquet footer). + * + *

    How drift is induced. {@code message} (text) and {@code severity} (byte) are + * declared up front, but each flush also indexes a growing set of dynamically-mapped + * numeric fields {@code pad_0..pad_W(s)} (segment {@code s} is wider than segment {@code s-1}). + * Because every flush becomes its own parquet segment, merge is disabled (see the + * {@code integTestNoMerge} cluster variant in build.gradle), and {@code row_group_max_rows} is + * small (multi-RG segments), successive segments carry differently-sized field sets — the + * HashMap-order divergence that drifts {@code severity}'s per-segment ordinal away from its + * union ordinal. The high field count (~300 in the widest segment) is what makes the + * divergence reliable; small fixtures (a handful of fields) do not drift and pass even without + * the fix. + * + *

    The {@code text} field is declared in the initial mapping on purpose: + * {@code match()} on a dynamically-added field across segments is a separate, known + * issue ({@code DynamicMappingSearchIT#testSearchOnDynamicallyAddedFields}, {@code @AwaitsFix} + * PR #21701), so the full-text field stays stable and only the numeric pad fields drift. + * + *

    Query shapes (each asserted against a ground truth computed during ingest): + *

      + *
    1. Numeric filter only — {@code where severity >= 0 | stats count()} + * (hits {@code eval_leaf} / {@code prune_rg} on the parquet-only path).
    2. + *
    3. Sort + limit (TopK) — {@code where severity >= 0 | sort - severity | head k} + * (adds the {@code DynamicRgPruner} / {@code rg_provably_excluded} path).
    4. + *
    5. match() + numeric residual — {@code where match(message,'alpha') and severity >= 0 | stats count()} + * (the production shape: Lucene-delegated text predicate AND a stats-pruned residual).
    6. + *
    7. match() + filter + sort + limit — all three prune paths at once.
    8. + *
    + * + *

    Pre-fix, the always-true {@code severity >= 0} residual prunes drifted segments and the + * counts come in below ground truth. Post-fix every count matches exactly. + */ +public class SchemaDriftPruneNoMergeIT extends AnalyticsRestTestCase { + + private static final String INDEX = "schema_drift_prune"; + private static final String TARGET = "alpha"; + + // The bug is driven by per-segment dynamic-field WIDTH: the Java mapper's HashMap column + // order scatters `severity` to a different ordinal in each differently-sized segment, + // diverging from its first-seen ordinal in the union table schema, so the positional + // StatisticsConverter lookup reads the wrong/no column. Reproducing it reliably needs many + // segments of widely-varying width (smaller fixtures — e.g. 4 segments up to 150 fields — + // do not diverge enough and pass even without the fix). These values mirror the manual + // cluster reproduction (24 segments, dynamic widths up to ~300, multi-RG segments) and were + // verified to fail without the fix (match()+severity>=0 returned 6600 instead of 7200). + + /** Each flush becomes one parquet segment; widths grow so per-segment field sets differ. */ + private static final int SEGMENTS = 24; + /** Docs per segment. With row_group_max_rows small this yields multiple RGs per segment. */ + private static final int DOCS_PER_SEGMENT = 500; + /** Matching (contain TARGET) docs per segment; the rest are non-matching. */ + private static final int MATCHING_PER_SEGMENT = 300; + /** Widest segment's pad-field count. Segment s carries pad_0..pad_{W(s)-1}. */ + private static final int MAX_PAD_FIELDS = 300; + /** Parquet row-group size — small so each segment has several row groups (multi-RG like prod). */ + private static final int ROW_GROUP_MAX_ROWS = 100; + + private static final String[] MATCHING_TEMPLATES = new String[] { + "bravo alpha charlie", + "delta alpha echo foxtrot", + "alpha golf hotel india", + "juliet kilo alpha lima" }; + + private static final String[] NON_MATCHING_TEMPLATES = new String[] { + "bravo charlie delta", + "echo foxtrot golf", + "hotel india juliet kilo", + "papa quebec romeo sierra" }; + + // Ground truth accumulated during ingest. + private long totalDocs = 0L; + private long totalMatching = 0L; + + public void testStatsPruningCorrectUnderSchemaDrift() throws Exception { + createCompositeIndex(); + seedDriftingSegments(); + + // 1) Numeric-filter-only: severity >= 0 is true for every doc → full count. + long filterOnly = scalarCount("source = " + INDEX + " | where severity >= 0 | stats count()"); + assertEquals("numeric filter only (severity>=0) must not drop rows under schema drift", totalDocs, filterOnly); + + // 2) Sort + limit (TopK): exercises the dynamic-filter prune path. head k over the + // filtered set must return exactly min(k, totalDocs) rows, all with severity >= 0. + int k = 50; + List> topk = datarows( + executePpl("source = " + INDEX + " | where severity >= 0 | sort - severity | head " + k + " | fields severity") + ); + assertEquals("sort+limit row count under schema drift", Math.min(k, (int) totalDocs), topk.size()); + for (List r : topk) { + assertTrue("every returned row satisfies severity >= 0", num(r.get(0)) >= 0); + } + + // 3) match() + numeric residual: the production shape. Lucene-delegated match() AND a + // stats-pruned residual. Must equal the matching-doc ground truth. + long matchAndResidual = scalarCount( + "source = " + INDEX + " | where match(message, '" + TARGET + "') and severity >= 0 | stats count()" + ); + assertEquals("match() AND severity>=0 must not drop rows under schema drift", totalMatching, matchAndResidual); + + // Sanity: match() alone agrees with the residual-combined count (residual is always true). + long matchOnly = scalarCount("source = " + INDEX + " | where match(message, '" + TARGET + "') | stats count()"); + assertEquals("match() alone equals match()+residual (residual is always true)", matchOnly, matchAndResidual); + + // 4) match() + filter + sort + limit: all three prune paths together. + List> combined = datarows( + executePpl( + "source = " + INDEX + " | where match(message, '" + TARGET + "') and severity >= 0 " + + "| sort - severity | head " + k + " | fields severity" + ) + ); + assertEquals("match()+filter+sort+limit row count under schema drift", Math.min(k, (int) totalMatching), combined.size()); + for (List r : combined) { + assertTrue("every combined row satisfies severity >= 0", num(r.get(0)) >= 0); + } + } + + // ── Setup / ingest ─────────────────────────────────────────────────────────── + + private void createCompositeIndex() throws IOException { + try { + client().performRequest(new Request("DELETE", "/" + INDEX)); + } catch (Exception ignored) { + // index may not exist yet + } + + // message + severity declared up front; dynamic:true (the default) lets pad_* fields + // be auto-mapped per segment, producing the schema drift this test depends on. + // total_fields.limit is raised to admit the ~300 dynamic pad fields; row_group_max_rows + // is small so each segment has several row groups. + String body = "{" + + "\"settings\": {" + + " \"number_of_shards\": 1," + + " \"number_of_replicas\": 0," + + " \"index.mapping.total_fields.limit\": 2000," + + " \"index.parquet.row_group_max_rows\": " + ROW_GROUP_MAX_ROWS + "," + + " \"index.pluggable.dataformat.enabled\": true," + + " \"index.pluggable.dataformat\": \"composite\"," + + " \"index.composite.primary_data_format\": \"parquet\"," + + " \"index.composite.secondary_data_formats\": \"lucene\"" + + "}," + + "\"mappings\": {" + + " \"dynamic\": true," + + " \"properties\": {" + + " \"message\": { \"type\": \"text\" }," + + " \"severity\": { \"type\": \"byte\" }" + + " }" + + "}" + + "}"; + + Request createIndex = new Request("PUT", "/" + INDEX); + createIndex.setJsonEntity(body); + Map response = assertOkAndParse(client().performRequest(createIndex), "create index"); + assertEquals(true, response.get("acknowledged")); + + Request health = new Request("GET", "/_cluster/health/" + INDEX); + health.addParameter("wait_for_status", "green"); + health.addParameter("timeout", "30s"); + client().performRequest(health); + } + + /** + * Seeds {@link #SEGMENTS} segments of growing width. Segment {@code s} writes documents + * carrying {@code pad_0..pad_{W(s)-1}} dynamically-mapped numeric fields, where {@code W(s)} + * grows from a few fields up to {@link #MAX_PAD_FIELDS}. Successive segments therefore have + * different-sized field sets → divergent per-segment HashMap column order → {@code severity} + * drifts to a different ordinal than its union-schema ordinal. Every doc has a non-negative + * {@code severity}, so {@code severity >= 0} is always true and must never prune. + */ + private void seedDriftingSegments() throws IOException { + for (int s = 0; s < SEGMENTS; s++) { + int width = padWidthForSegment(s); + StringBuilder bulk = new StringBuilder(DOCS_PER_SEGMENT * 64); + for (int i = 0; i < DOCS_PER_SEGMENT; i++) { + boolean matching = i < MATCHING_PER_SEGMENT; + String message = matching + ? MATCHING_TEMPLATES[i % MATCHING_TEMPLATES.length] + : NON_MATCHING_TEMPLATES[i % NON_MATCHING_TEMPLATES.length]; + // severity in 0..17, always non-negative. + int severity = (i % 18); + + StringBuilder doc = new StringBuilder(); + doc.append("{\"message\":\"").append(escapeJson(message)).append("\""); + doc.append(",\"severity\":").append(severity); + // Pad values are strongly NEGATIVE so that if `severity` misresolves onto a + // pad column (positional drift), the wrong column's max < 0 makes the always-true + // `severity >= 0` evaluate to "cannot match" → the RG/segment is wrongly pruned. + // (With non-negative pads, a misresolution onto a pad would still satisfy + // severity >= 0 and silently hide the bug.) + for (int p = 0; p < width; p++) { + doc.append(",\"pad_").append(p).append("\":").append(-1000000 - p); + } + doc.append("}"); + + bulk.append("{\"index\":{}}\n"); + bulk.append(doc).append("\n"); + } + + Request req = new Request("POST", "/" + INDEX + "/_bulk"); + req.setJsonEntity(bulk.toString()); + Map parsed = assertOkAndParse(client().performRequest(req), "bulk segment " + s); + assertFalse("bulk errors in segment " + s + ": " + parsed, Boolean.TRUE.equals(parsed.get("errors"))); + + totalDocs += DOCS_PER_SEGMENT; + totalMatching += MATCHING_PER_SEGMENT; + + refresh(); + flush(); // force a parquet segment write; merge is disabled on this cluster variant + } + } + + /** Growing pad width per segment: a few fields in segment 0 up to MAX_PAD_FIELDS in the last. */ + private static int padWidthForSegment(int s) { + int w = (int) (((long) (s + 1) * MAX_PAD_FIELDS) / SEGMENTS); + return Math.max(1, Math.min(MAX_PAD_FIELDS, w)); + } + + // ── REST helpers ─────────────────────────────────────────────────────────────── + + private void refresh() throws IOException { + client().performRequest(new Request("POST", "/" + INDEX + "/_refresh")); + } + + private void flush() throws IOException { + Request req = new Request("POST", "/" + INDEX + "/_flush"); + req.addParameter("force", "true"); + client().performRequest(req); + } + + private long scalarCount(String ppl) throws IOException { + Request req = new Request("POST", "/_plugins/_ppl"); + req.setJsonEntity("{\"query\": \"" + escapeJson(ppl) + "\"}"); + Response resp = client().performRequest(req); + Map parsed = assertOkAndParse(resp, "PPL: " + ppl); + List> rows = datarows(parsed); + assertNotNull("PPL response missing datarows for: " + ppl, rows); + assertEquals("scalar count should return exactly 1 row for: " + ppl, 1, rows.size()); + assertEquals("scalar count should return exactly 1 column for: " + ppl, 1, rows.get(0).size()); + Object v = rows.get(0).get(0); + assertNotNull("scalar count value must not be null for: " + ppl, v); + return ((Number) v).longValue(); + } + + @SuppressWarnings("unchecked") + private static List> datarows(Map result) { + return (List>) result.get("datarows"); + } + + private static double num(Object cell) { + return ((Number) cell).doubleValue(); + } +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/ScopedPageIndexCacheIT.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/ScopedPageIndexCacheIT.java new file mode 100644 index 0000000000000..45a22477f1d0d --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/ScopedPageIndexCacheIT.java @@ -0,0 +1,1345 @@ +/* + * 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.analytics.qa; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.opensearch.client.Request; +import org.opensearch.client.Response; + +import java.io.IOException; +import java.util.Map; + +/** + * Integration tests for the scoped page-index cache (ColumnIndex + OffsetIndex). + * + *

    The cache is governed by the {@code datafusion.scoped_page_index.enabled} cluster setting. + * Key invariants verified across the suite: + *

      + *
    • Enabled: CI fills on cold query (misses), hits on warm repeat. Metadata cache is smaller + * because page index entries are stripped and kept separately in the scoped cache.
    • + *
    • Disabled: CI/OI stats = 0. Metadata cache is larger because page index is retained via + * PageIndexPolicy::Optional.
    • + *
    • Toggle-off clears CI/OI caches immediately; metadata cache is unaffected.
    • + *
    • Explicit clear resets stats so the next query is a miss again.
    • + *
    • OI fills on projection-only queries; CI does NOT fill when there is no predicate.
    • + *
    • Both CI and OI fill when the same column is filtered and projected.
    • + *
    • Schema-drift (new field added in a later segment) is handled: CI fills for the new field + * from the segment that carries it, and the old-field query continues to work.
    • + *
    • After a force-merge the pre-merge segment entries are gone; a fresh query re-misses.
    • + *
    + * + *

    Every test that needs clean measurements calls {@code recreateIndex()} (or a named variant), + * then {@code clearAllCaches()}, then {@code assertCacheEmpty()} before measuring. This ensures + * that any warming triggered by the refresh inside {@code recreateIndex()} is wiped out. + */ +public class ScopedPageIndexCacheIT extends AnalyticsRestTestCase { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String INDEX_NAME = "scoped_cache_it"; + private static final String DYNAMIC_INDEX_NAME = "scoped_cache_dynamic_it"; + private static final String CLEAR_ENDPOINT = "/_plugins/_analytics_backend_datafusion/cache/_clear"; + private static final int DOC_COUNT = 2000; + + /** + * Per-test setup via {@link AnalyticsRestTestCase#onBeforeQuery()} — this fires + * inside the test body lifecycle where {@link #client()} is guaranteed non-null. + * Using {@code setUp()} is unsafe here because {@code client()} may not yet be + * initialized when setUp() runs (see existing IT comments in this package). + */ + @Override + protected void onBeforeQuery() throws IOException { + setScopedPageIndexEnabled(true); + try { + recreateIndex(); + clearAllCaches(); + } catch (Exception e) { + throw new IOException("ScopedPageIndexCacheIT setup failed", e); + } + } + + @Override + public void tearDown() throws Exception { + try { + setScopedPageIndexEnabled(true); + restoreCacheDefaults(); + restoreLuceneBlockedPredicates(); + clearAllCaches(); + } catch (Exception ignored) { + // best-effort: don't mask the test failure + } + super.tearDown(); + } + + @Override + protected boolean preserveClusterUponCompletion() { + return true; + } + + // ── listing table path ──────────────────────────────────────────────────── + + /** + * Listing path (numeric age predicate): refresh must NOT fill the scoped CI cache; only a + * query should. Cold run produces CI misses; warm repeat produces CI hits. + */ + public void testListingPathColdMissThenWarmHit() throws Exception { + String ppl = "source=" + INDEX_NAME + " | where age > 90 | stats count()"; + + // After recreateIndex() + clearAllCaches() in setUp(): + // both metadata memory and CI entries must be 0 — refresh did not fill the cache. + JsonNode afterRefresh = stats(); + assertEquals("refresh must NOT fill metadata cache", 0L, metaMemoryBytes(afterRefresh)); + assertEquals("refresh must NOT fill CI cache", 0L, ciEntries(afterRefresh)); + assertEquals("refresh must NOT fill OI cache", 0L, oiEntries(afterRefresh)); + + // Cold query — must fill CI and OI (misses) and populate metadata cache. + executePpl(ppl); + JsonNode cold = stats(); + assertTrue("cold query must populate metadata cache", metaMemoryBytes(cold) > 0); + assertTrue("cold query must register CI misses", ciMisses(cold) > 0); + assertTrue("cold query must register OI misses (predicate col + col 0)", oiMisses(cold) > 0); + + // Warm query — same query hits from both CI and OI caches. + executePpl(ppl); + JsonNode warm = stats(); + assertTrue("warm query must register CI hits", ciHits(warm) > 0); + assertTrue("warm query must register OI hits", oiHits(warm) > 0); + } + + /** + * Listing path disabled: CI/OI = 0, metadata cache larger than enabled baseline. + * + *

    Flow per mode: delete → create → index → refresh → clearAllCaches() → + * assertCacheEmpty() → query → measure. + */ + public void testListingPathDisabledMetadataCacheLarger() throws Exception { + String ppl = "source=" + INDEX_NAME + " | where age > 90 | stats count()"; + + // ── ENABLED baseline ────────────────────────────────────────────── + setScopedPageIndexEnabled(true); + recreateIndex(); + clearAllCaches(); + assertCacheEmpty(); + + executePpl(ppl); + JsonNode s1 = stats(); + long metaEnabled = metaMemoryBytes(s1); + assertTrue("enabled: metadata cache populated by query", metaEnabled > 0); + assertTrue("enabled: CI must have activity", ciMisses(s1) + ciHits(s1) > 0); + + // ── DISABLED ───────────────────────────────────────────────────── + setScopedPageIndexEnabled(false); + assertEquals("CI entries 0 after auto-clear on disable", 0L, ciEntries(stats())); + assertEquals("OI entries 0 after auto-clear on disable", 0L, oiEntries(stats())); + + recreateIndex(); + clearAllCaches(); + assertCacheEmpty(); + + executePpl(ppl); + JsonNode s2 = stats(); + long metaDisabled = metaMemoryBytes(s2); + assertTrue("disabled: metadata cache populated by query", metaDisabled > 0); + assertTrue( + "disabled metadata >= enabled (page index retained): enabled=" + + metaEnabled + " disabled=" + metaDisabled, + metaDisabled >= metaEnabled + ); + assertEquals("CI hits 0 with scoped disabled", 0L, ciHits(s2)); + assertEquals("CI misses 0 with scoped disabled", 0L, ciMisses(s2)); + assertEquals("OI hits 0 with scoped disabled", 0L, oiHits(s2)); + assertEquals("OI misses 0 with scoped disabled", 0L, oiMisses(s2)); + } + + // ── indexed path (match() → Lucene + DataFusion) ────────────────────────── + + /** + * Indexed path: refresh must NOT fill the scoped CI/OI cache; only a query should. + * Cold run produces CI/OI misses; warm repeat produces hits. + */ + public void testIndexedPathColdMissThenWarmHit() throws Exception { + String ppl = "source=" + INDEX_NAME + " | where match(city, 'seattle') and age > 50 | stats count()"; + + // After setUp(): refresh must not have filled the scoped cache. + JsonNode afterRefresh = stats(); + assertEquals("refresh must NOT fill metadata cache", 0L, metaMemoryBytes(afterRefresh)); + assertEquals("refresh must NOT fill CI cache", 0L, ciEntries(afterRefresh)); + assertEquals("refresh must NOT fill OI cache", 0L, oiEntries(afterRefresh)); + + // Cold query — fills both CI and OI. + executePpl(ppl); + JsonNode cold = stats(); + assertTrue("indexed cold: metadata cache populated by query", metaMemoryBytes(cold) > 0); + assertTrue("indexed cold: CI misses > 0", ciMisses(cold) > 0); + assertTrue("indexed cold: OI misses > 0 (predicate col + col 0)", oiMisses(cold) > 0); + + // Warm query — hits from both CI and OI caches. + executePpl(ppl); + JsonNode warm = stats(); + assertTrue("indexed warm: CI hits > 0", ciHits(warm) > 0); + assertTrue("indexed warm: OI hits > 0", oiHits(warm) > 0); + } + + /** + * Indexed path disabled: CI/OI = 0, metadata cache larger than enabled baseline. + * + *

    Same flow as the listing test: recreate → clear → assertCacheEmpty → query → measure. + */ + public void testIndexedPathDisabledMetadataCacheLarger() throws Exception { + String ppl = "source=" + INDEX_NAME + " | where match(city, 'seattle') and age > 50 | stats count()"; + + // ── ENABLED baseline ────────────────────────────────────────────── + setScopedPageIndexEnabled(true); + recreateIndex(); + clearAllCaches(); + assertCacheEmpty(); + + executePpl(ppl); + JsonNode s1 = stats(); + long metaEnabled = metaMemoryBytes(s1); + assertTrue("indexed enabled: metadata cache populated", metaEnabled > 0); + + // ── DISABLED ───────────────────────────────────────────────────── + setScopedPageIndexEnabled(false); + assertEquals("CI entries 0 after auto-clear on disable", 0L, ciEntries(stats())); + assertEquals("OI entries 0 after auto-clear on disable", 0L, oiEntries(stats())); + + recreateIndex(); + clearAllCaches(); + assertCacheEmpty(); + + executePpl(ppl); + JsonNode s2 = stats(); + long metaDisabled = metaMemoryBytes(s2); + assertTrue("indexed disabled: metadata cache populated", metaDisabled > 0); + assertTrue( + "indexed disabled metadata >= enabled (page index retained): enabled=" + + metaEnabled + " disabled=" + metaDisabled, + metaDisabled >= metaEnabled + ); + assertEquals("CI hits 0 on indexed path disabled", 0L, ciHits(s2)); + assertEquals("CI misses 0 on indexed path disabled", 0L, ciMisses(s2)); + assertEquals("OI hits 0 on indexed path disabled", 0L, oiHits(s2)); + assertEquals("OI misses 0 on indexed path disabled", 0L, oiMisses(s2)); + } + + // ── string field filter (CI) ────────────────────────────────────────────── + + /** + * String keyword field predicate: {@code city = 'seattle'} hits the ColumnIndex because + * {@code city} is a keyword with string min/max statistics in the parquet ColumnIndex. + * Cold query fills CI with misses; warm repeat hits from cache. + */ + public void testStringFieldFilterFillsColumnIndex() throws Exception { + // city is a keyword field; equality would normally be delegated to Lucene and never + // touch parquet. Block EQUALS so the predicate scans parquet and fills the ColumnIndex. + setLuceneBlockedPredicates("EQUALS"); + String ppl = "source=" + INDEX_NAME + " | where city = 'seattle' | stats count()"; + clearAllCaches(); + assertCacheEmpty(); + + assertCacheFillsOnCold(ppl); + // CI: 1 file × city(col 3) × 1 RG = 1 entry + assertExactCiEntries("string predicate: CI entries = 1 (file×col×rg)", 1L); + // OI: 1 file × {col_0(name), city(col 3)} = 2 entries + assertExactOiEntries("string predicate: OI entries = 2 (file×{col_0,city})", 2L); + assertCacheHitsOnWarm(ppl); + } + + // ── numeric field filter (CI) ───────────────────────────────────────────── + + /** + * Numeric double field predicate: {@code score > 75.0} hits the ColumnIndex because + * {@code score} carries numeric min/max statistics in the parquet ColumnIndex. + * Cold query fills CI with misses; warm repeat hits from cache. + */ + public void testNumericFieldFilterFillsColumnIndex() throws Exception { + String ppl = "source=" + INDEX_NAME + " | where score > 75.0 | stats count()"; + clearAllCaches(); + assertCacheEmpty(); + + assertCacheFillsOnCold(ppl); + // CI: 1 file × score(col 2) × 1 RG = 1 entry + assertExactCiEntries("numeric predicate: CI entries = 1 (file×col×rg)", 1L); + // OI: 1 file × {col_0(name), score(col 2)} = 2 entries + assertExactOiEntries("numeric predicate: OI entries = 2 (file×{col_0,score})", 2L); + assertCacheHitsOnWarm(ppl); + } + + // ── projection-only, no filter (OI only) ───────────────────────────────── + + /** + * Projection-only query with no filter: {@code fields name, score | head 100} requires the + * OffsetIndex to locate the page offsets for the projected columns, but there is no predicate + * so the ColumnIndex should NOT be consulted at all. + * Asserts: OI misses > 0, CI misses == 0. + */ + public void testProjectionOnlyFillsOffsetIndexNotColumnIndex() throws Exception { + String ppl = "source=" + INDEX_NAME + " | fields name, score | head 100"; + clearAllCaches(); + assertCacheEmpty(); + + assertOnlyOiFills(ppl); + // OI key = (file, col); the column set is {col 0} ∪ predicate ∪ projection. The + // page-skip metric always reads col 0, so it is included even when not projected. + // Here name/score are distinct keyword/double leaves (neither is col 0), so the set + // is {col 0, name, score} = 3 entries over 1 file. + assertExactOiEntries("projection-only: OI entries = 3 (file×{col 0,name,score})", 3L); + assertExactCiEntries("projection-only: CI entries = 0 (no predicate)", 0L); + } + + // ── filter = projection same columns (CI + OI overlap) ─────────────────── + + /** + * When the same column ({@code age}) is used in both a filter predicate and a projection, + * the query must consult both the ColumnIndex (predicate push-down) and the OffsetIndex + * (column projection). Both CI and OI should accumulate misses on the cold run. + */ + public void testFilterAndProjectionSameColumnFillsBothCaches() throws Exception { + String ppl = "source=" + INDEX_NAME + " | where age > 80 | fields age | stats count()"; + clearAllCaches(); + assertCacheEmpty(); + + executePpl(ppl); + JsonNode cold = stats(); + assertPositive("CI+OI overlap: CI misses > 0", ciMisses(cold)); + assertPositive("CI+OI overlap: OI misses > 0", oiMisses(cold)); + assertPositive("CI+OI overlap: metadata populated", metaMemoryBytes(cold)); + // CI: 1 file × age(col 1) × 1 RG = 1 entry + // OI: 1 file × {col_0(name), age(col 1)} = 2 entries (predicate ∪ projection ∪ {col_0}) + assertExactCiEntries("filter=projection: CI entries = 1 (age×file×rg)", 1L); + assertExactOiEntries("filter=projection: OI entries = 2 (file×{col_0(name),age})", 2L); + } + + // ── dynamic mapping — new field in second segment ───────────────────────── + + /** + * Schema-drift scenario: a new field ({@code region}) is added in a second batch after the + * first segment already exists. Verifies that: + *

      + *
    1. The first batch's field ({@code age}) can still be queried correctly after the schema + * expands.
    2. + *
    3. The new field ({@code region}) fills the CI from the segment that carries it, even + * though the first segment has no such column.
    4. + *
    + * Uses a dedicated index ({@value #DYNAMIC_INDEX_NAME}) with dynamic mapping so no explicit + * schema is defined — the mapping is inferred from each batch. + */ + public void testDynamicMappingNewFieldInSecondSegmentFillsCI() throws Exception { + // region/city are keyword fields; block EQUALS so the keyword-equality predicate + // (region = 'west') scans parquet and fills the ColumnIndex instead of being + // delegated to Lucene. + setLuceneBlockedPredicates("EQUALS"); + + // Clean up any leftovers from a prior run. + try { client().performRequest(new Request("DELETE", "/" + DYNAMIC_INDEX_NAME)); } catch (Exception ignored) {} + + // Create index with dynamic mapping (no explicit properties) and composite parquet format. + String createBody = "{" + + "\"settings\":{" + + " \"number_of_shards\":1,\"number_of_replicas\":0," + + " \"index.pluggable.dataformat.enabled\":true," + + " \"index.pluggable.dataformat\":\"composite\"," + + " \"index.composite.primary_data_format\":\"parquet\"," + + " \"index.composite.secondary_data_formats\":\"lucene\"" + + "}}"; + Request create = new Request("PUT", "/" + DYNAMIC_INDEX_NAME); + create.setJsonEntity(createBody); + assertOkAndParse(client().performRequest(create), "create dynamic index"); + + Request health = new Request("GET", "/_cluster/health/" + DYNAMIC_INDEX_NAME); + health.addParameter("wait_for_status", "green"); + health.addParameter("timeout", "30s"); + client().performRequest(health); + + // Batch 1: fields name, age, city — creates segment 1. + StringBuilder batch1 = new StringBuilder(); + String[] cities = {"seattle", "portland", "denver", "austin", "boston"}; + for (int i = 0; i < 500; i++) { + batch1.append("{\"index\":{}}\n") + .append("{\"name\":\"user").append(i) + .append("\",\"age\":").append(i % 100) + .append(",\"city\":\"").append(cities[i % cities.length]) + .append("\"}\n"); + } + Request bulk1 = new Request("POST", "/" + DYNAMIC_INDEX_NAME + "/_bulk"); + bulk1.setJsonEntity(batch1.toString()); + bulk1.addParameter("refresh", "true"); + bulk1.setOptions(bulk1.getOptions().toBuilder().addHeader("Content-Type", "application/x-ndjson").build()); + Map r1 = assertOkAndParse(client().performRequest(bulk1), "dynamic bulk1"); + assertEquals("dynamic bulk1 errors", false, r1.get("errors")); + + // After batch 1 refresh: clear caches and verify age query fills CI. + clearAllCaches(); + assertCacheEmpty(); + + String agePpl = "source=" + DYNAMIC_INDEX_NAME + " | where age > 50 | stats count()"; + executePpl(agePpl); + JsonNode afterBatch1 = stats(); + assertPositive("dynamic batch1: CI fills for age", ciMisses(afterBatch1)); + assertPositive("dynamic batch1: metadata populated", metaMemoryBytes(afterBatch1)); + + // Batch 2: adds NEW field region to every doc → creates segment 2 with schema drift. + StringBuilder batch2 = new StringBuilder(); + String[] regions = {"west", "east", "central"}; + for (int i = 500; i < 1000; i++) { + batch2.append("{\"index\":{}}\n") + .append("{\"name\":\"user").append(i) + .append("\",\"age\":").append(i % 100) + .append(",\"city\":\"").append(cities[i % cities.length]) + .append("\",\"region\":\"").append(regions[i % regions.length]) + .append("\"}\n"); + } + Request bulk2 = new Request("POST", "/" + DYNAMIC_INDEX_NAME + "/_bulk"); + bulk2.setJsonEntity(batch2.toString()); + bulk2.addParameter("refresh", "true"); + bulk2.setOptions(bulk2.getOptions().toBuilder().addHeader("Content-Type", "application/x-ndjson").build()); + Map r2 = assertOkAndParse(client().performRequest(bulk2), "dynamic bulk2"); + assertEquals("dynamic bulk2 errors", false, r2.get("errors")); + + // Clear caches before measuring new-field scenario. + clearAllCaches(); + assertCacheEmpty(); + + // Query on new field region — only segment 2 carries it; CI must fill from that segment. + String regionPpl = "source=" + DYNAMIC_INDEX_NAME + " | where region = 'west' | stats count()"; + executePpl(regionPpl); + JsonNode afterRegion = stats(); + assertPositive("dynamic: CI fills for new field region", ciMisses(afterRegion)); + assertPositive("dynamic: metadata populated after region query", metaMemoryBytes(afterRegion)); + + // The old-field age query must still work correctly after schema drift. + clearAllCaches(); + assertCacheEmpty(); + executePpl(agePpl); + JsonNode afterAgeDrift = stats(); + assertPositive("dynamic: age CI still fills after schema drift", ciMisses(afterAgeDrift)); + } + + // ── refresh → merge lifecycle ───────────────────────────────────────────── + + /** + * Force-merge lifecycle: two separate refresh cycles produce two segments. After a + * force-merge to one segment the pre-merge cache entries are stale; a fresh query after + * clearing must re-miss against the merged segment. + * + *

    Flow: + *

      + *
    1. Batch 1 → refresh (segment 1)
    2. + *
    3. Batch 2 → refresh (segment 2)
    4. + *
    5. clearAllCaches → assertCacheEmpty → query → assert CI fills across both segments
    6. + *
    7. Force-merge to 1 segment
    8. + *
    9. clearAllCaches → assertCacheEmpty (old entries gone)
    10. + *
    11. Query → CI misses > 0 (merged segment is a fresh miss)
    12. + *
    + */ + public void testRefreshThenMergeCacheRefillsAfterMerge() throws Exception { + // Start with a clean index that has NO data yet — we will drive two separate + // refreshes ourselves so we get two distinct segments. + try { client().performRequest(new Request("DELETE", "/" + INDEX_NAME)); } catch (Exception ignored) {} + + String createBody = "{" + + "\"settings\":{" + + " \"number_of_shards\":1,\"number_of_replicas\":0," + + " \"index.pluggable.dataformat.enabled\":true," + + " \"index.pluggable.dataformat\":\"composite\"," + + " \"index.composite.primary_data_format\":\"parquet\"," + + " \"index.composite.secondary_data_formats\":\"lucene\"" + + "}," + + "\"mappings\":{\"properties\":{" + + " \"name\":{\"type\":\"keyword\"}," + + " \"age\":{\"type\":\"integer\"}," + + " \"score\":{\"type\":\"double\"}," + + " \"city\":{\"type\":\"keyword\"}" + + "}}}"; + Request createReq = new Request("PUT", "/" + INDEX_NAME); + createReq.setJsonEntity(createBody); + assertOkAndParse(client().performRequest(createReq), "merge-test: create index"); + + Request health = new Request("GET", "/_cluster/health/" + INDEX_NAME); + health.addParameter("wait_for_status", "green"); + health.addParameter("timeout", "30s"); + client().performRequest(health); + + String[] cities = {"seattle", "portland", "denver", "austin", "boston"}; + + // Batch 1 → refresh → segment 1. + StringBuilder b1 = new StringBuilder(); + for (int i = 0; i < 500; i++) { + b1.append("{\"index\":{}}\n") + .append("{\"name\":\"user").append(i) + .append("\",\"age\":").append(i % 100) + .append(",\"score\":").append(50.0 + (i % 50)) + .append(",\"city\":\"").append(cities[i % cities.length]) + .append("\"}\n"); + } + Request bulk1 = new Request("POST", "/" + INDEX_NAME + "/_bulk"); + bulk1.setJsonEntity(b1.toString()); + bulk1.addParameter("refresh", "true"); + bulk1.setOptions(bulk1.getOptions().toBuilder().addHeader("Content-Type", "application/x-ndjson").build()); + Map rb1 = assertOkAndParse(client().performRequest(bulk1), "merge-test bulk1"); + assertEquals("merge-test bulk1 errors", false, rb1.get("errors")); + + // Batch 2 → refresh → segment 2. + StringBuilder b2 = new StringBuilder(); + for (int i = 500; i < 1000; i++) { + b2.append("{\"index\":{}}\n") + .append("{\"name\":\"user").append(i) + .append("\",\"age\":").append(i % 100) + .append(",\"score\":").append(50.0 + (i % 50)) + .append(",\"city\":\"").append(cities[i % cities.length]) + .append("\"}\n"); + } + Request bulk2 = new Request("POST", "/" + INDEX_NAME + "/_bulk"); + bulk2.setJsonEntity(b2.toString()); + bulk2.addParameter("refresh", "true"); + bulk2.setOptions(bulk2.getOptions().toBuilder().addHeader("Content-Type", "application/x-ndjson").build()); + Map rb2 = assertOkAndParse(client().performRequest(bulk2), "merge-test bulk2"); + assertEquals("merge-test bulk2 errors", false, rb2.get("errors")); + + // Clear caches then verify query fills CI across both segments. + clearAllCaches(); + assertCacheEmpty(); + + String ppl = "source=" + INDEX_NAME + " | where age > 80 | stats count()"; + executePpl(ppl); + JsonNode premerge = stats(); + assertPositive("pre-merge: CI misses across both segments", ciMisses(premerge)); + assertPositive("pre-merge: OI misses across both segments", oiMisses(premerge)); + assertPositive("pre-merge: metadata populated", metaMemoryBytes(premerge)); + // CI: 2 files × age(col 1) × 1 RG each = 2 entries + assertExactCiEntries("pre-merge: CI entries = 2 (2 files × age × rg)", 2L); + // OI: 2 files × {col_0(name), age(col 1)} = 4 entries + assertExactOiEntries("pre-merge: OI entries = 4 (2 files × {col_0,age})", 4L); + + // Force-merge to a single segment — old segment entries in the scoped cache are stale. + Request fm = new Request("POST", "/" + INDEX_NAME + "/_forcemerge"); + fm.addParameter("max_num_segments", "1"); + client().performRequest(fm); + + // Wipe stale entries; the merged segment should not be in the cache. + clearAllCaches(); + assertCacheEmpty(); + + // Fresh query against the merged segment — must re-miss. + executePpl(ppl); + JsonNode postmerge = stats(); + assertPositive("post-merge: CI misses > 0 (forced re-miss after merge)", ciMisses(postmerge)); + assertPositive("post-merge: OI misses > 0 (forced re-miss after merge)", oiMisses(postmerge)); + assertPositive("post-merge: metadata populated from merged segment", metaMemoryBytes(postmerge)); + // CI: 1 merged file × age(col 1) × 1 RG = 1 entry (down from 2) + assertExactCiEntries("post-merge: CI entries = 1 (1 merged file × age × rg)", 1L); + // OI: 1 merged file × {col_0(name), age(col 1)} = 2 entries (down from 4) + assertExactOiEntries("post-merge: OI entries = 2 (1 merged file × {col_0,age})", 2L); + } + + // ── cross-index shared cache ────────────────────────────────────────────── + + private static final String INDEX_B = "scoped_cache_it_b"; + + /** + * Two parquet indices share the same process-global CI/OI caches. + * Queries on INDEX_NAME and INDEX_B accumulate independent entries + * (different file paths → different cache keys). Entries from one index + * must not corrupt or evict entries from the other if the cache is sized + * to hold both comfortably. + * + *

    This exercises the FIFO write lock under real concurrent key diversity. + */ + public void testCrossIndexCacheEntriesAreIndependent() throws Exception { + // Provision a second index with the same schema. + try { client().performRequest(new Request("DELETE", "/" + INDEX_B)); } catch (Exception ignored) {} + provisionNamedIndex(INDEX_B); + try { + clearAllCaches(); + assertCacheEmpty(); + + String pplA = "source=" + INDEX_NAME + " | where age > 80 | stats count()"; + String pplB = "source=" + INDEX_B + " | where score > 75.0 | stats count()"; + + // Cold run on both indices. + executePpl(pplA); + executePpl(pplB); + JsonNode cold = stats(); + // Each index contributes 1 CI entry (1 file × 1 pred col × 1 RG). + // Total CI entries = 2 (one per index, different file paths). + long ciAfterBoth = ciEntries(cold); + assertTrue("cross-index: CI entries must be >= 2 after cold queries on both indices", + ciAfterBoth >= 2); + + // Warm run on both — both should hit. + long hitsBefore = ciHits(cold); + executePpl(pplA); + executePpl(pplB); + JsonNode warm = stats(); + assertTrue("cross-index: CI hits must increase after warm queries on both indices", + ciHits(warm) > hitsBefore); + + // Clearing index A's entries via prefix eviction must not affect index B. + // (Index B has different file path prefix so its entries survive.) + executePpl(pplB); // ensure B is warm + long bHitsBefore = ciHits(stats()); + // Simulate an eviction for INDEX_NAME by clearing all and re-running only B. + clearAllCaches(); + executePpl(pplA); // re-populates A + executePpl(pplB); // should hit from re-populated B after cache clear + // B's entries are fresh misses after the clear — that's expected and correct. + assertPositive("cross-index: B CI misses after full clear", ciMisses(stats())); + } finally { + try { client().performRequest(new Request("DELETE", "/" + INDEX_B)); } catch (Exception ignored) {} + } + } + + /** + * Cache sized to hold exactly one index's worth of entries (tight budget). + * Concurrent queries on INDEX_NAME and INDEX_B put the FIFO write lock under + * contention while eviction fires. After all queries: + *

      + *
    • used_bytes must not exceed the limit (correctness under eviction).
    • + *
    • Both indices must still return correct query results (cache is optional).
    • + *
    • Cache stats must be internally consistent (no phantom entries).
    • + *
    + * + *

    The budget is set to approximately the size of CI entries for one index so + * inserting B's entries forces eviction of A's (or vice versa) — exercising the + * FIFO drain path under concurrent inserts. + */ + public void testCacheContetionTwoIndicesOneTightBudget() throws Exception { + try { client().performRequest(new Request("DELETE", "/" + INDEX_B)); } catch (Exception ignored) {} + provisionNamedIndex(INDEX_B); + try { + // Set a tight budget: ~500 bytes — enough for a handful of entries but not all. + // A typical CI entry for an integer column with 2000 rows is ~100-500 bytes. + // This forces eviction when both indices push entries concurrently. + setCacheTotalSize("2kb"); + clearAllCaches(); + assertCacheEmpty(); + + String pplA1 = "source=" + INDEX_NAME + " | where age > 80 | stats count()"; + String pplA2 = "source=" + INDEX_NAME + " | where score > 75.0 | stats count()"; + String pplB1 = "source=" + INDEX_B + " | where age > 60 | stats count()"; + String pplB2 = "source=" + INDEX_B + " | where score > 60.0 | stats count()"; + + // Interleave queries on both indices — drives concurrent cache inserts + evictions. + for (int i = 0; i < 3; i++) { + executePpl(pplA1); + executePpl(pplB1); + executePpl(pplA2); + executePpl(pplB2); + // Interleave refreshes to create new segments. + if (i == 1) { + client().performRequest(new Request("POST", "/" + INDEX_NAME + "/_refresh")); + client().performRequest(new Request("POST", "/" + INDEX_B + "/_refresh")); + } + } + + JsonNode s = stats(); + long limit = s.get("column_index_cache").get("size_limit_bytes").asLong(); + long usedBytes = s.get("column_index_cache").get("memory_bytes").asLong(); + + // Correctness: cache must never exceed its limit. + assertTrue( + "CI used_bytes=" + usedBytes + " must be <= size_limit_bytes=" + limit, + usedBytes <= limit + ); + + // Result correctness: queries must return valid results regardless of eviction. + // Re-run all queries — they must not error even if the cache is empty. + executePpl(pplA1); + executePpl(pplA2); + executePpl(pplB1); + executePpl(pplB2); + + } finally { + restoreCacheDefaults(); + clearAllCaches(); + try { client().performRequest(new Request("DELETE", "/" + INDEX_B)); } catch (Exception ignored) {} + } + } + + /** Provision a named parquet index with the same schema as the main test index. */ + private void provisionNamedIndex(String indexName) throws IOException { + String body = "{" + + "\"settings\":{" + + " \"number_of_shards\":1,\"number_of_replicas\":0," + + " \"index.pluggable.dataformat.enabled\":true," + + " \"index.pluggable.dataformat\":\"composite\"," + + " \"index.composite.primary_data_format\":\"parquet\"," + + " \"index.composite.secondary_data_formats\":\"lucene\"" + + "}," + + "\"mappings\":{\"properties\":{" + + " \"name\":{\"type\":\"keyword\"}," + + " \"age\":{\"type\":\"integer\"}," + + " \"score\":{\"type\":\"double\"}," + + " \"city\":{\"type\":\"keyword\"}" + + "}}}"; + Request create = new Request("PUT", "/" + indexName); + create.setJsonEntity(body); + assertOkAndParse(client().performRequest(create), "create " + indexName); + + Request health = new Request("GET", "/_cluster/health/" + indexName); + health.addParameter("wait_for_status", "green"); + health.addParameter("timeout", "30s"); + client().performRequest(health); + + String[] cities = {"seattle", "portland", "denver", "austin", "boston"}; + StringBuilder bulk = new StringBuilder(); + for (int i = 0; i < DOC_COUNT; i++) { + bulk.append("{\"index\":{}}\n") + .append("{\"name\":\"user").append(i) + .append("\",\"age\":").append(i % 100) + .append(",\"score\":").append(50.0 + (i % 50)) + .append(",\"city\":\"").append(cities[i % cities.length]) + .append("\"}\n"); + } + Request bulkReq = new Request("POST", "/" + indexName + "/_bulk"); + bulkReq.setJsonEntity(bulk.toString()); + bulkReq.addParameter("refresh", "true"); + bulkReq.setOptions(bulkReq.getOptions().toBuilder().addHeader("Content-Type", "application/x-ndjson").build()); + Map r = assertOkAndParse(client().performRequest(bulkReq), "bulk " + indexName); + assertEquals(indexName + " bulk errors", false, r.get("errors")); + } + + // ── full enabled → disabled lifecycle loop ──────────────────────────────── + + /** + * Full lifecycle covering listing + indexed paths through enabled → disabled → re-enabled: + * + *

    +     * Phase 1 — ENABLED, listing path
    +     *   recreate → clear → assertCacheEmpty (refresh did NOT fill)
    +     *   query → assert metadata populated, CI misses > 0
    +     *   query again → assert CI hits > 0 (warm)
    +     *
    +     * Phase 2 — ENABLED, indexed path  (same data, same caches still live)
    +     *   match() query → CI/OI misses or hits recorded (cumulative)
    +     *   match() query again → hits increase
    +     *
    +     * Phase 3 — DISABLE  (auto-clear CI/OI)
    +     *   assert CI entries = 0, OI entries = 0 immediately
    +     *   assert metadata still present (not cleared by disabling)
    +     *
    +     * Phase 4 — DISABLED, listing path
    +     *   recreate → clear → assertCacheEmpty
    +     *   query → assert metadata populated AND larger than enabled baseline
    +     *   assert CI = 0, OI = 0
    +     *
    +     * Phase 5 — DISABLED, indexed path
    +     *   recreate → clear → assertCacheEmpty
    +     *   match() query → metadata larger than enabled, CI = 0, OI = 0
    +     *
    +     * Phase 6 — RE-ENABLE
    +     *   recreate → clear → assertCacheEmpty
    +     *   query → CI misses fill again (confirms scoped cache is back)
    +     * 
    + */ + public void testFullLifecycleEnabledDisabledReenabled() throws Exception { + String listingPpl = "source=" + INDEX_NAME + " | where age > 90 | stats count()"; + String indexedPpl = "source=" + INDEX_NAME + " | where match(city, 'seattle') and age > 50 | stats count()"; + + // ── Phase 1: ENABLED, listing path ──────────────────────────────── + setScopedPageIndexEnabled(true); + recreateIndex(); + clearAllCaches(); + + // Refresh must NOT fill the scoped cache. + assertZero("P1 refresh: metadata", metaMemoryBytes(stats())); + assertZero("P1 refresh: CI entries", ciEntries(stats())); + assertZero("P1 refresh: OI entries", oiEntries(stats())); + + // Cold listing query. + executePpl(listingPpl); + JsonNode p1cold = stats(); + assertPositive("P1 cold: metadata populated", metaMemoryBytes(p1cold)); + assertPositive("P1 cold: CI misses", ciMisses(p1cold)); + long metaEnabledListing = metaMemoryBytes(p1cold); + + // Warm listing query. + executePpl(listingPpl); + assertPositive("P1 warm: CI hits", ciHits(stats())); + + // ── Phase 2: ENABLED, indexed path ──────────────────────────────── + executePpl(indexedPpl); + JsonNode p2cold = stats(); + long p2coldActivity = ciMisses(p2cold) + oiMisses(p2cold) + ciHits(p2cold) + oiHits(p2cold); + assertPositive("P2 cold indexed: CI/OI activity", p2coldActivity); + + executePpl(indexedPpl); + JsonNode p2warm = stats(); + long p2warmHits = ciHits(p2warm) + oiHits(p2warm); + assertPositive("P2 warm indexed: CI or OI hits", p2warmHits); + + // ── Phase 3: DISABLE ────────────────────────────────────────────── + setScopedPageIndexEnabled(false); + JsonNode p3 = stats(); + assertZero("P3 disable: CI entries auto-cleared", ciEntries(p3)); + assertZero("P3 disable: OI entries auto-cleared", oiEntries(p3)); + // Metadata cache is unaffected by the toggle. + assertPositive("P3 disable: metadata cache still present", metaMemoryBytes(p3)); + + // ── Phase 4: DISABLED, listing path ─────────────────────────────── + recreateIndex(); + clearAllCaches(); + assertZero("P4 before query: metadata", metaMemoryBytes(stats())); + assertZero("P4 before query: CI", ciEntries(stats())); + assertZero("P4 before query: OI", oiEntries(stats())); + + executePpl(listingPpl); + JsonNode p4 = stats(); + long metaDisabledListing = metaMemoryBytes(p4); + assertPositive("P4 disabled listing: metadata populated", metaDisabledListing); + assertTrue( + "P4 disabled listing metadata >= enabled (page index retained): enabled=" + + metaEnabledListing + " disabled=" + metaDisabledListing, + metaDisabledListing >= metaEnabledListing + ); + assertZero("P4 CI hits", ciHits(p4)); + assertZero("P4 CI misses", ciMisses(p4)); + assertZero("P4 OI hits", oiHits(p4)); + assertZero("P4 OI misses", oiMisses(p4)); + + // ── Phase 5: DISABLED, indexed path ─────────────────────────────── + recreateIndex(); + clearAllCaches(); + assertZero("P5 before query: metadata", metaMemoryBytes(stats())); + + executePpl(indexedPpl); + JsonNode p5 = stats(); + long metaDisabledIndexed = metaMemoryBytes(p5); + assertPositive("P5 disabled indexed: metadata populated", metaDisabledIndexed); + assertTrue( + "P5 disabled indexed metadata >= enabled: enabled=" + + metaEnabledListing + " disabled=" + metaDisabledIndexed, + metaDisabledIndexed >= metaEnabledListing + ); + assertZero("P5 CI hits", ciHits(p5)); + assertZero("P5 CI misses", ciMisses(p5)); + assertZero("P5 OI hits", oiHits(p5)); + assertZero("P5 OI misses", oiMisses(p5)); + + // ── Phase 6: RE-ENABLE ──────────────────────────────────────────── + setScopedPageIndexEnabled(true); + recreateIndex(); + clearAllCaches(); + assertZero("P6 before query: metadata", metaMemoryBytes(stats())); + assertZero("P6 before query: CI", ciEntries(stats())); + + executePpl(listingPpl); + JsonNode p6 = stats(); + assertPositive("P6 re-enabled: CI misses (scoped cache back)", ciMisses(p6)); + assertPositive("P6 re-enabled: metadata populated", metaMemoryBytes(p6)); + // Metadata is footer-only again (smaller than disabled). + assertTrue( + "P6 re-enabled metadata must be <= disabled (page index stripped): enabled=" + + metaMemoryBytes(p6) + " disabled=" + metaDisabledListing, + metaMemoryBytes(p6) <= metaDisabledListing + ); + } + + // ── pure Lucene path (no parquet scan) ─────────────────────────────────── + + /** + * {@code match(city, 'seattle') | stats count()} can be satisfied entirely by + * Lucene's inverted index without touching the parquet files, so neither CI + * nor OI should be populated. No parquet IO means no page-index loading. + */ + public void testPureLuceneCountQueryProducesNoCacheActivity() throws Exception { + clearAllCaches(); + assertCacheEmpty(); + + // match() + count(*) — Lucene satisfies this without scanning parquet pages + String ppl = "source=" + INDEX_NAME + " | where match(city, 'seattle') | stats count()"; + executePpl(ppl); + executePpl(ppl); + + JsonNode s = stats(); + assertZero("pure Lucene count: CI hits must be 0", ciHits(s)); + assertZero("pure Lucene count: CI misses must be 0", ciMisses(s)); + assertZero("pure Lucene count: OI hits must be 0", oiHits(s)); + assertZero("pure Lucene count: OI misses must be 0", oiMisses(s)); + assertZero("pure Lucene count: CI entries must be 0", ciEntries(s)); + assertZero("pure Lucene count: OI entries must be 0", oiEntries(s)); + } + + /** + * Aggregation whose {@code case(...)} expression reads a column that is NOT in the + * group-by/projection list must still scan that column correctly. + * + *

    Regression test for a scoped-OffsetIndex bug: the optimizer derived the scan's read set + * from the projected output field names, but with expression push-down the projection + * contains computed fields like {@code CASE WHEN status = 200 ...} whose names are not file + * columns. The base column the CASE reads ({@code age}/{@code score} here) was therefore left + * out of the scoped OffsetIndex and got a single-page placeholder. When the scan actually read + * that column, arrow decoded the whole chunk as one page and failed with + * "provided output is too small for the decompressed data" (HTTP 500). + * + *

    Two variants exercise both factory-install paths: + *

      + *
    • Listing path — numeric predicate keeps the scan on the listing/parquet path.
    • + *
    • Indexed path — {@code match()} routes through the Lucene-indexed executor, which wires + * the scoped page index via a different code path ({@code collect_plan_column_names}).
    • + *
    + * Each query groups by one column while a {@code case()} reads a different, non-projected one; + * {@code executePpl} asserts HTTP 200, so a decompression failure fails the test. + */ + public void testCaseAggregationOnNonProjectedColumnListingPath() throws Exception { + clearAllCaches(); + // Listing path (mirrors api_metrics Q1/Q7): NO predicate, group by city, the case() + // aggregations read `age`/`score` which are not otherwise projected. Projection push-down + // makes the scan project {city, CASE(age...), CASE(score...)} — the CASE fields are not + // file columns, so the buggy name-based read-set inference dropped age/score and gave them + // a placeholder OffsetIndex, corrupting the page read. + String listing = "source=" + INDEX_NAME + + " | stats count() as total, sum(case(age > 50, 1 else 0)) as old," + + " sum(case(score > 75.0, 1 else 0)) as hi by city" + + " | eval old_rate = round(old * 100.0 / total, 2)" + + " | fields city, total, old_rate"; + executePpl(listing); + executePpl(listing); // warm: exercise the cached-OI path too + } + + public void testCaseAggregationOnNonProjectedColumnIndexedPath() throws Exception { + clearAllCaches(); + // Indexed path: match() routes through the Lucene-indexed executor, which wires the scoped + // page index via a different code path than the listing optimizer. Same hazard: the case() + // aggregations read `age`/`score`, which are not in the group-by/projection list. + String indexed = "source=" + INDEX_NAME + + " | where match(city, 'seattle') | stats count() as total," + + " sum(case(age > 50, 1 else 0)) as old, sum(case(score > 75.0, 1 else 0)) as hi by city" + + " | fields city, total, old, hi"; + executePpl(indexed); + executePpl(indexed); // warm + } + + /** + * Delegated keyword-equality predicate produces NO scoped cache activity. + * + *

    This is the converse of {@link #testStringFieldFilterFillsColumnIndex}: with the default + * delegation block-list (EQUALS is NOT blocked), {@code city = 'seattle'} on a keyword field is + * answered by Lucene's inverted index. The parquet file is never scanned for that predicate, so + * neither the ColumnIndex nor the OffsetIndex scoped cache should record any hit, miss, or entry. + * + *

    Guards against a regression where a delegated string predicate still triggers a parquet + * page-index load (which would defeat the point of delegation and waste cache budget). + */ + public void testDelegatedStringPredicateProducesNoOffsetIndexActivity() throws Exception { + // Be explicit that EQUALS is delegated to Lucene (this is also the cluster default). + setLuceneBlockedPredicates(); + clearAllCaches(); + assertCacheEmpty(); + + String ppl = "source=" + INDEX_NAME + " | where city = 'seattle' | stats count()"; + executePpl(ppl); + executePpl(ppl); + + JsonNode s = stats(); + // The predicate went to Lucene; parquet pages were never read. + assertZero("delegated EQUALS: OI hits must be 0", oiHits(s)); + assertZero("delegated EQUALS: OI misses must be 0", oiMisses(s)); + assertZero("delegated EQUALS: OI entries must be 0", oiEntries(s)); + assertZero("delegated EQUALS: CI hits must be 0", ciHits(s)); + assertZero("delegated EQUALS: CI misses must be 0", ciMisses(s)); + assertZero("delegated EQUALS: CI entries must be 0", ciEntries(s)); + } + + // ── tiny cache limits — correctness under eviction pressure ────────────── + + /** + * Set the total metadata-index cache budget to a handful of bytes so every entry is + * guaranteed to be rejected on insert (CI entry size = column_index_length bytes from the + * parquet footer, always ≥ tens of bytes; OI entry size = offset_index_length, likewise). + * When an entry's {@code size > limit} the Rust cache skips the insert — every access is a + * miss, {@code used_bytes} stays 0, {@code entry_count} stays 0. + * + *

    The budget is split across the sub-caches by fixed percentages (CI 13%, OI 34%) with + * integer truncation, and the native limit setters IGNORE a zero limit (treating it as + * "unset" and keeping the default multi-MB budget). So a 1-byte total would truncate both + * shares to 0 and leave the real caches huge. We use 8 bytes instead: CI → 8×13/100 = 1 byte, + * OI → 8×34/100 = 2 bytes — both non-zero (honored) yet far below any real entry, which is + * exactly the "reject everything" regime this test needs. + * + *

    Queries must still return correct results — the cache is a performance + * optimisation, not a correctness dependency. The parquet reader falls back to + * loading page-index bytes fresh from the object store on every query. + */ + public void testQueriesCorrectWithTinyCacheLimits() throws Exception { + // 8b → CI limit 1b, OI limit 2b: tiny but non-zero, so every entry is larger and rejected. + // The cluster-settings PUT is acknowledged only after every node has applied the update and + // run the cache-limit consumer, so the scoped limits are already live when this returns. + setCacheTotalSize("8b"); + try { + clearAllCaches(); + + // Listing path predicate — misses every time, never gets cached. + String listing = "source=" + INDEX_NAME + " | where age > 90 | stats count()"; + executePpl(listing); + JsonNode s1 = stats(); + // size_limit_bytes must reflect the configured tiny per-cache limit (% of 8b), + // small enough that no entry can ever fit. + assertTrue("CI size_limit_bytes must be tiny (<= 8) under 8b budget", + s1.get("column_index_cache").get("size_limit_bytes").asLong() <= 8L); + assertTrue("OI size_limit_bytes must be tiny (<= 8) under 8b budget", + s1.get("offset_index_cache").get("size_limit_bytes").asLong() <= 8L); + // Entries must be 0 — entries are too large to fit, so nothing is stored. + assertEquals("CI entry_count must be 0 (entries too large for 1b limit)", + 0L, ciEntries(s1)); + assertEquals("OI entry_count must be 0 (entries too large for 1b limit)", + 0L, oiEntries(s1)); + // used_bytes must be 0 — nothing was stored. + assertEquals("CI memory_bytes must be 0", 0L, + s1.get("column_index_cache").get("memory_bytes").asLong()); + assertEquals("OI memory_bytes must be 0", 0L, + s1.get("offset_index_cache").get("memory_bytes").asLong()); + // All accesses are misses since nothing is ever cached. + assertPositive("CI misses must be > 0 with 1b limit", ciMisses(s1)); + assertPositive("OI misses must be > 0 with 1b limit", oiMisses(s1)); + + // Second run — still all misses (nothing was cached from first run). + executePpl(listing); + JsonNode s2 = stats(); + assertEquals("CI hits must remain 0 — nothing fits in 1b cache", 0L, ciHits(s2)); + assertEquals("OI hits must remain 0 — nothing fits in 1b cache", 0L, oiHits(s2)); + assertTrue("CI misses must increase on second run", ciMisses(s2) > ciMisses(s1)); + + // Indexed path — must also work correctly with evicted cache. + String indexed = "source=" + INDEX_NAME + " | where match(city, 'seattle') and age > 50 | stats count()"; + executePpl(indexed); + executePpl(indexed); + + // Projection-only — must work correctly. + String proj = "source=" + INDEX_NAME + " | fields name, score | head 10"; + executePpl(proj); + + // Pure Lucene count — must still work (does not touch CI/OI at all). + String lucene = "source=" + INDEX_NAME + " | where match(city, 'seattle') | stats count()"; + executePpl(lucene); + + } finally { + restoreCacheDefaults(); + clearAllCaches(); + } + } + + // ── cache clear ─────────────────────────────────────────────────────────── + + /** Explicit CI clear resets hit counter; next query is a miss again. */ + public void testClearColumnIndexResetsToMiss() throws Exception { + String ppl = "source=" + INDEX_NAME + " | where age > 90 | stats count()"; + + executePpl(ppl); + executePpl(ppl); + long hitsBefore = ciHits(stats()); + assertTrue("must have hits before clear", hitsBefore > 0); + + clearColumnCaches(); + assertEquals("CI hits reset to 0 after clear", 0L, ciHits(stats())); + + executePpl(ppl); + assertTrue("re-run after clear produces misses", ciMisses(stats()) > 0); + } + + // ── stats shape ─────────────────────────────────────────────────────────── + + /** Stats endpoint exposes column_index_cache and offset_index_cache with expected fields. */ + public void testStatsShapeHasBothCacheGroups() throws Exception { + executePpl("source=" + INDEX_NAME + " | where age > 50 | stats count()"); + + JsonNode s = stats(); + for (String group : new String[]{"column_index_cache", "offset_index_cache"}) { + JsonNode block = s.get(group); + assertNotNull(group + " block missing", block); + for (String field : new String[]{"hit_count", "miss_count", "entry_count", "memory_bytes", "size_limit_bytes", "hit_rate"}) { + assertTrue(group + "." + field + " missing", block.has(field)); + } + } + } + + // ── reusable assertion helpers ──────────────────────────────────────────── + + /** + * Run {@code ppl} cold (caches assumed empty by caller) and assert that both CI and OI fill. + * + *

    OI is built for {@code predicate ∪ projection ∪ {col 0}}, so any predicate query + * produces OI misses in addition to CI misses — even if no explicit {@code fields} clause + * is present. + */ + private void assertCacheFillsOnCold(String ppl) throws Exception { + executePpl(ppl); + JsonNode cold = stats(); + assertPositive("cold CI misses for: " + ppl, ciMisses(cold)); + assertPositive("cold OI misses for: " + ppl, oiMisses(cold)); + assertPositive("cold metadata populated for: " + ppl, metaMemoryBytes(cold)); + } + + /** + * Run {@code ppl} a second time (cache already warm) and assert that both CI and OI hit. + * OI is keyed by (file, col) — same file+col on a repeat query is a guaranteed hit. + */ + private void assertCacheHitsOnWarm(String ppl) throws Exception { + executePpl(ppl); + JsonNode warm = stats(); + assertPositive("warm CI hits for: " + ppl, ciHits(warm)); + assertPositive("warm OI hits for: " + ppl, oiHits(warm)); + } + + /** + * Run {@code ppl} twice with scoped disabled (CI/OI = 0 each time). + * Does NOT clear or assert metadata — the caller controls surrounding state. + */ + @SuppressWarnings("unused") + private void assertScopedDisabled(String ppl) throws Exception { + executePpl(ppl); + JsonNode s1 = stats(); + assertZero("disabled CI misses run1: " + ppl, ciMisses(s1)); + assertZero("disabled OI misses run1: " + ppl, oiMisses(s1)); + + executePpl(ppl); + JsonNode s2 = stats(); + assertZero("disabled CI hits run2: " + ppl, ciHits(s2)); + assertZero("disabled OI hits run2: " + ppl, oiHits(s2)); + } + + /** + * Assert that all scoped cache counters and metadata memory are zero (i.e. caches are empty). + * Call this after {@link #clearAllCaches()} to confirm the clear took effect before measuring. + */ + private void assertCacheEmpty() throws Exception { + JsonNode s = stats(); + assertZero("assertCacheEmpty: metadata memory", metaMemoryBytes(s)); + assertZero("assertCacheEmpty: CI entries", ciEntries(s)); + assertZero("assertCacheEmpty: OI entries", oiEntries(s)); + } + + /** + * Run {@code ppl} and assert that ONLY the OffsetIndex fills: OI misses > 0 and CI misses == 0. + * Intended for projection-only queries that carry no predicate. + */ + private void assertOnlyOiFills(String ppl) throws Exception { + executePpl(ppl); + JsonNode s = stats(); + assertPositive("assertOnlyOiFills: OI misses > 0 for: " + ppl, oiMisses(s)); + assertZero("assertOnlyOiFills: CI misses must be 0 for: " + ppl, ciMisses(s)); + } + + // ── index lifecycle helpers ─────────────────────────────────────────────── + + /** + * Delete, recreate, and bulk-index {@value #DOC_COUNT} documents with refresh=true. + * Every test that needs a clean state calls this explicitly. + */ + private void recreateIndex() throws IOException { + try { client().performRequest(new Request("DELETE", "/" + INDEX_NAME)); } catch (Exception ignored) {} + + String body = "{" + + "\"settings\":{" + + " \"number_of_shards\":1,\"number_of_replicas\":0," + + " \"index.pluggable.dataformat.enabled\":true," + + " \"index.pluggable.dataformat\":\"composite\"," + + " \"index.composite.primary_data_format\":\"parquet\"," + + " \"index.composite.secondary_data_formats\":\"lucene\"" + + "}," + + "\"mappings\":{\"properties\":{" + + " \"name\":{\"type\":\"keyword\"}," + + " \"age\":{\"type\":\"integer\"}," + + " \"score\":{\"type\":\"double\"}," + + " \"city\":{\"type\":\"keyword\"}" + + "}}}"; + + Request create = new Request("PUT", "/" + INDEX_NAME); + create.setJsonEntity(body); + assertOkAndParse(client().performRequest(create), "create index"); + + Request health = new Request("GET", "/_cluster/health/" + INDEX_NAME); + health.addParameter("wait_for_status", "green"); + health.addParameter("timeout", "30s"); + client().performRequest(health); + + String[] cities = {"seattle", "portland", "denver", "austin", "boston"}; + int batchSize = 500; + for (int batch = 0; batch < DOC_COUNT / batchSize; batch++) { + boolean lastBatch = batch == (DOC_COUNT / batchSize - 1); + StringBuilder bulk = new StringBuilder(); + for (int i = 0; i < batchSize; i++) { + int id = batch * batchSize + i; + bulk.append("{\"index\":{}}\n") + .append("{\"name\":\"user").append(id) + .append("\",\"age\":").append(id % 100) + .append(",\"score\":").append(50.0 + (id % 50)) + .append(",\"city\":\"").append(cities[id % cities.length]) + .append("\"}\n"); + } + Request bulkReq = new Request("POST", "/" + INDEX_NAME + "/_bulk"); + bulkReq.setJsonEntity(bulk.toString()); + bulkReq.addParameter("refresh", lastBatch ? "true" : "false"); + bulkReq.setOptions(bulkReq.getOptions().toBuilder().addHeader("Content-Type", "application/x-ndjson").build()); + Map r = assertOkAndParse(client().performRequest(bulkReq), "bulk batch " + batch); + assertEquals("bulk batch " + batch + " errors", false, r.get("errors")); + } + } + + /** Set the total metadata-index cache budget (e.g. {@code "100b"}, {@code "500mb"}). */ + private void setCacheTotalSize(String size) throws IOException { + Request req = new Request("PUT", "/_cluster/settings"); + req.setJsonEntity("{\"persistent\":{\"datafusion.metadata_index_cache.total_size\":\"" + size + "\"}}"); + client().performRequest(req); + } + + /** + * Restore the total metadata-index cache budget to the cluster default (null = derive from AC). + * The cluster-settings PUT is acknowledged only after every node has applied the update and run + * the cache-limit consumer, so the large default scoped limits are live again on return. + */ + private void restoreCacheDefaults() throws IOException { + Request req = new Request("PUT", "/_cluster/settings"); + req.setJsonEntity("{\"persistent\":{\"datafusion.metadata_index_cache.total_size\":null}}"); + client().performRequest(req); + } + + /** + * Block the given predicate functions from being delegated to the Lucene backend. + * + *

    String fields (keyword/text) route their predicates to Lucene's inverted index by + * default, so a predicate like {@code city = 'seattle'} is answered by Lucene and never + * scans the parquet file — meaning it never loads the parquet ColumnIndex. Blocking the + * predicate (e.g. {@code EQUALS}) forces the planner to leave it on the DataFusion/parquet + * backend, which is what fills the scoped ColumnIndex cache. Pass an empty list to clear. + */ + private void setLuceneBlockedPredicates(String... predicates) throws IOException { + StringBuilder arr = new StringBuilder("["); + for (int i = 0; i < predicates.length; i++) { + if (i > 0) arr.append(','); + arr.append('"').append(predicates[i]).append('"'); + } + arr.append(']'); + Request req = new Request("PUT", "/_cluster/settings"); + req.setJsonEntity("{\"persistent\":{\"analytics.delegation.lucene.blocked_predicates\":" + arr + "}}"); + client().performRequest(req); + } + + /** Restore the Lucene delegation block-list to the cluster default (null clears the override). */ + private void restoreLuceneBlockedPredicates() throws IOException { + Request req = new Request("PUT", "/_cluster/settings"); + req.setJsonEntity("{\"persistent\":{\"analytics.delegation.lucene.blocked_predicates\":null}}"); + client().performRequest(req); + } + + private void setScopedPageIndexEnabled(boolean enabled) throws IOException { + Request req = new Request("PUT", "/_cluster/settings"); + req.setJsonEntity("{\"persistent\":{\"datafusion.scoped_page_index.enabled\":" + enabled + "}}"); + client().performRequest(req); + } + + private void clearAllCaches() throws IOException { + client().performRequest(new Request("POST", CLEAR_ENDPOINT)); + } + + private void clearColumnCaches() throws IOException { + client().performRequest(new Request("POST", CLEAR_ENDPOINT + "?column=true")); + } + + /** + * Fetches DataFusion cache stats aggregated across all nodes in the cluster. + * + *

    The test cluster runs with multiple nodes but the test index has a single shard, + * so the parquet scan that fills the scoped CI/OI caches (process-global per JVM) and + * the per-runtime metadata cache only happens on the node that hosts that shard. The + * caches therefore live on one specific node, while the round-robin REST client may + * route a {@code _local} stats request to any node. Reading a single node's stats would + * intermittently observe an empty cache simply because the request landed on a node that + * never ran the scan. + * + *

    To make the measurements deterministic we query the cluster-wide stats endpoint + * (all nodes) and sum each cache counter across every node. Cluster-wide sums are exactly + * what the assertions want: cache activity is non-zero iff some node did the work, + * and {@code clearAllCaches()} broadcasts to every node so cleared sums are zero everywhere. + */ + private JsonNode stats() throws Exception { + Response response = client().performRequest( + new Request("GET", "/_plugins/_analytics_backend_datafusion/stats") + ); + JsonNode root = MAPPER.readTree(EntityUtils.toString(response.getEntity())); + JsonNode nodes = root.get("nodes"); + assertNotNull("nodes block missing", nodes); + + // Combine each cache group's fields across all nodes into a synthetic cache_stats + // object with the same shape the accessor helpers expect. Counters (hit_count, + // miss_count, entry_count, memory_bytes) accumulate across the cluster, so they are + // SUMMED. size_limit_bytes is a per-node capacity (every node configures the same + // budget), so it is taken as the MAX rather than summed — otherwise an N-node cluster + // would report N× the configured limit. + com.fasterxml.jackson.databind.node.ObjectNode aggregate = MAPPER.createObjectNode(); + for (JsonNode node : nodes) { + JsonNode cacheStats = node.get("cache_stats"); + if (cacheStats == null) { + continue; + } + cacheStats.fieldNames().forEachRemaining(group -> { + JsonNode groupNode = cacheStats.get(group); + if (groupNode == null || !groupNode.isObject()) { + return; + } + com.fasterxml.jackson.databind.node.ObjectNode aggGroup = aggregate.has(group) + ? (com.fasterxml.jackson.databind.node.ObjectNode) aggregate.get(group) + : aggregate.putObject(group); + groupNode.fieldNames().forEachRemaining(field -> { + JsonNode value = groupNode.get(field); + if (value != null && value.isNumber()) { + long prev = aggGroup.path(field).asLong(0L); + long combined = "size_limit_bytes".equals(field) + ? Math.max(prev, value.asLong()) + : prev + value.asLong(); + aggGroup.put(field, combined); + } + }); + }); + } + assertTrue("cache_stats block missing on every node", aggregate.size() > 0); + return aggregate; + } + + // ── low-level assertion utilities ───────────────────────────────────────── + + private void assertZero(String msg, long v) { assertEquals(msg + " must be 0, got " + v, 0L, v); } + private void assertPositive(String msg, long v) { assertTrue(msg + " must be > 0, got " + v, v > 0); } + + /** + * Assert exact entry count with an informative message. + * + *

    CI key = (file, col, rg) so expectedCi = files × predicateCols × rowGroupsPerFile. + * OI key = (file, col) so expectedOi = files × projectionCols. + * + *

    With DOC_COUNT=2000 on a single shard and a single refresh the data lands in one + * parquet segment with one row group (2000 rows well under the default 1M-row row-group limit), so: + *

      + *
    • 1 predicate col → CI entries = 1
    • + *
    • N projection cols → OI entries = N
    • + *
    + * For multi-segment scenarios (two separate refreshes before merge) multiply by the + * number of segments. + */ + private void assertExactCiEntries(String msg, long expected) throws Exception { + assertEquals(msg, expected, ciEntries(stats())); + } + + private void assertExactOiEntries(String msg, long expected) throws Exception { + assertEquals(msg, expected, oiEntries(stats())); + } + + // ── stat accessor shorthands ────────────────────────────────────────────── + + private long ciHits(JsonNode s) { return s.get("column_index_cache").get("hit_count").asLong(); } + private long ciMisses(JsonNode s) { return s.get("column_index_cache").get("miss_count").asLong(); } + private long ciEntries(JsonNode s) { return s.get("column_index_cache").get("entry_count").asLong(); } + private long oiHits(JsonNode s) { return s.get("offset_index_cache").get("hit_count").asLong(); } + private long oiMisses(JsonNode s) { return s.get("offset_index_cache").get("miss_count").asLong(); } + private long oiEntries(JsonNode s) { return s.get("offset_index_cache").get("entry_count").asLong(); } + private long metaMemoryBytes(JsonNode s) { return s.get("metadata_cache").get("memory_bytes").asLong(); } +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/SearchStatsContributorIT.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/SearchStatsContributorIT.java index a562432a734e6..acb701a78dce0 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/SearchStatsContributorIT.java +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/SearchStatsContributorIT.java @@ -25,42 +25,91 @@ public class SearchStatsContributorIT extends AnalyticsRestTestCase { private static final Dataset DATASET = new Dataset("calcs", "calcs"); + /** + * Multi-shard index so the test can distinguish the per-query contributor + * (queryCount delta ≈ N) from the per-shard Lucene-equivalent contributor + * (queryCount delta ≈ N × shards). Catches the regression class where the + * contributor counts 1-per-query and silently under-reports search rate + * by the shard fan-out factor to downstream consumers of node stats. + */ + private static final int NUMBER_OF_SHARDS = 3; private static boolean dataProvisioned = false; private void ensureDataProvisioned() throws IOException { if (dataProvisioned == false) { - DatasetProvisioner.provision(client(), DATASET); + DatasetProvisioner.provision(client(), DATASET, NUMBER_OF_SHARDS); dataProvisioned = true; } } - public void testQueryTotalIncrementsAfterAnalyticsQueries() throws IOException { + /** + * Guards the 1-per-query bug: cluster-wide query_total must grow with the + * shard fan-out factor. A 1-per-query contributor would produce delta ≈ N + * (instead of N × shards), under-reporting search rate by the shard fan-out + * factor to downstream consumers of node stats. + */ + public void testQueryTotalScalesWithShardFanOut() throws IOException { ensureDataProvisioned(); - long baselineQueryTotal = getQueryTotalAcrossNodes(); - long baselineQueryTimeMs = getQueryTimeAcrossNodes(); + long before = getQueryTotalAcrossNodes(); + fireQueries(); + long after = getQueryTotalAcrossNodes(); - // Fire a handful of analytics-engine PPL queries — varied shapes so the contributor - // sees both real elapsed time and a non-trivial count. + long delta = after - before; + long minExpected = (long) TOTAL_QUERIES * NUMBER_OF_SHARDS / 2; + assertTrue( + "search.query_total must grow with shard fan-out (fired " + TOTAL_QUERIES + + " on " + NUMBER_OF_SHARDS + "-shard index, expected ≥ " + minExpected + + ", observed delta=" + delta + ")", + delta >= minExpected + ); + } + + /** + * Guards the latency-aggregation bug: query_time_in_millis must move when + * real PPL work runs. A delta of 0 means the contributor's time source + * dropped every recording (e.g. an all-or-nothing window that collapsed). + */ + public void testQueryTimeIncrementsAfterAnalyticsQueries() throws IOException { + ensureDataProvisioned(); + + long before = getQueryTimeAcrossNodes(); + fireQueries(); + long after = getQueryTimeAcrossNodes(); + + long delta = after - before; + assertTrue("search.query_time_in_millis must grow > 0 after PPL work, observed delta=" + delta, delta > 0); + } + + /** + * Minimum-viable check: every fired query must contribute at least once to + * cluster-wide query_total. Catches regressions where the contributor stops + * firing entirely (e.g. returns null, or filters out all stages). + */ + public void testQueryTotalIncrementsAtLeastOncePerQuery() throws IOException { + ensureDataProvisioned(); + + long before = getQueryTotalAcrossNodes(); + fireQueries(); + long after = getQueryTotalAcrossNodes(); + + long delta = after - before; + assertTrue( + "search.query_total must grow by at least one per query (fired " + TOTAL_QUERIES + + ", observed delta=" + delta + ")", + delta >= TOTAL_QUERIES + ); + } + + private static final int TOTAL_QUERIES = 15; + + private void fireQueries() throws IOException { + // Varied shapes so the contributor sees both real elapsed time and a non-trivial count. for (int i = 0; i < 5; i++) { executePpl("source=" + DATASET.indexName + " | fields str0"); executePpl("source=" + DATASET.indexName + " | where num0 > 0 | fields str0, num0"); executePpl("source=" + DATASET.indexName + " | stats avg(num0)"); } - - long afterQueryTotal = getQueryTotalAcrossNodes(); - long afterQueryTimeMs = getQueryTimeAcrossNodes(); - - long countDelta = afterQueryTotal - baselineQueryTotal; - assertTrue( - "search.query_total must increment by at least 1 after analytics queries, delta=" + countDelta, - countDelta >= 1 - ); - // query_time_in_millis is a sum so it should grow strictly monotonically with count; - // any single query that took 0ms is unlikely with non-trivial work, but the contract - // we care about is "the field is being populated", so >= 0 is enough. - long timeDelta = afterQueryTimeMs - baselineQueryTimeMs; - assertTrue("search.query_time_in_millis must not regress, delta=" + timeDelta, timeDelta >= 0); } private long getQueryTotalAcrossNodes() throws IOException { diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/StreamingCoordinatorReduceIT.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/StreamingCoordinatorReduceIT.java index d3314d526a80b..67027488abb06 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/StreamingCoordinatorReduceIT.java +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/StreamingCoordinatorReduceIT.java @@ -220,7 +220,7 @@ private void indexValuedDocs(IntUnaryOperator valueFn) throws Exception { int total = NUM_SHARDS * DOCS_PER_SHARD; StringBuilder bulk = new StringBuilder(); for (int i = 0; i < total; i++) { - bulk.append("{\"index\": {\"_id\": \"").append(i).append("\"}}\n"); + bulk.append("{\"index\": {}}\n"); bulk.append("{\"value\": ").append(valueFn.applyAsInt(i)).append("}\n"); } @@ -285,7 +285,7 @@ private void indexDeterministicDocs() throws Exception { int total = NUM_SHARDS * DOCS_PER_SHARD; StringBuilder bulk = new StringBuilder(); for (int i = 0; i < total; i++) { - bulk.append("{\"index\": {\"_id\": \"").append(i).append("\"}}\n"); + bulk.append("{\"index\": {}}\n"); bulk.append("{\"value\": ").append(VALUE).append("}\n"); } diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/TextRelevanceValidationIT.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/TextRelevanceValidationIT.java new file mode 100644 index 0000000000000..f0ebbf5bed003 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/TextRelevanceValidationIT.java @@ -0,0 +1,272 @@ +/* + * 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.analytics.qa; + +import org.opensearch.client.ResponseException; + +import java.io.IOException; +import java.util.Map; + +/** + * Integration test for text-relevance field-type validation on the analytics-engine route. + * + *

    Multi-field text-relevance functions ({@code query_string}, {@code simple_query_string}, + * {@code multi_match}) may only be applied to {@code text}/{@code keyword} fields. The analytics + * planner rejects them on numeric/date fields at planning time with an actionable error rather + * than failing later in the pipeline (see {@code TextRelevanceFieldValidator}). These tests send + * real PPL queries through {@code POST /_plugins/_ppl} against the {@code otel_logs} dataset to + * verify the end-to-end behavior. + * + *

    Provisions the {@code otel_logs} dataset once per class via {@link DatasetProvisioner}; + * {@link AnalyticsRestTestCase#preserveIndicesUponCompletion()} keeps it across test methods. + */ +public class TextRelevanceValidationIT extends AnalyticsRestTestCase { + + private static final Dataset DATASET = OtelLogsTestHelper.DATASET; + + private static boolean dataProvisioned = false; + + /** + * Lazily provision the otel_logs dataset on first invocation. Same lazy-provision pattern as + * {@link WhereCommandIT} — {@code client()} is only reliably available inside a test body. + */ + @Override + protected void onBeforeQuery() throws IOException { + if (dataProvisioned == false) { + DatasetProvisioner.provision(client(), DATASET); + dataProvisioned = true; + } + } + + /** + * {@code query_string(['severityNumber'], ...)} on a {@code long} field is rejected at + * planning. The error body names the offending field and the supported types. + */ + public void testQueryStringOnNumericFieldIsRejected() { + String ppl = "source=" + DATASET.indexName + " | where query_string(['severityNumber'], 'severityNumber:>15')"; + assertErrorContains(ppl, "severityNumber"); + assertErrorContains(ppl, "text and keyword"); + } + + /** + * {@code query_string(['body'], 'GET')} on a {@code text} field plans and executes + * successfully — text fields are valid for text-relevance functions. + */ + public void testQueryStringOnTextFieldSucceeds() throws IOException { + assertSucceeds("source=" + DATASET.indexName + " | where query_string(['body'], 'GET')"); + } + + /** + * Same numeric-field rejection as {@link #testQueryStringOnNumericFieldIsRejected}, but driven + * through PPL's explicit {@code search} command form ({@code search source=... | where ...}). + * Confirms the field-type validation fires regardless of which command introduces the relation. + */ + public void testSearchCommandQueryStringOnNumericFieldIsRejected() { + String ppl = "search source=" + DATASET.indexName + " | where query_string(['severityNumber'], 'severityNumber:>15')"; + assertErrorContains(ppl, "severityNumber"); + assertErrorContains(ppl, "text and keyword"); + } + + /** + * {@code search source=... | where query_string(['body'], 'GET')} on a {@code text} field + * plans and executes successfully via the {@code search} command. + */ + public void testSearchCommandQueryStringOnTextFieldSucceeds() throws IOException { + assertSucceeds("search source=" + DATASET.indexName + " | where query_string(['body'], 'GET')"); + } + + /** + * A mixed explicit field list (text + numeric) is rejected because of the numeric field — every + * explicitly-named literal field must be text/keyword. The error names the offending field. + */ + public void testQueryStringMixedFieldsRejectsBecauseOfNumericField() { + String ppl = "source=" + DATASET.indexName + " | where query_string(['body', 'severityNumber'], 'GET')"; + assertErrorContains(ppl, "severityNumber"); + assertErrorContains(ppl, "text and keyword"); + } + + /** + * A wildcard field pattern ({@code body*}) is a pattern token, not a literal: the planner passes + * it through unvalidated and the data node expands it at execution. So it is NOT rejected and the + * query runs. {@code body*} is chosen deliberately because it expands only to text-family fields + * ({@code body}, {@code body.keyword}); a pattern matching a numeric field could throw a runtime + * number-format error unrelated to this plan-time validation. The property under test is that a + * pattern token never triggers the text-relevance rejection. + */ + public void testQueryStringWildcardFieldPatternIsNotRejected() throws IOException { + assertSucceeds("source=" + DATASET.indexName + " | where query_string(['body*'], 'GET')"); + } + + /** + * With {@code lenient=true} the planner suppresses the eager text/keyword rejection, but no + * backend declares {@code query_string} capability over a numeric field, so backend selection + * still finds no viable backend and planning fails — now with the generic routing error + * ("No backend can evaluate") rather than the friendly "text and keyword" message. This confirms + * {@code lenient} changes the failure mode, not the outcome, for an explicitly-named non-text + * field: it cannot conjure a backend that can serve the field, so the query still cannot run. + */ + public void testQueryStringLenientTrueStillCannotRouteNumericField() { + String ppl = "source=" + DATASET.indexName + " | where query_string(['severityNumber'], '15', lenient=true)"; + assertErrorContains(ppl, "No backend can evaluate"); + assertErrorContains(ppl, "severityNumber"); + } + + /** + * {@code simple_query_string(['severityNumber'], ...)} on a {@code long} field is rejected — + * exercises the SIMPLE_QUERY_STRING field-reference extractor path. + */ + public void testSimpleQueryStringOnNumericFieldIsRejected() { + String ppl = "source=" + DATASET.indexName + " | where simple_query_string(['severityNumber'], 'error')"; + assertErrorContains(ppl, "severityNumber"); + assertErrorContains(ppl, "text and keyword"); + } + + /** + * {@code multi_match(['body','severityNumber'], ...)} with a numeric field is rejected — + * exercises the MULTI_MATCH field-reference extractor path. + */ + public void testMultiMatchOnNumericFieldIsRejected() { + String ppl = "source=" + DATASET.indexName + " | where multi_match(['body', 'severityNumber'], 'error')"; + assertErrorContains(ppl, "severityNumber"); + assertErrorContains(ppl, "text and keyword"); + } + + /** + * In-string literal field reference: the {@code fields} list is a valid text field ({@code body}), + * but the query string names a numeric field ({@code severityNumber:15}). The extractor parses the + * query string and surfaces {@code severityNumber} as an explicit literal, so the planner rejects it. + * This isolates the in-string extraction path — {@code body} alone would not be rejected, so the + * rejection must come from the field named inside the query string. + */ + public void testQueryStringInStringNumericFieldIsRejected() { + String ppl = "source=" + DATASET.indexName + " | where query_string(['body'], 'severityNumber:15')"; + assertErrorContains(ppl, "severityNumber"); + assertErrorContains(ppl, "text and keyword"); + } + + /** + * In-string literal field with a regex value ({@code severityNumber:/1[0-9]/}). The regex applies to + * the value, not the field name — {@code severityNumber} is still a concrete literal field. So the + * planner must extract and reject it just like a non-regex in-string reference. Verifies that a regex + * value does not change literal-field classification. + */ + public void testQueryStringInStringRegexOnNumericFieldIsRejected() { + String ppl = "source=" + DATASET.indexName + " | where query_string(['body'], 'severityNumber:/1[0-9]/')"; + assertErrorContains(ppl, "severityNumber"); + assertErrorContains(ppl, "text and keyword"); + } + + /** + * Field-less literal non-text rejection (no explicit {@code fields} operand). PPL's {@code search} + * command lowers {@code severityNumber=15} to {@code query_string(MAP('query', 'severityNumber:15'))} + * — there is no {@code fields} list, so {@code severityNumber} is named only inside the query string. + * The extractor parses the query string, surfaces {@code severityNumber} as a concrete literal field, + * and the planner rejects it because it is a {@code long}. This is the field-less analogue of + * {@link #testQueryStringInStringNumericFieldIsRejected} and pins the rejection behind the + * {@code search}-command lowering path (the same path exercised by extensive-coverage Q92). + */ + public void testSearchCommandFieldlessLiteralNumericFieldIsRejected() { + String ppl = "search source=" + DATASET.indexName + " severityNumber=15"; + assertErrorContains(ppl, "severityNumber"); + assertErrorContains(ppl, "text and keyword"); + } + + /** + * In-string wildcard field qualifier, written in the only syntactically valid form: the + * {@code *} is escaped ({@code serviceName\*:test}) so Lucene's classic grammar lexes the whole token + * as a single field name rather than a prefix term. (An unescaped {@code serviceName*:test} is not a + * field qualifier at all — the grammar only accepts a plain {@code TERM} or bare {@code *} before the + * {@code :} — so it fails to parse; see {@code QueryParser.Clause}.) The escaped field token still + * contains {@code *}, so {@link org.opensearch.common.regex.Regex#isSimpleMatchPattern} classifies it + * as a pattern, not a literal: the planner passes it through unvalidated (no text/keyword rejection — + * the property under test) and the data node expands it via {@code extractMultiFields} at execution. + * {@code serviceName*} expands only to text-family fields, so the query plans and executes successfully. + */ + public void testQueryStringInStringWildcardFieldIsNotRejected() throws IOException { + assertSucceeds("source=" + DATASET.indexName + " | where query_string(['body'], 'serviceName\\*:test')"); + } + + /** + * Escaped in-string wildcard field that also matches a numeric field ({@code severity\*} → {@code severityText} + * text + {@code severityNumber} long). Because the escaped field token contains {@code *} it is classified as + * a pattern, so it is passed through and never rejected — even though it spans a non-text field — and the + * query plans and executes successfully (the value {@code 15} is valid for both the text and numeric + * expansions). As above, the {@code *} must be escaped to be a valid field qualifier. + */ + public void testQueryStringInStringWildcardFieldSpanningNumericIsNotRejected() throws IOException { + assertSucceeds("source=" + DATASET.indexName + " | where query_string(['body'], 'severity\\*:15')"); + } + + /** + * In-string literal field with an integer fuzzy value ({@code severityNumber:15~2}). The + * {@code ~2} edit-distance applies to the value, not the field name — {@code severityNumber} is still a + * concrete literal field — and {@code ~2} (integer edit distance) is grammar the plan-time classic parser + * accepts, so the query string parses, {@code severityNumber} is surfaced as a literal, and the planner + * rejects it because it is a {@code long}. This is the fuzzy-value analogue of + * {@link #testQueryStringInStringRegexOnNumericFieldIsRejected}: a fuzzy operator on the value does not + * change literal-field classification. + */ + public void testQueryStringInStringFuzzyOnNumericFieldIsRejected() { + String ppl = "source=" + DATASET.indexName + " | where query_string(['body'], 'severityNumber:15~2')"; + assertErrorContains(ppl, "severityNumber"); + assertErrorContains(ppl, "text and keyword"); + } + + /** + * Graceful fallback for a query string the plan-time parser cannot parse. {@code GET~2.5} uses a + * fractional fuzzy similarity, which Lucene's classic {@link org.apache.lucene.queryparser.classic.QueryParser} + * no longer accepts, so {@code RelevanceFieldExtractor} fails to parse the whole string, logs a warning, and + * skips in-string field extraction (rather than failing the plan). Validation then relies solely on the + * explicit {@code fields} operand ({@code body}, a text field), which is valid — so the query is NOT rejected + * at planning and runs to completion (the authoritative parse happens at execution). This pins the + * best-effort fallback contract: an unparseable in-string body must not turn into a plan-time rejection. + */ + public void testQueryStringUnparseableInStringFallsBackToExplicitField() throws IOException { + assertSucceeds( + "source=" + DATASET.indexName + " | where query_string(['body'], 'severityNumber:15 OR GET~2.5') | stats count() as c" + ); + } + + /** + * Sends a PPL query expected to plan and execute successfully; asserts a non-null response that + * carries a result shape ({@code datarows} or {@code schema}). + */ + private void assertSucceeds(String ppl) throws IOException { + Map response = executePpl(ppl); + assertNotNull("Expected a non-null response for [" + ppl + "]", response); + assertTrue( + "Expected response to carry a result shape (datarows or schema): " + response, + response.containsKey("datarows") || response.containsKey("schema") + ); + } + + /** + * Send a PPL query expecting the planner to reject it; assert the error body contains + * {@code expectedSubstring}. + */ + private void assertErrorContains(String ppl, String expectedSubstring) { + try { + Map response = executePpl(ppl); + fail("Expected query to fail with [" + expectedSubstring + "] but got response: " + response); + } catch (ResponseException e) { + String body; + try { + body = org.apache.hc.core5.http.io.entity.EntityUtils.toString(e.getResponse().getEntity()); + } catch (Exception ioe) { + body = e.getMessage(); + } + assertTrue( + "Expected response body to contain [" + expectedSubstring + "] but was: " + body, + body.contains(expectedSubstring) + ); + } catch (IOException e) { + fail("Unexpected IOException: " + e); + } + } +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/TopKCssCorrectnessIT.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/TopKCssCorrectnessIT.java new file mode 100644 index 0000000000000..5f3936a684eac --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/TopKCssCorrectnessIT.java @@ -0,0 +1,299 @@ +/* + * 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 source license. + */ + +package org.opensearch.analytics.qa; + +import org.opensearch.client.Request; +import org.opensearch.client.Response; + +import java.util.List; +import java.util.Map; + +/** + * Regression tests for TopK correctness when concurrent segment search (CSS) is active. + * + *

    Before the PartialReduce fix, CSS caused each intra-shard partition to independently + * truncate to the TopK fetch limit before the coordinator merge, producing wrong counts. + * Each test runs the same query with CSS off (reference) and CSS on (subject) and asserts + * the results are identical. + * + *

    Covers 13 aggregate shapes identified by Aniketh Jain across count, sum, avg, min/max, + * distinct_count, stddev/variance, percentile, offset, scalar agg, and permutation variants. + */ +@SuppressWarnings("unchecked") +public class TopKCssCorrectnessIT extends AnalyticsRestTestCase { + + private static volatile boolean provisioned = false; + private static final String INDEX = "parquet_hits"; + + private void ensureProvisioned() throws Exception { + if (!provisioned) { + // MULTI_SEGMENT (2 segments/shard) + low oversampling makes the CSS truncation + // bug reproducible on the local test cluster — each CSS partition independently + // truncates to a very small fetch limit, producing wrong results without the fix. + DatasetProvisioner.provision(client(), ClickBenchTestHelper.DATASET, 2, DatasetProvisioner.SegmentLayout.MULTI_SEGMENT); + Request req = new Request("PUT", "/_cluster/settings"); + req.setJsonEntity( + "{\"persistent\":{\"analytics.shard_bucket_oversampling_factor\": 0.1}}" + ); + client().performRequest(req); + provisioned = true; + } + } + + // ── case-01: multi-key, count/sum/avg, != filter ────────────────────────── + + public void testCase01_multiKeyCountSumAvg_cssMatchesNoCss() throws Exception { + ensureProvisioned(); + assertCssMatchesNoCss( + "source = " + INDEX + + " | where SearchPhrase != ''" + + " | stats count() as c, sum(IsRefresh), avg(ResolutionWidth)" + + " by SearchEngineID, ClientIP" + + " | sort - c, SearchEngineID, ClientIP | head 10" + ); + } + + // ── case-02: single-key count ──────────────────────────────────────────── + + public void testCase02_singleKeyCount_cssMatchesNoCss() throws Exception { + ensureProvisioned(); + assertCssMatchesNoCss( + "source = " + INDEX + + " | stats count() as c by SearchEngineID" + + " | sort - c, SearchEngineID | head 2" + ); + } + + // ── case-03: distinct_count (HLL) ──────────────────────────────────────── + + public void testCase03_distinctCount_cssMatchesNoCss() throws Exception { + ensureProvisioned(); + assertCssMatchesNoCss( + "source = " + INDEX + + " | stats distinct_count(ClientIP) as dc by SearchEngineID" + + " | sort - dc, SearchEngineID | head 2" + ); + } + + // ── case-04: stddev / variance ─────────────────────────────────────────── + + public void testCase04_stddevVariance_cssMatchesNoCss() throws Exception { + ensureProvisioned(); + assertCssMatchesNoCss( + "source = " + INDEX + + " | stats stddev_samp(ResolutionWidth) as sd," + + " var_samp(ResolutionWidth) as vs," + + " var_pop(ResolutionWidth) as vp" + + " by SearchEngineID | sort SearchEngineID | head 10" + ); + } + + // ── case-05: scalar aggregate (no group-by, no TopK) ───────────────────── + + public void testCase05_scalarSums_cssMatchesNoCss() throws Exception { + ensureProvisioned(); + assertCssMatchesNoCss( + "source = " + INDEX + + " | stats sum(ResolutionWidth)," + + " sum(ResolutionWidth+1)," + + " sum(ResolutionWidth+2)," + + " count()" + ); + } + + // ── case-06: offset + limit ─────────────────────────────────────────────── + + public void testCase06_offsetLimit_cssMatchesNoCss() throws Exception { + ensureProvisioned(); + assertCssMatchesNoCss( + "source = " + INDEX + + " | stats count() as c by SearchEngineID" + + " | sort - c, SearchEngineID | head 2 from 1" + ); + } + + // ── case-07: min / max ──────────────────────────────────────────────────── + + public void testCase07_minMax_cssMatchesNoCss() throws Exception { + ensureProvisioned(); + assertCssMatchesNoCss( + "source = " + INDEX + + " | stats min(ResolutionWidth) as mn," + + " max(ResolutionWidth) as mx," + + " count() as c by SearchEngineID" + + " | sort - c, SearchEngineID | head 2" + ); + } + + // ── case-08: avg + sum ──────────────────────────────────────────────────── + + public void testCase08_avgSum_cssMatchesNoCss() throws Exception { + ensureProvisioned(); + // Sort by SearchEngineID (deterministic key, not count) to avoid tie-breaking flakiness. + assertCssMatchesNoCss( + "source = " + INDEX + + " | stats avg(ResolutionWidth) as a," + + " sum(ResolutionWidth) as s," + + " count() as c by SearchEngineID" + + " | sort SearchEngineID | head 5" + ); + } + + // ── case-09a: agg permutation (count, sum, avg, min, max) ──────────────── + + public void testCase09a_permutation1_cssMatchesNoCss() throws Exception { + ensureProvisioned(); + assertCssMatchesNoCss( + "source = " + INDEX + + " | stats count() as c," + + " sum(IsRefresh) as si," + + " avg(ResolutionWidth) as a," + + " min(ResolutionWidth) as mn," + + " max(ResolutionWidth) as mx by SearchEngineID" + + " | sort - c, SearchEngineID | head 2" + ); + } + + // ── case-09b: agg permutation (max, avg, count, min, sum) ──────────────── + + public void testCase09b_permutation2_cssMatchesNoCss() throws Exception { + ensureProvisioned(); + assertCssMatchesNoCss( + "source = " + INDEX + + " | stats max(ResolutionWidth) as mx," + + " avg(ResolutionWidth) as a," + + " count() as c," + + " min(ResolutionWidth) as mn," + + " sum(IsRefresh) as si by SearchEngineID" + + " | sort - c, SearchEngineID | head 2" + ); + } + + // ── case-09c: agg permutation (avg, min, sum, max, count) ──────────────── + + public void testCase09c_permutation3_cssMatchesNoCss() throws Exception { + ensureProvisioned(); + assertCssMatchesNoCss( + "source = " + INDEX + + " | stats avg(ResolutionWidth) as a," + + " min(ResolutionWidth) as mn," + + " sum(IsRefresh) as si," + + " max(ResolutionWidth) as mx," + + " count() as c by SearchEngineID" + + " | sort - c, SearchEngineID | head 2" + ); + } + + // ── case-10: no aliases ─────────────────────────────────────────────────── + + public void testCase10_noAliases_cssMatchesNoCss() throws Exception { + ensureProvisioned(); + assertCssMatchesNoCss( + "source = " + INDEX + + " | stats count(), sum(ResolutionWidth)," + + " avg(ResolutionWidth)," + + " min(ResolutionWidth)," + + " max(ResolutionWidth) by SearchEngineID" + + " | sort SearchEngineID | head 5" + ); + } + + // ── case-11: many aggs on same column ──────────────────────────────────── + + public void testCase11_manyAggsOnSameColumn_cssMatchesNoCss() throws Exception { + ensureProvisioned(); + assertCssMatchesNoCss( + "source = " + INDEX + + " | stats sum(ResolutionWidth)," + + " avg(ResolutionWidth)," + + " min(ResolutionWidth)," + + " max(ResolutionWidth)," + + " count(ResolutionWidth) by SearchEngineID" + + " | sort SearchEngineID | head 5" + ); + } + + // ── case-12: percentile ─────────────────────────────────────────────────── + + public void testCase12_percentile_cssMatchesNoCss() throws Exception { + ensureProvisioned(); + assertCssMatchesNoCss( + "source = " + INDEX + + " | stats percentile(ResolutionWidth, 50) as p50," + + " percentile(ResolutionWidth, 95) as p95 by SearchEngineID" + + " | sort SearchEngineID | head 5" + ); + } + + // ── case-13: mixed split + non-split (count/sum + percentile) ──────────── + + public void testCase13_mixedSplitAndNonSplit_cssMatchesNoCss() throws Exception { + ensureProvisioned(); + assertCssMatchesNoCss( + "source = " + INDEX + + " | stats count() as c," + + " sum(ResolutionWidth) as s," + + " percentile(ResolutionWidth, 50) as p50 by SearchEngineID" + + " | sort - c, SearchEngineID | head 2" + ); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + /** + * Runs {@code ppl} with CSS off, then with CSS on (4 slices), and asserts the + * result rows are identical. Restores CSS-off after the check. + */ + private void assertCssMatchesNoCss(String ppl) throws Exception { + setCss("none", 0); + List> reference = rowsOf(executePPL(ppl)); + + setCss("all", 4); + List> withCss = rowsOf(executePPL(ppl)); + + assertEquals( + "CSS result differs from no-CSS reference for query: " + ppl, + reference, + withCss + ); + + setCss("none", 0); + } + + private void setCss(String mode, int sliceCount) throws Exception { + Request req = new Request("PUT", "/_cluster/settings"); + if (sliceCount > 0) { + req.setJsonEntity( + "{\"transient\":{\"search.concurrent_segment_search.mode\":\"" + + mode + + "\",\"search.concurrent.max_slice_count\":" + + sliceCount + + "}}" + ); + } else { + req.setJsonEntity( + "{\"transient\":{\"search.concurrent_segment_search.mode\":\"" + mode + "\"}}" + ); + } + client().performRequest(req); + } + + private Map executePPL(String ppl) throws Exception { + Request request = new Request("POST", "/_analytics/ppl"); + request.setJsonEntity("{\"query\": \"" + ppl + "\"}"); + Response response = client().performRequest(request); + return entityAsMap(response); + } + + private List> rowsOf(Map result) { + List rows = (List) result.get("rows"); + assertNotNull("response must have rows, got: " + result.keySet(), rows); + return (List>) rows; + } +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/UnsupportedFunctionIT.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/UnsupportedFunctionIT.java new file mode 100644 index 0000000000000..35e8cbf532eaf --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/UnsupportedFunctionIT.java @@ -0,0 +1,69 @@ +/* + * 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.analytics.qa; + +import org.opensearch.client.Request; +import org.opensearch.client.ResponseException; +import org.opensearch.test.rest.OpenSearchRestTestCase; + +import java.io.IOException; + +/** + * Integration tests verifying that unsupported functions return HTTP 400 + * with a user-friendly error message. + */ +public class UnsupportedFunctionIT extends OpenSearchRestTestCase { + + private static boolean provisioned = false; + + @Override + protected boolean preserveIndicesUponCompletion() { + return true; + } + + private void ensureProvisioned() throws IOException { + if (provisioned == false) { + DatasetProvisioner.provision(client(), new Dataset("calcs", "calcs")); + provisioned = true; + } + } + + public void testUnsupportedScalarFunctionReturns400() throws Exception { + ensureProvisioned(); + + ResponseException e = expectThrows(ResponseException.class, () -> { + Request r = new Request("POST", "/_analytics/ppl"); + r.setJsonEntity("{\"query\": \"source=calcs | eval x = MATCH(str0, 'hello')\"}"); + client().performRequest(r); + }); + + assertEquals(400, e.getResponse().getStatusLine().getStatusCode()); + String body = new String(e.getResponse().getEntity().getContent().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + assertTrue("error type should be unsupported_function_exception: " + body, + body.contains("unsupported_function_exception")); + assertTrue("error should mention function name MATCH: " + body, + body.contains("Function [MATCH] is not currently supported")); + } + + public void testUnsupportedScalarDoesNotLeakBackendNames() throws Exception { + ensureProvisioned(); + + ResponseException e = expectThrows(ResponseException.class, () -> { + Request r = new Request("POST", "/_analytics/ppl"); + r.setJsonEntity("{\"query\": \"source=calcs | eval x = MATCH(str0, 'hello')\"}"); + client().performRequest(r); + }); + + String body = new String(e.getResponse().getEntity().getContent().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + assertFalse("error should not mention internal backend 'datafusion': " + body, + body.contains("datafusion")); + assertFalse("error should not mention internal backend 'lucene': " + body, + body.contains("lucene")); + } +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/planshape/ClickBenchPlanShapeIT.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/planshape/ClickBenchPlanShapeIT.java new file mode 100644 index 0000000000000..1855314084b42 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/planshape/ClickBenchPlanShapeIT.java @@ -0,0 +1,48 @@ +/* + * 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.analytics.qa.planshape; + +import com.carrotsearch.randomizedtesting.annotations.Name; +import com.carrotsearch.randomizedtesting.annotations.ParametersFactory; + +import org.opensearch.analytics.qa.ClickBenchTestHelper; + +import java.util.List; +import java.util.stream.IntStream; + +/** + * Plan-shape golden IT for ClickBench. Each (query, combo) is reported as its own JUnit case named + * {@code clickbench/q{N}[combo]}; target one with {@code --tests "*ClickBenchPlanShapeIT*q8*"}. + * + *

    All harness logic is in {@link PlanShapeGoldenTestBase}; this class only supplies the + * {@link WorkloadSpec}. The {@code @ParametersFactory} must be static, so it cannot read an + * instance field — hence the static {@link #SPEC}. + * + *

    All 43 ClickBench PPL queries are exercised. Each query's golden declares which combos it + * applies to — queries whose shard physical plan is absent (lucene fast-path) simply omit that layer. + */ +public class ClickBenchPlanShapeIT extends PlanShapeGoldenTestBase { + + private static final WorkloadSpec SPEC = new WorkloadSpec( + "clickbench", + ClickBenchTestHelper.DATASET, + "datasets/clickbench/ppl", + "planshape/clickbench", + IntStream.rangeClosed(1, 43).mapToObj(i -> "q" + i).toList() + ); + + @ParametersFactory(shuffle = false) + public static Iterable parameters() { + return buildQueryAndSettingCombinationsToRun(SPEC); + } + + public ClickBenchPlanShapeIT(@Name("case") GoldenCase testCase) { + super(testCase); + } +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/planshape/ExpectedQueryPlan.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/planshape/ExpectedQueryPlan.java new file mode 100644 index 0000000000000..03d8fe6ff5528 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/planshape/ExpectedQueryPlan.java @@ -0,0 +1,111 @@ +/* + * 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.analytics.qa.planshape; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +/** + * The expected plans for ONE query, parsed from its {@code q{N}.plan.yaml} golden. Holds the query + * text and, per applicable {@link SettingsCombo combo}, the expected plan text for each + * {@link PlanShapeLayer layer}. + * + *

    A layer entry is either literal plan text or a {@code same_as: } reference (one hop, no + * recursion), resolved at load time. A layer is uniformly an {@link Optional}: present = expected + * text, empty = the layer is expected ABSENT under that combo (e.g. single-shard has no coordinator + * stage, or a Lucene fast-path stage produces no DataFusion physical plan). + */ +public final class ExpectedQueryPlan { + + /** + * The plan layers asserted per (query, combo), and their YAML keys under a combo's plans block. + * Physical layers are backend-agnostic: a stage answered by Lucene yields an empty physical + * layer (no DF plan). {@code chosen_backend} / {@code tree_shape} are stage properties carried + * in the {@link #FRAGMENT} layer's per-stage text. + */ + public enum PlanShapeLayer { + /** Whole post-CBO Calcite plan, before the DAG cut: {@code profile.full_plan}. */ + POST_CBO("post_cbo"), + /** Per-stage Calcite slice from the DAG cut + chosen_backend + tree_shape: {@code profile.stages[*]}. */ + FRAGMENT("fragment"), + /** Shard physical plan: {@code stages[SHARD_FRAGMENT].tasks[*].physical_plan}; empty for a Lucene stage. */ + SHARD_PHYSICAL("shard_physical"), + /** Coordinator physical plan: {@code stages[COORDINATOR_REDUCE].tasks[*].physical_plan}; empty if no coord stage. */ + COORD_PHYSICAL("coord_physical"); + + private final String yamlKey; + + PlanShapeLayer(String yamlKey) { + this.yamlKey = yamlKey; + } + + public String yamlKey() { + return yamlKey; + } + } + + private final String queryId; + private final String queryText; + private final List appliesCombos; + // comboName -> (layer yamlKey -> resolved plan text). A missing key means the layer is absent. + private final Map> plansByCombo; + + public ExpectedQueryPlan( + String queryId, + String queryText, + List appliesCombos, + Map> plansByCombo + ) { + this.queryId = queryId; + this.queryText = queryText; + this.appliesCombos = appliesCombos; + this.plansByCombo = plansByCombo; + } + + public String queryId() { + return queryId; + } + + /** The PPL/SQL query text (resolved from inline {@code ppl} or referenced {@code ppl_file}). */ + public String queryText() { + return queryText; + } + + /** Combo names this query is asserted under. */ + public List appliesCombos() { + return appliesCombos; + } + + /** + * Expected plan text for a (combo, layer), or {@link Optional#empty()} if the layer is expected + * absent under that combo. {@code same_as} references are already resolved at load time. + */ + public Optional expected(String comboName, PlanShapeLayer layer) { + return expectedTextForLayerKey(comboName, layer.yamlKey()); + } + + /** + * Expected plan text for a (combo, layer key), or {@link Optional#empty()} if absent. The layer + * key is the golden's YAML key for a {@link PlanShapeLayer} ({@code post_cbo} / {@code fragment} / + * {@code shard_physical} / {@code coord_physical}), or a shard segment-layout sub-key + * ({@code shard_physical_1seg} / {@code shard_physical_nseg}) that exists only when the single- + * and multi-segment shard plans diverge. + */ + public Optional expectedTextForLayerKey(String comboName, String layerKey) { + Map textByLayerKey = plansByCombo.get(comboName); + if (textByLayerKey == null) { + throw new IllegalArgumentException( + String.format(Locale.ROOT, "query '%s' has no plans for combo '%s'", queryId, comboName) + ); + } + return Optional.ofNullable(textByLayerKey.get(layerKey)); + } +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/planshape/ExpectedQueryPlanLoader.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/planshape/ExpectedQueryPlanLoader.java new file mode 100644 index 0000000000000..7d583253adcb8 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/planshape/ExpectedQueryPlanLoader.java @@ -0,0 +1,196 @@ +/* + * 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.analytics.qa.planshape; + +import org.opensearch.common.xcontent.yaml.YamlXContent; +import org.opensearch.core.xcontent.DeprecationHandler; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.core.xcontent.XContentParser; + +import java.io.BufferedReader; +import java.util.Locale; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Loads one query's {@code q{N}.plan.yaml} golden into an {@link ExpectedQueryPlan}. + * + *

    The golden declares the query EXACTLY ONE way: inline {@code ppl:} or a referenced + * {@code ppl_file:} (path relative to the workload's query dir). It lists the combos it + * {@code applies} to, and under {@code plans} gives, per combo, the expected text for each layer. + * A layer value is either literal plan text or a {@code {same_as: }} reference (one hop), + * resolved here. An omitted layer means the layer is expected absent. + */ +public final class ExpectedQueryPlanLoader { + + private ExpectedQueryPlanLoader() {} + + /** + * Load one query's {@code q{N}.plan.yaml} golden (at {@code goldenResourcePath} on the + * classpath) into an {@link ExpectedQueryPlan}: resolves the query text (inline {@code ppl} or a + * {@code ppl_file} under {@code queryDir}) and the per-combo, per-layer expected plan text, + * resolving any {@code same_as} references. + */ + @SuppressWarnings("unchecked") + public static ExpectedQueryPlan loadGolden(String goldenResourcePath, String queryDir) throws IOException { + Map root = parseYaml(goldenResourcePath); + + String queryId = requiredString(root, "query", goldenResourcePath); + String queryText = resolveQueryText(root, queryDir, goldenResourcePath); + + List applies = (List) root.get("applies"); + if (applies == null || applies.isEmpty()) { + throw new IllegalStateException( + String.format(Locale.ROOT, "golden '%s' has no 'applies' combos", goldenResourcePath) + ); + } + + Map plans = (Map) root.get("plans"); + if (plans == null) { + throw new IllegalStateException( + String.format(Locale.ROOT, "golden '%s' has no 'plans' block", goldenResourcePath) + ); + } + + // First pass: collect each combo's raw layer map (text or same_as ref) without resolving. + Map> rawByCombo = new LinkedHashMap<>(); + for (String combo : applies) { + Object comboPlans = plans.get(combo); + if (comboPlans == null) { + throw new IllegalStateException( + String.format(Locale.ROOT, "golden '%s' applies to combo '%s' but has no plans for it", goldenResourcePath, combo) + ); + } + rawByCombo.put(combo, (Map) comboPlans); + } + + // Second pass: resolve same_as (one hop) into concrete layer text. + Map> resolvedByCombo = new LinkedHashMap<>(); + for (String combo : applies) { + Map rawLayers = rawByCombo.get(combo); + Map textByLayerKey = new LinkedHashMap<>(); + for (Map.Entry layer : rawLayers.entrySet()) { + textByLayerKey.put(layer.getKey(), resolveLayerText(layer.getValue(), rawByCombo, combo, layer.getKey(), goldenResourcePath)); + } + resolvedByCombo.put(combo, textByLayerKey); + } + + return new ExpectedQueryPlan(queryId, queryText, applies, resolvedByCombo); + } + + /** + * The expected plan text for one layer of one combo. A layer value in the golden is either the + * literal plan text, or a {@code {same_as: }} map that reuses that other combo's text + * for the same layer verbatim (so identical plans aren't duplicated across combos). Resolves the + * latter — one hop only; the target must itself be literal text. + */ + @SuppressWarnings("unchecked") + private static String resolveLayerText( + Object rawLayerValue, + Map> rawByCombo, + String combo, + String layerKey, + String goldenResourcePath + ) { + if (rawLayerValue instanceof String literalText) { + return literalText; + } + if (rawLayerValue instanceof Map) { + Object sameAs = ((Map) rawLayerValue).get("same_as"); + if (sameAs instanceof String referencedCombo) { + Map referencedLayers = rawByCombo.get(referencedCombo); + if (referencedLayers == null) { + throw new IllegalStateException( + String.format(Locale.ROOT, + "golden '%s' combo '%s' layer '%s' references same_as '%s', which is not an applied combo", + goldenResourcePath, combo, layerKey, referencedCombo + ) + ); + } + Object referencedText = referencedLayers.get(layerKey); + if (!(referencedText instanceof String literalText)) { + throw new IllegalStateException( + String.format(Locale.ROOT, + "golden '%s' combo '%s' layer '%s' same_as '%s' must point at literal text (no chained same_as / missing layer)", + goldenResourcePath, combo, layerKey, referencedCombo + ) + ); + } + return literalText; + } + } + throw new IllegalStateException( + String.format(Locale.ROOT, "golden '%s' combo '%s' layer '%s' is neither text nor a same_as map", goldenResourcePath, combo, layerKey) + ); + } + + /** Resolve query text from inline {@code ppl:} XOR referenced {@code ppl_file:}. */ + private static String resolveQueryText(Map root, String queryDir, String goldenResourcePath) throws IOException { + Object inline = root.get("ppl"); + Object file = root.get("ppl_file"); + if ((inline == null) == (file == null)) { + throw new IllegalStateException( + String.format(Locale.ROOT, "golden '%s' must declare exactly one of 'ppl' or 'ppl_file'", goldenResourcePath) + ); + } + if (inline != null) { + return ((String) inline).strip(); + } + return loadResource(queryDir + "/" + file).strip(); + } + + /** Read a required top-level string field from the parsed golden, failing if absent or non-string. */ + private static String requiredString(Map root, String key, String goldenResourcePath) { + Object value = root.get(key); + if (!(value instanceof String s)) { + throw new IllegalStateException( + String.format(Locale.ROOT, "golden '%s' missing required string field '%s'", goldenResourcePath, key) + ); + } + return s; + } + + private static Map parseYaml(String resourcePath) throws IOException { + try (InputStream is = resourceStream(resourcePath, "golden")) { + try ( + XContentParser parser = YamlXContent.yamlXContent.createParser( + NamedXContentRegistry.EMPTY, + DeprecationHandler.IGNORE_DEPRECATIONS, + is + ) + ) { + return parser.map(); + } + } + } + + private static String loadResource(String resourcePath) throws IOException { + try ( + InputStream is = resourceStream(resourcePath, "query"); + BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8)) + ) { + return reader.lines().collect(Collectors.joining("\n")); + } + } + + /** Open a classpath resource (from this class's loader), failing with a {@code kind}-tagged message. */ + private static InputStream resourceStream(String resourcePath, String kind) { + InputStream is = ExpectedQueryPlanLoader.class.getClassLoader().getResourceAsStream(resourcePath); + if (is == null) { + throw new IllegalStateException(String.format(Locale.ROOT, "%s resource not found: %s", kind, resourcePath)); + } + return is; + } +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/planshape/PlanShapeGoldenTestBase.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/planshape/PlanShapeGoldenTestBase.java new file mode 100644 index 0000000000000..39231044d7634 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/planshape/PlanShapeGoldenTestBase.java @@ -0,0 +1,383 @@ +/* + * 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.analytics.qa.planshape; + +import org.opensearch.analytics.qa.AnalyticsRestTestCase; +import org.opensearch.analytics.qa.Dataset; +import org.opensearch.analytics.qa.DatasetProvisioner; +import org.opensearch.analytics.qa.DatasetProvisioner.SegmentLayout; +import org.opensearch.analytics.qa.planshape.ExpectedQueryPlan.PlanShapeLayer; +import org.opensearch.client.Request; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Shared harness for plan-shape golden ITs. One JUnit case per (query, combo): each applies the + * combo's cluster settings, captures the {@code /_plugins/_ppl?profile=true} plan under both segment + * layouts ({@link SegmentLayout}), renders it into the {@link PlanShapeLayer}s, and asserts each + * against the query's golden. The non-shard layers are segment-independent (asserted once); the + * shard physical plan is asserted per segment layout. + * + *

    Per-workload subclasses supply a {@link WorkloadSpec} via a static {@code @ParametersFactory} + * that calls {@link #buildQueryAndSettingCombinationsToRun(WorkloadSpec)} (the factory must be + * static, so it cannot read an instance field). + */ +public abstract class PlanShapeGoldenTestBase extends AnalyticsRestTestCase { + + /** Classpath path of the global combos registry. */ + private static final String COMBOS_RESOURCE = "planshape/combos.yaml"; + + /** One (query, combo) pair — the unit a JUnit case asserts. {@code toString()} names the case. */ + public static final class GoldenCase { + final WorkloadSpec workload; + final String queryId; + final String comboName; + + GoldenCase(WorkloadSpec workload, String queryId, String comboName) { + this.workload = workload; + this.queryId = queryId; + this.comboName = comboName; + } + + @Override + public String toString() { + return workload.name() + "/" + queryId + "[" + comboName + "]"; + } + } + + /** + * The two segment layouts every combo is captured under — a controlled axis (see core-axioms): + * the shard physical plan can legitimately differ with segment count (e.g. the scan's + * {@code input_partitions}). The non-shard layers (post_cbo / fragment / coord) are NOT + * segment-derived and are captured once (from the single-segment index). + */ + private static final SegmentLayout[] SEGMENT_LAYOUTS = { SegmentLayout.SINGLE_SEGMENT, SegmentLayout.MULTI_SEGMENT }; + + /** One unit of YAML indentation; golden nesting is whole multiples of this. */ + private static final String INDENT = " "; + + /** YAML key for the deduped shard physical plan (single-segment == multi-segment). */ + private static final String SHARD_KEY = PlanShapeLayer.SHARD_PHYSICAL.yamlKey(); + /** YAML keys for the per-segment-layout shard physical plans (used only when they diverge). */ + private static String shardKey(SegmentLayout layout) { + return SHARD_KEY + "_" + layout.suffix; + } + + private final GoldenCase testCase; + + protected PlanShapeGoldenTestBase(GoldenCase testCase) { + this.testCase = testCase; + } + + /** + * Build the JUnit parameter rows — one per (query, setting-combo) — for a workload, the source for + * the subclass {@code @ParametersFactory}. Each query's golden declares which combos it applies + * to, intersected with the combos this run exercises ({@link #getSettingCombosToRun}). Static, so + * it can't read an instance field. + */ + protected static List buildQueryAndSettingCombinationsToRun(WorkloadSpec spec) { + try { + SettingsComboRegistry combos = SettingsComboRegistry.load(COMBOS_RESOURCE); + List runCombos = getSettingCombosToRun(combos); + // When generating, a golden may not exist yet — use every run-combo per query. + // When asserting, only the combos the golden declares it applies to (intersected). + boolean generating = System.getProperty("plan.generate") != null; + + List cases = new ArrayList<>(); + for (String queryId : spec.queryIds()) { + List queryCombos = generating + ? runCombos + : ExpectedQueryPlanLoader.loadGolden(spec.goldenResourcePath(queryId), spec.queryDir()) + .appliesCombos().stream().filter(runCombos::contains).toList(); + for (String comboName : queryCombos) { + cases.add(new Object[] { new GoldenCase(spec, queryId, comboName) }); + } + } + return cases; + } catch (IOException e) { + throw new RuntimeException("failed building plan-shape cases for workload " + spec.name(), e); + } + } + + /** The {@link SettingsCombo} names this run should exercise: {@code -Dplan.combos=a,b} or {@code all}, else the file defaults. */ + private static List getSettingCombosToRun(SettingsComboRegistry combos) { + String prop = System.getProperty("plan.combos"); + if (prop == null || prop.isBlank()) { + return combos.defaults(); + } + if (prop.trim().equals("all")) { + return combos.allNames(); + } + List requested = List.of(prop.trim().split("\\s*,\\s*")); + // Validate up front: an unknown name in assert mode would otherwise just filter to zero cases + // and silently run nothing. byName throws with the list of defined combos. + requested.forEach(combos::byName); + return requested; + } + + public void testPlanShape() throws Exception { + SettingsComboRegistry combos = SettingsComboRegistry.load(COMBOS_RESOURCE); + SettingsCombo combo = combos.byName(testCase.comboName); + boolean generating = System.getProperty("plan.generate") != null; + // Assertion mode needs the golden; generate mode does not (it's creating it) and reads the + // query text straight from the workload's query file. + ExpectedQueryPlan golden = generating + ? null + : ExpectedQueryPlanLoader.loadGolden(testCase.workload.goldenResourcePath(testCase.queryId), testCase.workload.queryDir()); + String queryText = generating + ? loadResource(testCase.workload.queryDir() + "/" + testCase.queryId + ".ppl").strip() + : golden.queryText(); + + applyClusterSettings(combo); + + // Capture under both segment layouts. The non-shard layers come from the single-segment + // index (they are not segment-derived); the shard physical plan is captured per layout. + Map captured = new LinkedHashMap<>(); + for (SegmentLayout layout : SEGMENT_LAYOUTS) { + provisionOnce(testCase.workload, combo.numberOfShards(), layout); + String indexName = indexNameFor(testCase.workload, combo.numberOfShards(), layout); + // Point the query at the per-layout index. Replaces every occurrence of the dataset + // name; relies on it being a distinctive token (the source name), not a substring that + // also appears elsewhere in the query. + String ppl = queryText.replace(testCase.workload.dataset().name, indexName); + Map response = executePplWithProfile(ppl); + captured.put(layout, ProfilePlanExtractor.extractFrom(response, indexName)); + } + + // -Dplan.generate=write|print : capture instead of assert. write -> the q{N}.plan.yaml in + // the source tree; print -> log the block. Absent -> assertion mode. + String generate = System.getProperty("plan.generate"); + if (generate != null) { + String comboYaml = renderComboYaml(captured); + if ("print".equals(generate)) { + logger.info("plan-shape generated for {}:\n{}", testCase, comboYaml); + } else { + writeGolden(comboYaml); + } + return; + } + + // Non-shard layers: assert once (from the single-segment capture — segment-independent). + ProfilePlanExtractor singleSegment = captured.get(SegmentLayout.SINGLE_SEGMENT); + for (PlanShapeLayer layer : PlanShapeLayer.values()) { + if (layer == PlanShapeLayer.SHARD_PHYSICAL) { + continue; + } + assertPlanShapeLayer(golden.expected(testCase.comboName, layer), singleSegment.layer(layer), layer.toString()); + } + assertShardPhysical(golden, captured); + } + + /** + * Assert the shard physical plan for both segment layouts. A golden stores either a single + * deduped {@code shard_physical} (when the two are identical) or both {@code shard_physical_1seg} + * / {@code shard_physical_nseg} (when they diverge). Resolve per layout: prefer the + * layout-specific key, else fall back to the deduped key. + */ + private void assertShardPhysical(ExpectedQueryPlan golden, Map captured) { + for (SegmentLayout layout : SEGMENT_LAYOUTS) { + Optional actual = captured.get(layout).layer(PlanShapeLayer.SHARD_PHYSICAL); + Optional expected = golden.expectedTextForLayerKey(testCase.comboName, shardKey(layout)); + if (expected.isEmpty()) { + expected = golden.expectedTextForLayerKey(testCase.comboName, SHARD_KEY); // deduped + } + assertPlanShapeLayer(expected, actual, PlanShapeLayer.SHARD_PHYSICAL + "[" + layout.suffix + "]"); + } + } + + /** + * Render this (query, combo)'s captured layers as the YAML body that sits under {@code plans:}. + * Non-shard layers come from the single-segment capture (segment-independent). The shard physical + * plan is deduped: if the single- and multi-segment plans are identical, emit one + * {@code shard_physical}; if they diverge, emit both {@code shard_physical_1seg} and + * {@code shard_physical_nseg}. + */ + private String renderComboYaml(Map captured) { + ProfilePlanExtractor singleSegment = captured.get(SegmentLayout.SINGLE_SEGMENT); + StringBuilder yaml = new StringBuilder(); + yaml.append(INDENT).append(testCase.comboName).append(":\n"); + for (PlanShapeLayer layer : PlanShapeLayer.values()) { + if (layer == PlanShapeLayer.SHARD_PHYSICAL) { + appendShardPhysical(yaml, captured); + continue; + } + singleSegment.layer(layer).ifPresent(text -> appendBlock(yaml, layer.yamlKey(), text)); + } + return yaml.toString(); + } + + /** Emit the shard physical plan(s): one deduped key if the layouts match, else both. */ + private void appendShardPhysical(StringBuilder yaml, Map captured) { + Optional oneSeg = captured.get(SegmentLayout.SINGLE_SEGMENT).layer(PlanShapeLayer.SHARD_PHYSICAL); + Optional multiSeg = captured.get(SegmentLayout.MULTI_SEGMENT).layer(PlanShapeLayer.SHARD_PHYSICAL); + if (oneSeg.isEmpty() && multiSeg.isEmpty()) { + return; // no shard physical (Lucene fast-path): omit, asserted absent for both + } + if (oneSeg.equals(multiSeg)) { + appendBlock(yaml, SHARD_KEY, oneSeg.get()); // identical across layouts -> dedup + return; + } + // Diverge: pin each layout under its own key. This also covers the asymmetric case where + // one layout has a shard plan and the other doesn't (Optional.empty) — the present side + // gets its key, the absent side is simply omitted (asserted absent for that layout). + oneSeg.ifPresent(text -> appendBlock(yaml, shardKey(SegmentLayout.SINGLE_SEGMENT), text)); + multiSeg.ifPresent(text -> appendBlock(yaml, shardKey(SegmentLayout.MULTI_SEGMENT), text)); + } + + private static void appendBlock(StringBuilder yaml, String yamlKey, String text) { + yaml.append(INDENT.repeat(2)).append(yamlKey).append(": |\n"); + for (String line : text.split("\n")) { + yaml.append(INDENT.repeat(3)).append(line).append('\n'); + } + } + + /** + * Write this (query, combo)'s YAML into the query's golden file in the source tree (path from + * {@code -Dplan.resourcesDir}). Since a golden holds all combos but each JUnit case is one + * combo, accumulate combos per query (single-fork sequential run) and rewrite the whole file + * each time, so the file always reflects every combo generated so far. + */ + private void writeGolden(String comboYaml) { + String resourcesDir = System.getProperty("plan.resourcesDir"); + if (resourcesDir == null) { + throw new IllegalStateException("plan.generate=write needs -Dplan.resourcesDir (set by build.gradle)"); + } + Map yamlByCombo = GENERATED_YAML_BY_QUERY.computeIfAbsent( + testCase.workload.name() + "/" + testCase.queryId, k -> new LinkedHashMap<>()); + yamlByCombo.put(testCase.comboName, comboYaml); + + Path out = Path.of(resourcesDir, testCase.workload.goldenResourcePath(testCase.queryId)); + String header = preserveExistingHeader(out); + + StringBuilder doc = new StringBuilder(); + doc.append(header); + doc.append("query: ").append(testCase.queryId).append('\n'); + doc.append("ppl_file: ").append(testCase.queryId).append(".ppl\n"); + doc.append("applies: [").append(String.join(", ", yamlByCombo.keySet())).append("]\n"); + doc.append("plans:\n"); + yamlByCombo.values().forEach(doc::append); + try { + Files.createDirectories(out.getParent()); + Files.writeString(out, doc.toString()); + logger.info("plan-shape wrote golden {} -> {}", testCase, out); + } catch (IOException e) { + throw new UncheckedIOException("failed writing golden " + out, e); + } + } + + /** "workload/queryId" -> (comboName -> that combo's rendered YAML), accumulated across a generate run. */ + private static final Map> GENERATED_YAML_BY_QUERY = new ConcurrentHashMap<>(); + + /** + * Read leading {@code #} comment lines from an existing golden file to preserve hand-authored + * annotations across regeneration. Returns empty string if no file or no comments exist. + */ + private static String preserveExistingHeader(Path goldenFile) { + if (Files.exists(goldenFile) == false) { + return ""; + } + try { + StringBuilder header = new StringBuilder(); + for (String line : Files.readAllLines(goldenFile)) { + if (line.startsWith("#")) { + header.append(line).append('\n'); + } else { + break; + } + } + return header.toString(); + } catch (IOException e) { + return ""; + } + } + + /** Assert one captured plan-shape layer against its expected text (present text, or empty = expected absent). */ + private void assertPlanShapeLayer(Optional expected, Optional actual, String layerLabel) { + String label = testCase + " " + layerLabel; + + if (expected.isEmpty()) { + assertTrue( + String.format(Locale.ROOT, "%s — expected layer ABSENT but plan was produced:\n%s", label, actual.orElse("")), + actual.isEmpty() + ); + return; + } + assertTrue( + String.format(Locale.ROOT, "%s — expected a plan but layer was ABSENT in the response", label), + actual.isPresent() + ); + // Compare trailing-whitespace-insensitively: a YAML block scalar (|) keeps one trailing + // newline the captured plan string doesn't have. Plan shape is what matters, not edge newlines. + assertEquals( + String.format(Locale.ROOT, "%s — plan shape mismatch.\n=== ACTUAL (paste into golden) ===\n%s\n=== END ===", label, actual.get()), + expected.get().stripTrailing(), + actual.get().stripTrailing() + ); + } + + private Map executePplWithProfile(String ppl) throws IOException { + Request request = new Request("POST", "/_plugins/_ppl"); + request.setJsonEntity("{\"query\": \"" + escapeJson(ppl) + "\", \"profile\": true}"); + return assertOkAndParse(client().performRequest(request), "PPL(profile): " + ppl); + } + + private void applyClusterSettings(SettingsCombo combo) throws IOException { + if (combo.clusterSettings().isEmpty()) { + return; + } + StringBuilder settingsJson = new StringBuilder("{\"transient\":{"); + boolean first = true; + for (Map.Entry setting : combo.clusterSettings().entrySet()) { + if (!first) { + settingsJson.append(','); + } + first = false; + settingsJson.append('"').append(setting.getKey()).append("\":").append(toJsonValue(setting.getValue())); + } + settingsJson.append("}}"); + Request request = new Request("PUT", "/_cluster/settings"); + request.setJsonEntity(settingsJson.toString()); + assertOkAndParse(client().performRequest(request), "PUT settings: " + settingsJson); + } + + private static String toJsonValue(Object v) { + return v instanceof String ? "\"" + v + "\"" : String.valueOf(v); + } + + /** + * One index per (shard count, segment layout) cell, so all variants coexist on one cluster, + * e.g. {@code parquet_hits_2s_1seg} / {@code parquet_hits_2s_nseg}. + */ + private static String indexNameFor(WorkloadSpec spec, int numberOfShards, SegmentLayout layout) { + return spec.dataset().indexName + "_" + numberOfShards + "s_" + layout.suffix; + } + + private void provisionOnce(WorkloadSpec spec, int numberOfShards, SegmentLayout layout) throws IOException { + String indexName = indexNameFor(spec, numberOfShards, layout); + if (!PROVISIONED_INDICES.add(indexName)) { + return; // already provisioned this JVM + } + Dataset index = new Dataset(spec.dataset().name, indexName); + DatasetProvisioner.provision(client(), index, numberOfShards, layout); + } + + /** Indices already provisioned this JVM — each (shard count, segment layout) index is provisioned once. */ + private static final Set PROVISIONED_INDICES = ConcurrentHashMap.newKeySet(); +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/planshape/ProfilePlanExtractor.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/planshape/ProfilePlanExtractor.java new file mode 100644 index 0000000000000..d916ee987d049 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/planshape/ProfilePlanExtractor.java @@ -0,0 +1,238 @@ +/* + * 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.analytics.qa.planshape; + +import org.opensearch.analytics.qa.planshape.ExpectedQueryPlan.PlanShapeLayer; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeSet; +import java.util.regex.Pattern; + +/** + * Turns the {@code profile} block of a {@code /_plugins/_ppl?profile=true} response into the same + * four {@link PlanShapeLayer} strings stored in a golden, so a captured plan can be diffed against + * its {@link ExpectedQueryPlan}. The render + scrub rules here MUST match exactly how goldens were + * authored. + * + *

      + *
    • {@link PlanShapeLayer#POST_CBO} = {@code profile.full_plan} lines joined by newline. + *
    • {@link PlanShapeLayer#FRAGMENT} = per stage, a header + * {@code [ chosen_backend= tree_shape=]} then that stage's fragment lines. + *
    • {@link PlanShapeLayer#SHARD_PHYSICAL} = the SHARD_FRAGMENT stage's task physical plans, + * scrubbed and deduped (all shard tasks are identical after scrubbing). Empty if none + * (Lucene fast-path / empty shard). + *
    • {@link PlanShapeLayer#COORD_PHYSICAL} = the COORDINATOR_REDUCE stage's task physical plan, + * scrubbed. Empty if there is no coordinator stage (single-shard / non-splitting query). + *
    + */ +public final class ProfilePlanExtractor { + + /** Single consistent token for every non-deterministic value we redact from a plan. */ + private static final String SCRUBBED = ""; + + /** Host/UUID/shard/generation-specific parquet path. */ + private static final Pattern FILE_GROUPS = Pattern.compile("file_groups=\\{[^}]*\\}"); + private static final String FILE_GROUPS_SCRUBBED = "file_groups={" + SCRUBBED + "}"; + + // TopK SortExec's `, filter=[ > N]` is a runtime value, not plan shape — its presence flips + // with segment count, so we delete the whole clause (anchored on preserve_partitioning=[...] so + // only SortExec is touched; DataSourceExec uses predicate=, not this). + private static final Pattern TOPK_DYNAMIC_FILTER = + Pattern.compile("(preserve_partitioning=\\[[^\\]]*\\]), filter=\\[[^\\]]*\\]"); + private static final String TOPK_DYNAMIC_FILTER_KEPT = "$1"; + + // input_partitions is deliberately NOT scrubbed: it equals the shard's segment count, which is a + // controlled axis (captured per segment layout), so it's deterministic and worth asserting. + + // DataSourceExec DynamicFilter predicate: the TopK optimization pushes a boundary filter down into + // DataSourceExec as `predicate=DynamicFilter [ ]`. The boundary is a runtime value + // that depends on data seen so far by the TopK heap — in multi-shard configs, each shard sees + // different data and arrives at a different boundary, so both the DynamicFilter body AND its + // derived pruning_predicate diverge across shards. We scrub: + // (a) Pure DynamicFilter predicate: when the entire predicate= is a DynamicFilter (no static + // WHERE clause), the pruning_predicate is entirely derived from it — scrub both. + // (b) Mixed predicate: when DynamicFilter is appended to static predicates (e.g. + // "predicate=X AND DynamicFilter [ empty ]"), only the DynamicFilter body is scrubbed; + // the static predicates and their pruning_predicate remain for assertion. + private static final Pattern PURE_DYNAMIC_FILTER_PREDICATE = Pattern.compile( + "predicate=DynamicFilter \\[[^\\]]*], pruning_predicate=[^,]*, "); + private static final String PURE_DYNAMIC_FILTER_SCRUBBED = + "predicate=DynamicFilter [ " + SCRUBBED + " ], pruning_predicate=" + SCRUBBED + ", "; + + private static final Pattern MIXED_DYNAMIC_FILTER_BODY = Pattern.compile( + "DynamicFilter \\[[^\\]]*]"); + private static final String MIXED_DYNAMIC_FILTER_BODY_SCRUBBED = + "DynamicFilter [ " + SCRUBBED + " ]"; + + // Constant-folded aggregates: when an aggregate (e.g. min/max) can be resolved at plan-time from + // parquet metadata, DataFusion folds the entire subtree to a ProjectionExec of literal constants + // fed by PlaceholderRowExec. The literal values are data-dependent (each shard holds different + // data), so they diverge across shards in multi-shard configs. We scrub bare numeric literals in + // such projections. Safety: if the optimizer stops constant-folding (regression), PlaceholderRowExec + // vanishes and the rule no longer fires — the structural change is still caught. + private static final Pattern CONSTANT_FOLDED_PROJECTION = Pattern.compile( + "(ProjectionExec: expr=\\[)([^\\]]+)(\\]\\s*\\n\\s*PlaceholderRowExec)" + ); + /** Bare numeric literal as the expression side of "expr as alias" within a ProjectionExec. */ + private static final Pattern BARE_NUMERIC_LITERAL = Pattern.compile("(?<=^|, )-?\\d+(?= as )"); + + /** The concrete index name is scrubbed too, so goldens are index-name agnostic. */ + private static final String INDEX_TOKEN = SCRUBBED; + + private static final String SHARD_FRAGMENT = "SHARD_FRAGMENT"; + private static final String COORDINATOR_REDUCE = "COORDINATOR_REDUCE"; + + private final Map> layers; + + private ProfilePlanExtractor(Map> layers) { + this.layers = layers; + } + + /** The captured plan text for a layer, or empty if that layer was absent in the response. */ + public Optional layer(PlanShapeLayer layer) { + return layers.get(layer); + } + + /** + * Render the four layers from a profile response. {@code indexName} is the concrete index the + * query ran against; every occurrence is scrubbed to {@link #INDEX_TOKEN} so a golden is + * agnostic to which per-shard-count index was provisioned. + */ + @SuppressWarnings("unchecked") + public static ProfilePlanExtractor extractFrom(Map response, String indexName) { + Map profile = (Map) response.get("profile"); + if (profile == null) { + throw new AssertionError("response has no 'profile' block — request must set profile=true"); + } + // The opensearch-sql plugin's QueryProfile {summary, phases, plan} embeds the analytics-engine + // plan profile (full_plan + stages) under the `plan` field, so they live at profile.plan. + Map planProfile = (Map) profile.get("plan"); + if (planProfile == null) { + throw new AssertionError("profile has no 'plan' block; profile keys=" + profile.keySet()); + } + List> stages = (List>) planProfile.get("stages"); + if (stages == null) { + throw new AssertionError("profile.plan has no stages; plan keys=" + planProfile.keySet()); + } + + Map> layers = new LinkedHashMap<>(); + layers.put(PlanShapeLayer.POST_CBO, Optional.of(joinLines((List) planProfile.get("full_plan")))); + layers.put(PlanShapeLayer.FRAGMENT, Optional.of(renderFragment(stages))); + layers.put(PlanShapeLayer.SHARD_PHYSICAL, physicalPlanOf(stages, SHARD_FRAGMENT)); + layers.put(PlanShapeLayer.COORD_PHYSICAL, physicalPlanOf(stages, COORDINATOR_REDUCE)); + + layers.replaceAll((layer, text) -> text.map(t -> t.replace(indexName, INDEX_TOKEN))); + return new ProfilePlanExtractor(layers); + } + + // TODO(plan-shape): FRAGMENT renders the PRE-FORK marked fragment. Once the profile exposes + // post-fork `alternatives` (see QueryProfileBuilder TODO), assert on those instead. + @SuppressWarnings("unchecked") + private static String renderFragment(List> stages) { + StringBuilder rendered = new StringBuilder(); + for (Map stage : stages) { + // tree_shape is the delegation shape (CONJUNCTIVE / INTERLEAVED_BOOLEAN_EXPRESSION / + // ...) and MUST be asserted. "Absent" serializes inconsistently across builds (JSON + // null vs the string "None"); canonicalize both to NONE so the value is always present + // and node-agnostic. + Object treeShape = stage.get("tree_shape"); + String treeShapeStr = (treeShape == null || "None".equals(treeShape)) ? "NONE" : treeShape.toString(); + rendered.append('[') + .append(stage.get("execution_type")) + .append(" chosen_backend=") + .append(stage.get("chosen_backend")) + .append(" tree_shape=") + .append(treeShapeStr) + .append(']') + .append('\n'); + List fragment = (List) stage.get("fragment"); + if (fragment != null) { + for (String line : fragment) { + rendered.append(line).append('\n'); + } + } + } + return stripTrailingNewline(rendered.toString()); + } + + /** + * The scrubbed, deduped physical plan for the stage of the given execution type, or empty if + * the stage is absent or carries no task physical plan (Lucene / empty shard). All tasks of a + * stage are expected to be identical after scrubbing; a divergence is a real finding and fails. + */ + @SuppressWarnings("unchecked") + private static Optional physicalPlanOf(List> stages, String executionType) { + for (Map stage : stages) { + if (!executionType.equals(stage.get("execution_type"))) { + continue; + } + List> tasks = (List>) stage.get("tasks"); + if (tasks == null) { + return Optional.empty(); + } + TreeSet distinctPlans = new TreeSet<>(); + for (Map task : tasks) { + Object physicalPlan = task.get("physical_plan"); + if (physicalPlan instanceof String text && !text.isEmpty()) { + distinctPlans.add(scrub(text)); + } + } + if (distinctPlans.isEmpty()) { + return Optional.empty(); + } + if (distinctPlans.size() > 1) { + throw new AssertionError( + "tasks of stage " + executionType + " produced differing physical plans after scrub:\n" + + String.join("\n--- vs ---\n", distinctPlans) + ); + } + return Optional.of(distinctPlans.first()); + } + return Optional.empty(); + } + + private static String scrub(String physicalPlan) { + String scrubbed = FILE_GROUPS.matcher(physicalPlan).replaceAll(FILE_GROUPS_SCRUBBED); + scrubbed = TOPK_DYNAMIC_FILTER.matcher(scrubbed).replaceAll(TOPK_DYNAMIC_FILTER_KEPT); + // DynamicFilter scrub: pure first (scrubs both predicate + pruning_predicate), then mixed + // (scrubs only the DynamicFilter body, leaving static predicates intact). + scrubbed = PURE_DYNAMIC_FILTER_PREDICATE.matcher(scrubbed).replaceAll(PURE_DYNAMIC_FILTER_SCRUBBED); + scrubbed = MIXED_DYNAMIC_FILTER_BODY.matcher(scrubbed).replaceAll(MIXED_DYNAMIC_FILTER_BODY_SCRUBBED); + scrubbed = scrubConstantFoldedProjections(scrubbed); + return scrubbed; + } + + /** + * Scrub data-dependent numeric literals in constant-folded projections (ProjectionExec above + * PlaceholderRowExec). Replaces bare numeric values with {@code } so per-shard variation + * in folded aggregates (min/max/count on different data slices) doesn't break cross-shard dedup. + */ + private static String scrubConstantFoldedProjections(String plan) { + java.util.regex.Matcher m = CONSTANT_FOLDED_PROJECTION.matcher(plan); + StringBuilder sb = new StringBuilder(); + while (m.find()) { + String exprs = m.group(2); + String scrubbedExprs = BARE_NUMERIC_LITERAL.matcher(exprs).replaceAll(SCRUBBED); + m.appendReplacement(sb, java.util.regex.Matcher.quoteReplacement(m.group(1) + scrubbedExprs + m.group(3))); + } + m.appendTail(sb); + return sb.toString(); + } + + private static String joinLines(List lines) { + return lines == null ? "" : String.join("\n", lines); + } + + private static String stripTrailingNewline(String s) { + return s.endsWith("\n") ? s.substring(0, s.length() - 1) : s; + } +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/planshape/SettingsCombo.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/planshape/SettingsCombo.java new file mode 100644 index 0000000000000..e47f8064ea970 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/planshape/SettingsCombo.java @@ -0,0 +1,86 @@ +/* + * 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.analytics.qa.planshape; + +import java.util.Collections; +import java.util.Map; + +/** + * One named combination of plan-shape-affecting settings, loaded from the global + * {@code planshape/combos.yaml}. Goldens reference a combo only by {@link #name()}. + * + *

    A combo IS its settings — there is no knob-token indirection. {@link #clusterSettings()} are + * applied via {@code PUT /_cluster/settings}. + * + *

    {@code number_of_shards} is a REQUIRED index setting — the universal root knob (it decides + * whether the plan splits into a coordinator stage), so every combo must declare it under + * {@code index:}. TODAY it is the ONLY index setting the harness applies: it is read via the typed + * {@link #numberOfShards()} and injected at the index {@code PUT}. Any OTHER key in + * {@link #indexSettings()} is currently parsed but NOT applied — wiring the full map through + * provisioning is future work for when a second index-scoped knob is added. + * + *

    Example {@code combos.yaml}: + *

    + * combos:
    + *   prod:
    + *     index:
    + *       number_of_shards: 2
    + *     cluster:
    + *       analytics.shard_bucket_oversampling_factor: 2.0
    + *       search.concurrent.max_slice_count: 4
    + *       datafusion.reduce.target_partitions: 4
    + *   prod1s:
    + *     index:
    + *       number_of_shards: 1
    + *     cluster: { ... }
    + * defaults: [prod, prod1s]
    + * 
    + */ +public final class SettingsCombo { + + /** The required index setting every combo must declare — the universal root knob. */ + public static final String NUMBER_OF_SHARDS = "number_of_shards"; + + private final String name; + private final Map clusterSettings; + private final Map indexSettings; + + public SettingsCombo(String name, Map clusterSettings, Map indexSettings) { + this.name = name; + this.clusterSettings = Collections.unmodifiableMap(clusterSettings); + this.indexSettings = Collections.unmodifiableMap(indexSettings); + } + + public String name() { + return name; + } + + /** Required shard count for this combo — the universal root knob (split vs no-split). */ + public int numberOfShards() { + return ((Number) indexSettings.get(NUMBER_OF_SHARDS)).intValue(); + } + + /** Cluster-scope settings applied via {@code PUT /_cluster/settings} for this combo. */ + public Map clusterSettings() { + return clusterSettings; + } + + /** + * Index-scope settings as declared under {@code index:}. NOTE: only {@code number_of_shards} is + * applied today (via {@link #numberOfShards()}); other keys are not yet wired into provisioning. + */ + public Map indexSettings() { + return indexSettings; + } + + @Override + public String toString() { + return name; + } +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/planshape/SettingsComboRegistry.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/planshape/SettingsComboRegistry.java new file mode 100644 index 0000000000000..2ed46f85267fe --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/planshape/SettingsComboRegistry.java @@ -0,0 +1,115 @@ +/* + * 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.analytics.qa.planshape; + +import org.opensearch.common.xcontent.yaml.YamlXContent; +import org.opensearch.core.xcontent.DeprecationHandler; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.core.xcontent.XContentParser; + +import java.io.IOException; +import java.util.Locale; +import java.io.InputStream; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Loads and provides the named {@link SettingsCombo}s from the global {@code planshape/combos.yaml} + * — the single source of truth mapping each combo name to its plan-shape-affecting settings. + * Goldens reference combos only by name. + */ +public final class SettingsComboRegistry { + + /** Insertion-ordered (file order) so {@link #allNames()} reflects the combos.yaml order. */ + private final LinkedHashMap byName; + private final List defaults; + + private SettingsComboRegistry(LinkedHashMap byName, List defaults) { + this.byName = byName; + this.defaults = defaults; + } + + /** Look up a combo by name, failing loudly if a golden references an undefined combo. */ + public SettingsCombo byName(String name) { + SettingsCombo combo = byName.get(name); + if (combo == null) { + throw new IllegalArgumentException( + String.format(Locale.ROOT, "Unknown combo '%s' — defined combos: %s", name, byName.keySet()) + ); + } + return combo; + } + + /** Combo names CI runs by default (the file's {@code defaults} list). */ + public List defaults() { + return defaults; + } + + /** Every defined combo name, in file order. */ + public List allNames() { + return List.copyOf(byName.keySet()); + } + + /** Load and parse a {@code combos.yaml} classpath resource into named {@link SettingsCombo}s. */ + @SuppressWarnings("unchecked") + public static SettingsComboRegistry load(String resourcePath) throws IOException { + try (InputStream is = SettingsComboRegistry.class.getClassLoader().getResourceAsStream(resourcePath)) { + if (is == null) { + throw new IllegalStateException( + String.format(Locale.ROOT, "combos resource not found: %s", resourcePath) + ); + } + Map root = parseYaml(is); + + Map combos = (Map) root.get("combos"); + if (combos == null || combos.isEmpty()) { + throw new IllegalStateException( + String.format(Locale.ROOT, "combos.yaml has no 'combos' block: %s", resourcePath) + ); + } + + LinkedHashMap byName = new LinkedHashMap<>(); + for (Map.Entry entry : combos.entrySet()) { + String comboName = entry.getKey(); + Map comboBody = (Map) entry.getValue(); + + Map cluster = (Map) comboBody.getOrDefault("cluster", Map.of()); + Map index = (Map) comboBody.getOrDefault("index", Map.of()); + if (!index.containsKey(SettingsCombo.NUMBER_OF_SHARDS)) { + throw new IllegalStateException( + String.format(Locale.ROOT, "combo '%s' must declare index.%s", comboName, SettingsCombo.NUMBER_OF_SHARDS) + ); + } + + byName.put(comboName, new SettingsCombo(comboName, cluster, index)); + } + + List defaults = (List) root.get("defaults"); + if (defaults == null || defaults.isEmpty()) { + throw new IllegalStateException( + String.format(Locale.ROOT, "combos.yaml has no 'defaults' list: %s", resourcePath) + ); + } + return new SettingsComboRegistry(byName, defaults); + } + } + + private static Map parseYaml(InputStream is) throws IOException { + try ( + XContentParser parser = YamlXContent.yamlXContent.createParser( + NamedXContentRegistry.EMPTY, + DeprecationHandler.IGNORE_DEPRECATIONS, + is + ) + ) { + return parser.map(); + } + } +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/planshape/WorkloadSpec.java b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/planshape/WorkloadSpec.java new file mode 100644 index 0000000000000..30f2ad01dd698 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/planshape/WorkloadSpec.java @@ -0,0 +1,66 @@ +/* + * 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.analytics.qa.planshape; + +import org.opensearch.analytics.qa.Dataset; + +import java.util.List; + +/** + * Describes one plan-shape workload: its dataset (mapping + bulk, via {@link Dataset}), where its + * query files and golden files live, and which queries are exercised. + * + *

    Onboarding a new workload (http_logs, big5, ...) is just constructing one of these plus its + * {@code q{N}.plan.yaml} goldens — no harness changes. + */ +public final class WorkloadSpec { + + private final String name; + private final Dataset dataset; + private final String queryDir; + private final String goldenDir; + private final List queryIds; + + public WorkloadSpec(String name, Dataset dataset, String queryDir, String goldenDir, List queryIds) { + this.name = name; + this.dataset = dataset; + this.queryDir = queryDir; + this.goldenDir = goldenDir; + this.queryIds = queryIds; + } + + public String name() { + return name; + } + + /** Dataset descriptor used to provision the index (mapping + bulk). */ + public Dataset dataset() { + return dataset; + } + + /** Classpath dir holding query files referenced by a golden's {@code ppl_file}. */ + public String queryDir() { + return queryDir; + } + + /** Classpath dir holding the per-query {@code q{N}.plan.yaml} goldens. */ + public String goldenDir() { + return goldenDir; + } + + /** The query ids exercised by this workload (the allowlist). */ + public List queryIds() { + return queryIds; + } + + /** Classpath path of a query's golden file. */ + public String goldenResourcePath(String queryId) { + return goldenDir + "/" + queryId + ".plan.yaml"; + } +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/bulk.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/bulk.json index 32e3d2d6213af..7fb6030d2d2e5 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/bulk.json +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/bulk.json @@ -199,3 +199,95 @@ {"index":{}} {"AdvEngineID":15,"Age":63,"BrowserCountry":"KR","BrowserLanguage":"en","CLID":752,"ClientEventTime":1384265139323,"ClientIP":1007792620,"ClientTimeZone":2,"CodeVersion":806,"ConnectTiming":319,"CookieEnable":0,"CounterClass":4,"CounterID":21953,"DNSTiming":5,"DontCountHits":0,"EventDate":1395789892066,"EventTime":1392593133962,"FUniqID":3732859785350930068,"FetchTiming":955,"FlashMajor":3,"FlashMinor":9,"FlashMinor2":4,"FromTag":"","GoodEvent":1,"HID":757275524,"HTTPError":0,"HasGCLID":0,"HistoryLength":9,"HitColor":"S","IPNetworkID":88143,"Income":2,"Interests":50,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":0,"IsMobile":0,"IsNotBounce":1,"IsOldCounter":0,"IsParameter":0,"IsRefresh":1,"JavaEnable":1,"JavascriptEnable":1,"LocalEventTime":1382597969779,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":4,"NetMinor":8,"OS":6,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://news.net/article","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article","RefererCategoryID":3,"RefererHash":8412775606973326503,"RefererRegionID":50,"RegionID":131,"RemoteIP":1609908305,"ResolutionDepth":24,"ResolutionHeight":1084,"ResolutionWidth":2263,"ResponseEndTiming":62,"ResponseStartTiming":140,"Robotness":0,"SearchEngineID":14,"SearchPhrase":"","SendTiming":453,"Sex":2,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Product List","TraficSourceID":7,"URL":"https://shop.io/product","URLCategoryID":11,"URLHash":1468111145634639481,"URLRegionID":168,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":4,"UserAgentMajor":92,"UserAgentMinor":"89","UserID":4102193423840591337,"WatchID":2027411064773594575,"WindowClientHeight":1192,"WindowClientWidth":623,"WindowName":0,"WithHash":1} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":62,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-07-14 00:00:00","EventTime":"2013-07-14 10:00:15","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":1,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":0,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Q42 Title A","TraficSourceID":-1,"URL":"https://test.org/q42-page-0","URLCategoryID":1,"URLHash":2868770270353813622,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":1,"WatchID":1,"WindowClientHeight":700,"WindowClientWidth":1000,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":2,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":62,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-07-14 00:00:00","EventTime":"2013-07-14 10:02:15","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":1,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":5,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Q42 Title B","TraficSourceID":1,"URL":"https://test.org/q42-page-1","URLCategoryID":1,"URLHash":2868770270353813622,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":1,"WatchID":1,"WindowClientHeight":750,"WindowClientWidth":1100,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":2,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":62,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-07-14 00:00:00","EventTime":"2013-07-14 10:04:15","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":1,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":5,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Q42 Title C","TraficSourceID":-1,"URL":"https://test.org/q42-page-2","URLCategoryID":1,"URLHash":2868770270353813622,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":1,"WatchID":1,"WindowClientHeight":800,"WindowClientWidth":1200,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":62,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-07-14 00:00:00","EventTime":"2013-07-14 10:06:15","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":1,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":0,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Q42 Title D","TraficSourceID":1,"URL":"https://test.org/q42-page-3","URLCategoryID":1,"URLHash":2868770270353813622,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":1,"WatchID":1,"WindowClientHeight":850,"WindowClientWidth":1300,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":2,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":62,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-07-14 00:00:00","EventTime":"2013-07-14 10:08:15","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":1,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":5,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Q42 Title A","TraficSourceID":-1,"URL":"https://test.org/q42-page-4","URLCategoryID":1,"URLHash":2868770270353813622,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":1,"WatchID":1,"WindowClientHeight":700,"WindowClientWidth":1400,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":2,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":62,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-07-14 00:00:00","EventTime":"2013-07-14 10:10:15","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":1,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":5,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Q42 Title B","TraficSourceID":1,"URL":"https://test.org/q42-page-0","URLCategoryID":1,"URLHash":2868770270353813622,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":1,"WatchID":1,"WindowClientHeight":750,"WindowClientWidth":1500,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":62,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-07-14 00:00:00","EventTime":"2013-07-14 10:12:15","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":1,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":0,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Q42 Title C","TraficSourceID":-1,"URL":"https://test.org/q42-page-1","URLCategoryID":1,"URLHash":2868770270353813622,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":1,"WatchID":1,"WindowClientHeight":800,"WindowClientWidth":1000,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":2,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":62,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-07-14 00:00:00","EventTime":"2013-07-14 10:14:15","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":1,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":5,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Q42 Title D","TraficSourceID":1,"URL":"https://test.org/q42-page-2","URLCategoryID":1,"URLHash":2868770270353813622,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":1,"WatchID":1,"WindowClientHeight":850,"WindowClientWidth":1100,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":2,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":62,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-07-14 00:00:00","EventTime":"2013-07-14 10:16:15","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":1,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":5,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Q42 Title A","TraficSourceID":-1,"URL":"https://test.org/q42-page-3","URLCategoryID":1,"URLHash":2868770270353813622,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":1,"WatchID":1,"WindowClientHeight":700,"WindowClientWidth":1200,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":62,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-07-14 00:00:00","EventTime":"2013-07-14 10:18:15","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":1,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":0,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Q42 Title B","TraficSourceID":1,"URL":"https://test.org/q42-page-4","URLCategoryID":1,"URLHash":2868770270353813622,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":1,"WatchID":1,"WindowClientHeight":750,"WindowClientWidth":1300,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":2,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":62,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-07-14 00:00:00","EventTime":"2013-07-14 10:20:15","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":1,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":5,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Q42 Title C","TraficSourceID":-1,"URL":"https://test.org/q42-page-0","URLCategoryID":1,"URLHash":2868770270353813622,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":1,"WatchID":1,"WindowClientHeight":800,"WindowClientWidth":1400,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":2,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":62,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-07-14 00:00:00","EventTime":"2013-07-14 10:22:15","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":1,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":5,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Q42 Title D","TraficSourceID":1,"URL":"https://test.org/q42-page-1","URLCategoryID":1,"URLHash":2868770270353813622,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":1,"WatchID":1,"WindowClientHeight":850,"WindowClientWidth":1500,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":62,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-07-14 00:00:00","EventTime":"2013-07-14 11:00:00","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":1,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article-0","RefererCategoryID":1,"RefererHash":3594120000172545465,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":0,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Q41 Title 0","TraficSourceID":-1,"URL":"https://test.org/q41-page-0","URLCategoryID":1,"URLHash":4000,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":1,"WatchID":1,"WindowClientHeight":900,"WindowClientWidth":1500,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":62,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-07-14 00:00:00","EventTime":"2013-07-14 11:05:00","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":1,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article-1","RefererCategoryID":1,"RefererHash":3594120000172545465,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":0,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Q41 Title 1","TraficSourceID":6,"URL":"https://test.org/q41-page-1","URLCategoryID":1,"URLHash":4001,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":1,"WatchID":1,"WindowClientHeight":900,"WindowClientWidth":1500,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":62,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-07-14 00:00:00","EventTime":"2013-07-14 11:10:00","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":1,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article-2","RefererCategoryID":1,"RefererHash":3594120000172545465,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":0,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Q41 Title 2","TraficSourceID":-1,"URL":"https://test.org/q41-page-2","URLCategoryID":1,"URLHash":4002,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":1,"WatchID":1,"WindowClientHeight":900,"WindowClientWidth":1500,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":62,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-07-14 00:00:00","EventTime":"2013-07-14 11:15:00","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":1,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article-3","RefererCategoryID":1,"RefererHash":3594120000172545465,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":0,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Q41 Title 0","TraficSourceID":6,"URL":"https://test.org/q41-page-0","URLCategoryID":1,"URLHash":4003,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":1,"WatchID":1,"WindowClientHeight":900,"WindowClientWidth":1500,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":62,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-07-15 00:00:00","EventTime":"2013-07-15 11:20:00","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":1,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article-0","RefererCategoryID":1,"RefererHash":3594120000172545465,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":0,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Q41 Title 1","TraficSourceID":-1,"URL":"https://test.org/q41-page-1","URLCategoryID":1,"URLHash":4000,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":1,"WatchID":1,"WindowClientHeight":900,"WindowClientWidth":1500,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":62,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-07-15 00:00:00","EventTime":"2013-07-15 11:25:00","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":1,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article-1","RefererCategoryID":1,"RefererHash":3594120000172545465,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":0,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Q41 Title 2","TraficSourceID":6,"URL":"https://test.org/q41-page-2","URLCategoryID":1,"URLHash":4001,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":1,"WatchID":1,"WindowClientHeight":900,"WindowClientWidth":1500,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":62,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-07-15 00:00:00","EventTime":"2013-07-15 11:30:00","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":1,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article-2","RefererCategoryID":1,"RefererHash":3594120000172545465,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":0,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Q41 Title 0","TraficSourceID":-1,"URL":"https://test.org/q41-page-0","URLCategoryID":1,"URLHash":4002,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":1,"WatchID":1,"WindowClientHeight":900,"WindowClientWidth":1500,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":62,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-07-15 00:00:00","EventTime":"2013-07-15 11:35:00","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":1,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article-3","RefererCategoryID":1,"RefererHash":3594120000172545465,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":0,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Q41 Title 1","TraficSourceID":6,"URL":"https://test.org/q41-page-1","URLCategoryID":1,"URLHash":4003,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":1,"WatchID":1,"WindowClientHeight":900,"WindowClientWidth":1500,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":62,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-07-15 00:00:00","EventTime":"2013-07-15 12:00:30","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":1,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article-0","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":0,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Page Title A","TraficSourceID":-1,"URL":"https://test.org/page-0","URLCategoryID":1,"URLHash":5000,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":1,"WatchID":1,"WindowClientHeight":1000,"WindowClientWidth":1600,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":62,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-07-15 00:00:00","EventTime":"2013-07-15 12:07:30","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":1,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article-1","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":0,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Page Title B","TraficSourceID":1,"URL":"https://test.org/page-1","URLCategoryID":1,"URLHash":5001,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":1,"WatchID":1,"WindowClientHeight":1000,"WindowClientWidth":1600,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":62,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-07-15 00:00:00","EventTime":"2013-07-15 12:14:30","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":1,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article-2","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":0,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Page Title C","TraficSourceID":-1,"URL":"https://test.org/page-2","URLCategoryID":1,"URLHash":5002,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":1,"WatchID":1,"WindowClientHeight":1000,"WindowClientWidth":1600,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":62,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-07-15 00:00:00","EventTime":"2013-07-15 12:21:30","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":1,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article-3","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":0,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Page Title D","TraficSourceID":1,"URL":"https://test.org/page-3","URLCategoryID":1,"URLHash":5003,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":1,"WatchID":1,"WindowClientHeight":1000,"WindowClientWidth":1600,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":62,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-07-15 00:00:00","EventTime":"2013-07-15 13:28:30","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":1,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article-0","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":0,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Page Title E","TraficSourceID":-1,"URL":"https://test.org/page-4","URLCategoryID":1,"URLHash":5004,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":1,"WatchID":1,"WindowClientHeight":1000,"WindowClientWidth":1600,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":62,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-07-15 00:00:00","EventTime":"2013-07-15 13:35:30","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":1,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article-1","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":0,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Page Title A","TraficSourceID":1,"URL":"https://test.org/page-5","URLCategoryID":1,"URLHash":5005,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":1,"WatchID":1,"WindowClientHeight":1000,"WindowClientWidth":1600,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":62,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-07-15 00:00:00","EventTime":"2013-07-15 13:42:30","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":1,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article-2","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":0,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Page Title B","TraficSourceID":-1,"URL":"https://test.org/page-0","URLCategoryID":1,"URLHash":5006,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":1,"WatchID":1,"WindowClientHeight":1000,"WindowClientWidth":1600,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":62,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-07-15 00:00:00","EventTime":"2013-07-15 13:49:30","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":1,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article-3","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":0,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Page Title C","TraficSourceID":1,"URL":"https://test.org/page-1","URLCategoryID":1,"URLHash":5007,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":1,"WatchID":1,"WindowClientHeight":1000,"WindowClientWidth":1600,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":62,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-07-15 00:00:00","EventTime":"2013-07-15 14:56:30","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":1,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article-0","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":0,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Page Title D","TraficSourceID":-1,"URL":"https://test.org/page-2","URLCategoryID":1,"URLHash":5008,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":1,"WatchID":1,"WindowClientHeight":1000,"WindowClientWidth":1600,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":62,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-07-15 00:00:00","EventTime":"2013-07-15 14:03:30","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":1,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article-1","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":0,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Page Title E","TraficSourceID":1,"URL":"https://test.org/page-3","URLCategoryID":1,"URLHash":5009,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":1,"WatchID":1,"WindowClientHeight":1000,"WindowClientWidth":1600,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":1000,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":99,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-08-01 00:00:00","EventTime":"2013-08-01 09:00:00","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":0,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":1,"MobilePhoneModel":"iPhone","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1280,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":1,"SearchPhrase":"opensearch tutorial","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Default Title","TraficSourceID":0,"URL":"https://test.org/home","URLCategoryID":1,"URLHash":1,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":100000,"WatchID":5000,"WindowClientHeight":800,"WindowClientWidth":1200,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":1001,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":99,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-08-01 00:00:00","EventTime":"2013-08-01 09:05:00","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":0,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":2,"MobilePhoneModel":"Pixel","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1290,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":2,"SearchPhrase":"opensearch tutorial","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Default Title","TraficSourceID":0,"URL":"https://test.org/home","URLCategoryID":1,"URLHash":1,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":100001,"WatchID":5001,"WindowClientHeight":800,"WindowClientWidth":1200,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":1002,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":99,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-08-01 00:00:00","EventTime":"2013-08-01 09:10:00","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":0,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":1,"MobilePhoneModel":"Galaxy","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1300,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":1,"SearchPhrase":"opensearch tutorial","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Default Title","TraficSourceID":0,"URL":"https://test.org/home","URLCategoryID":1,"URLHash":1,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":100002,"WatchID":5002,"WindowClientHeight":800,"WindowClientWidth":1200,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":1000,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":99,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-08-01 00:00:00","EventTime":"2013-08-01 09:15:00","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":0,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":2,"MobilePhoneModel":"iPhone","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1310,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":2,"SearchPhrase":"clickbench schema","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Default Title","TraficSourceID":0,"URL":"https://test.org/google-test","URLCategoryID":1,"URLHash":1,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":100003,"WatchID":5003,"WindowClientHeight":800,"WindowClientWidth":1200,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":1001,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":99,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-08-01 00:00:00","EventTime":"2013-08-01 09:20:00","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":0,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":1,"MobilePhoneModel":"Pixel","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1320,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":1,"SearchPhrase":"clickbench schema","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Default Title","TraficSourceID":0,"URL":"https://test.org/google-test","URLCategoryID":1,"URLHash":1,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":100000,"WatchID":5000,"WindowClientHeight":800,"WindowClientWidth":1200,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":1002,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":99,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-08-01 00:00:00","EventTime":"2013-08-01 09:25:00","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":0,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":2,"MobilePhoneModel":"Galaxy","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1330,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":2,"SearchPhrase":"data fusion","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Google Hits","TraficSourceID":0,"URL":"https://test.org/home","URLCategoryID":1,"URLHash":1,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":100001,"WatchID":5001,"WindowClientHeight":800,"WindowClientWidth":1200,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":1000,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":99,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-08-01 00:00:00","EventTime":"2013-08-01 09:30:00","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":0,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":1,"MobilePhoneModel":"iPhone","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1340,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":1,"SearchPhrase":"data fusion","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Google Hits","TraficSourceID":0,"URL":"https://test.org/home","URLCategoryID":1,"URLHash":1,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":100002,"WatchID":5002,"WindowClientHeight":800,"WindowClientWidth":1200,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":1001,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":99,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-08-01 00:00:00","EventTime":"2013-08-01 09:35:00","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":0,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":2,"MobilePhoneModel":"Pixel","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1350,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":2,"SearchPhrase":"data fusion","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Default Title","TraficSourceID":0,"URL":"https://test.org/home","URLCategoryID":1,"URLHash":1,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":100003,"WatchID":5003,"WindowClientHeight":800,"WindowClientWidth":1200,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":99,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-08-01 00:00:00","EventTime":"2013-08-01 12:00:00","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":0,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":0,"MobilePhoneModel":"","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":0,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Default Title","TraficSourceID":0,"URL":"https://test.org/home","URLCategoryID":1,"URLHash":1,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":435090932899640449,"WatchID":1,"WindowClientHeight":800,"WindowClientWidth":1200,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":99,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-08-01 00:00:00","EventTime":"2013-08-01 12:00:00","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":0,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":1,"MobilePhoneModel":"iPhone","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":0,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Default Title","TraficSourceID":0,"URL":"https://test.org/home","URLCategoryID":1,"URLHash":1,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":200000,"WatchID":1,"WindowClientHeight":800,"WindowClientWidth":1200,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":99,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-08-01 00:00:00","EventTime":"2013-08-01 12:00:00","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":0,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":2,"MobilePhoneModel":"Pixel","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":0,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Default Title","TraficSourceID":0,"URL":"https://test.org/home","URLCategoryID":1,"URLHash":1,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":200001,"WatchID":1,"WindowClientHeight":800,"WindowClientWidth":1200,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":99,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-08-01 00:00:00","EventTime":"2013-08-01 12:00:00","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":0,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":3,"MobilePhoneModel":"Galaxy","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":0,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Default Title","TraficSourceID":0,"URL":"https://test.org/home","URLCategoryID":1,"URLHash":1,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":200002,"WatchID":1,"WindowClientHeight":800,"WindowClientWidth":1200,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":99,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-08-01 00:00:00","EventTime":"2013-08-01 12:00:00","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":0,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":4,"MobilePhoneModel":"OnePlus","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":0,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Default Title","TraficSourceID":0,"URL":"https://test.org/home","URLCategoryID":1,"URLHash":1,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":200003,"WatchID":1,"WindowClientHeight":800,"WindowClientWidth":1200,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":99,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-08-01 00:00:00","EventTime":"2013-08-01 12:00:00","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":0,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":5,"MobilePhoneModel":"Nokia","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":0,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Default Title","TraficSourceID":0,"URL":"https://test.org/home","URLCategoryID":1,"URLHash":1,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":200004,"WatchID":1,"WindowClientHeight":800,"WindowClientWidth":1200,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":99,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-08-01 00:00:00","EventTime":"2013-08-01 12:00:00","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":0,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":6,"MobilePhoneModel":"Sony","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":0,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Default Title","TraficSourceID":0,"URL":"https://test.org/home","URLCategoryID":1,"URLHash":1,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":200005,"WatchID":1,"WindowClientHeight":800,"WindowClientWidth":1200,"WindowName":0,"WithHash":0} +{"index":{}} +{"AdvEngineID":0,"Age":30,"BrowserCountry":"US","BrowserLanguage":"en","CLID":100,"ClientEventTime":1373000000000,"ClientIP":100,"ClientTimeZone":0,"CodeVersion":100,"ConnectTiming":50,"CookieEnable":1,"CounterClass":1,"CounterID":99,"DNSTiming":60,"DontCountHits":0,"EventDate":"2013-08-01 00:00:00","EventTime":"2013-08-01 12:00:00","FUniqID":1,"FetchTiming":100,"FlashMajor":7,"FlashMinor":2,"FlashMinor2":0,"FromTag":"","GoodEvent":1,"HID":1,"HTTPError":0,"HasGCLID":0,"HistoryLength":1,"HitColor":"D","IPNetworkID":1,"Income":1,"Interests":1,"IsArtifical":0,"IsDownload":0,"IsEvent":0,"IsLink":0,"IsMobile":0,"IsNotBounce":0,"IsOldCounter":0,"IsParameter":0,"IsRefresh":0,"JavaEnable":0,"JavascriptEnable":1,"LocalEventTime":1373000000000,"MobilePhone":7,"MobilePhoneModel":"LG","NetMajor":2,"NetMinor":1,"OS":4,"OpenerName":0,"OpenstatAdID":"","OpenstatCampaignID":"","OpenstatServiceName":"","OpenstatSourceID":"","OriginalURL":"https://example.com","PageCharset":"UTF-8","ParamCurrency":"","ParamCurrencyID":0,"ParamOrderID":"","ParamPrice":0,"Params":"","Referer":"https://news.net/article","RefererCategoryID":1,"RefererHash":1,"RefererRegionID":1,"RegionID":1,"RemoteIP":1,"ResolutionDepth":24,"ResolutionHeight":1080,"ResolutionWidth":1920,"ResponseEndTiming":100,"ResponseStartTiming":50,"Robotness":0,"SearchEngineID":0,"SearchPhrase":"","SendTiming":50,"Sex":1,"SilverlightVersion1":0,"SilverlightVersion2":0,"SilverlightVersion3":0,"SilverlightVersion4":0,"SocialSourceNetworkID":0,"SocialSourcePage":"","Title":"Default Title","TraficSourceID":0,"URL":"https://test.org/home","URLCategoryID":1,"URLHash":1,"URLRegionID":1,"UTMCampaign":"","UTMContent":"","UTMMedium":"","UTMSource":"","UTMTerm":"","UserAgent":5,"UserAgentMajor":100,"UserAgentMinor":"0","UserID":200006,"WatchID":1,"WindowClientHeight":800,"WindowClientWidth":1200,"WindowName":0,"WithHash":0} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q1.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q1.json index b20d706c405cd..d46b42a6dabbb 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q1.json +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q1.json @@ -1,5 +1,7 @@ { "rows": [ - [100] + [ + 146 + ] ] } diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q10.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q10.json new file mode 100644 index 0000000000000..4cf27a8ebe002 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q10.json @@ -0,0 +1,74 @@ +{ + "rows": [ + [ + 36, + 47, + 1804.6170212765958, + 14, + 1 + ], + [ + 17, + 3, + 1483.0, + 3, + 53 + ], + [ + 33, + 3, + 1664.6666666666667, + 3, + 127 + ], + [ + 12, + 2, + 1746.0, + 2, + 8 + ], + [ + 21, + 2, + 1481.5, + 2, + 29 + ], + [ + 29, + 2, + 1504.0, + 2, + 68 + ], + [ + 19, + 2, + 1691.0, + 2, + 76 + ], + [ + 13, + 2, + 1592.0, + 2, + 125 + ], + [ + 32, + 2, + 1594.5, + 2, + 169 + ], + [ + 40, + 2, + 1171.0, + 2, + 174 + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q11.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q11.json new file mode 100644 index 0000000000000..1ba95a0416556 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q11.json @@ -0,0 +1,32 @@ +{ + "rows": [ + [ + 4, + "iPhone" + ], + [ + 4, + "Pixel" + ], + [ + 3, + "Galaxy" + ], + [ + 1, + "Nokia" + ], + [ + 1, + "Sony" + ], + [ + 1, + "LG" + ], + [ + 1, + "OnePlus" + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q12.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q12.json new file mode 100644 index 0000000000000..2d10eda024c77 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q12.json @@ -0,0 +1,54 @@ +{ + "rows": [ + [ + 3, + 1, + "iPhone" + ], + [ + 3, + 2, + "Pixel" + ], + [ + 1, + 1, + "Galaxy" + ], + [ + 1, + 1, + "Pixel" + ], + [ + 1, + 2, + "Galaxy" + ], + [ + 1, + 2, + "iPhone" + ], + [ + 1, + 3, + "Galaxy" + ], + [ + 1, + 4, + "OnePlus" + ], + [ + 1, + 5, + "Nokia" + ], + [ + 1, + 6, + "Sony" + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q13.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q13.json new file mode 100644 index 0000000000000..d6e33826fa9c9 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q13.json @@ -0,0 +1,16 @@ +{ + "rows": [ + [ + 3, + "data fusion" + ], + [ + 3, + "opensearch tutorial" + ], + [ + 2, + "clickbench schema" + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q14.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q14.json new file mode 100644 index 0000000000000..d6e33826fa9c9 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q14.json @@ -0,0 +1,16 @@ +{ + "rows": [ + [ + 3, + "data fusion" + ], + [ + 3, + "opensearch tutorial" + ], + [ + 2, + "clickbench schema" + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q15.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q15.json new file mode 100644 index 0000000000000..68ed68198d5e3 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q15.json @@ -0,0 +1,34 @@ +{ + "rows": [ + [ + 2, + 1, + "opensearch tutorial" + ], + [ + 2, + 2, + "data fusion" + ], + [ + 1, + 1, + "clickbench schema" + ], + [ + 1, + 1, + "data fusion" + ], + [ + 1, + 2, + "clickbench schema" + ], + [ + 1, + 2, + "opensearch tutorial" + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q16.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q16.json new file mode 100644 index 0000000000000..ab349a9f13284 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q16.json @@ -0,0 +1,44 @@ +{ + "rows": [ + [ + 30, + 1 + ], + [ + 2, + 100000 + ], + [ + 2, + 100001 + ], + [ + 2, + 100002 + ], + [ + 2, + 100003 + ], + [ + 1, + 200000 + ], + [ + 1, + 200001 + ], + [ + 1, + 200002 + ], + [ + 1, + 200003 + ], + [ + 1, + 200004 + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q17.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q17.json new file mode 100644 index 0000000000000..e3fe4ad45a58a --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q17.json @@ -0,0 +1,54 @@ +{ + "rows": [ + [ + 30, + 1, + "" + ], + [ + 1, + 100000, + "clickbench schema" + ], + [ + 1, + 100000, + "opensearch tutorial" + ], + [ + 1, + 100001, + "data fusion" + ], + [ + 1, + 100001, + "opensearch tutorial" + ], + [ + 1, + 100002, + "data fusion" + ], + [ + 1, + 100002, + "opensearch tutorial" + ], + [ + 1, + 100003, + "clickbench schema" + ], + [ + 1, + 100003, + "data fusion" + ], + [ + 1, + 200000, + "" + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q18.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q18.json new file mode 100644 index 0000000000000..e3fe4ad45a58a --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q18.json @@ -0,0 +1,54 @@ +{ + "rows": [ + [ + 30, + 1, + "" + ], + [ + 1, + 100000, + "clickbench schema" + ], + [ + 1, + 100000, + "opensearch tutorial" + ], + [ + 1, + 100001, + "data fusion" + ], + [ + 1, + 100001, + "opensearch tutorial" + ], + [ + 1, + 100002, + "data fusion" + ], + [ + 1, + 100002, + "opensearch tutorial" + ], + [ + 1, + 100003, + "clickbench schema" + ], + [ + 1, + 100003, + "data fusion" + ], + [ + 1, + 200000, + "" + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q19.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q19.json new file mode 100644 index 0000000000000..6f451ac154682 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q19.json @@ -0,0 +1,64 @@ +{ + "rows": [ + [ + 3, + 1, + 0, + "" + ], + [ + 2, + 1, + 10, + "" + ], + [ + 2, + 1, + 14, + "" + ], + [ + 2, + 1, + 20, + "" + ], + [ + 2, + 1, + 35, + "" + ], + [ + 1, + 1, + 2, + "" + ], + [ + 1, + 1, + 3, + "" + ], + [ + 1, + 1, + 4, + "" + ], + [ + 1, + 1, + 5, + "" + ], + [ + 1, + 1, + 6, + "" + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q2.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q2.json index 5b0b781a851b9..e55992a8eea68 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q2.json +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q2.json @@ -1,5 +1,7 @@ { "rows": [ - [93] + [ + 101 + ] ] } diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q20.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q20.json new file mode 100644 index 0000000000000..fb0169f788966 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q20.json @@ -0,0 +1,7 @@ +{ + "rows": [ + [ + 435090932899640449 + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q21.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q21.json new file mode 100644 index 0000000000000..5ae4abd3ff42b --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q21.json @@ -0,0 +1,7 @@ +{ + "rows": [ + [ + 2 + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q22.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q22.json new file mode 100644 index 0000000000000..e0c4d1472530a --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q22.json @@ -0,0 +1,8 @@ +{ + "rows": [ + [ + 2, + "clickbench schema" + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q23.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q23.json new file mode 100644 index 0000000000000..31bb036912bf0 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q23.json @@ -0,0 +1,9 @@ +{ + "rows": [ + [ + 2, + 2, + "data fusion" + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q24.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q24.json new file mode 100644 index 0000000000000..2d0815b7c9d06 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q24.json @@ -0,0 +1,214 @@ +{ + "rows": [ + [ + 0, + 30, + "US", + "en", + 100, + "2013-07-05 04:53:20", + 1000, + 0, + 100, + 50, + 1, + 1, + 99, + 60, + 0, + "2013-08-01 00:00:00", + "2013-08-01 09:15:00", + 1, + 100, + 7, + 2, + 0, + "", + 1, + 1, + 0, + 0, + 1, + "D", + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + "2013-07-05 04:53:20", + 2, + "iPhone", + 2, + 1, + 4, + 0, + "", + "", + "", + "", + "https://example.com", + "UTF-8", + "", + 0, + "", + 0, + "", + "https://news.net/article", + 1, + 1, + 1, + 1, + 1, + 24, + 1080, + 1310, + 100, + 50, + 0, + 2, + "clickbench schema", + 50, + 1, + 0, + 0, + 0, + 0, + 0, + "", + "Default Title", + 0, + "https://test.org/google-test", + 1, + 1, + 1, + "", + "", + "", + "", + "", + 5, + 100, + "0", + 100003, + 5003, + 800, + 1200, + 0, + 0 + ], + [ + 0, + 30, + "US", + "en", + 100, + "2013-07-05 04:53:20", + 1001, + 0, + 100, + 50, + 1, + 1, + 99, + 60, + 0, + "2013-08-01 00:00:00", + "2013-08-01 09:20:00", + 1, + 100, + 7, + 2, + 0, + "", + 1, + 1, + 0, + 0, + 1, + "D", + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + "2013-07-05 04:53:20", + 1, + "Pixel", + 2, + 1, + 4, + 0, + "", + "", + "", + "", + "https://example.com", + "UTF-8", + "", + 0, + "", + 0, + "", + "https://news.net/article", + 1, + 1, + 1, + 1, + 1, + 24, + 1080, + 1320, + 100, + 50, + 0, + 1, + "clickbench schema", + 50, + 1, + 0, + 0, + 0, + 0, + 0, + "", + "Default Title", + 0, + "https://test.org/google-test", + 1, + 1, + 1, + "", + "", + "", + "", + "", + 5, + 100, + "0", + 100000, + 5000, + 800, + 1200, + 0, + 0 + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q25.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q25.json new file mode 100644 index 0000000000000..618ad6e29f5e1 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q25.json @@ -0,0 +1,28 @@ +{ + "rows": [ + [ + "opensearch tutorial" + ], + [ + "opensearch tutorial" + ], + [ + "opensearch tutorial" + ], + [ + "clickbench schema" + ], + [ + "clickbench schema" + ], + [ + "data fusion" + ], + [ + "data fusion" + ], + [ + "data fusion" + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q26.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q26.json new file mode 100644 index 0000000000000..2596b6cb5ee26 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q26.json @@ -0,0 +1,28 @@ +{ + "rows": [ + [ + "clickbench schema" + ], + [ + "clickbench schema" + ], + [ + "data fusion" + ], + [ + "data fusion" + ], + [ + "data fusion" + ], + [ + "opensearch tutorial" + ], + [ + "opensearch tutorial" + ], + [ + "opensearch tutorial" + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q27.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q27.json new file mode 100644 index 0000000000000..618ad6e29f5e1 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q27.json @@ -0,0 +1,28 @@ +{ + "rows": [ + [ + "opensearch tutorial" + ], + [ + "opensearch tutorial" + ], + [ + "opensearch tutorial" + ], + [ + "clickbench schema" + ], + [ + "clickbench schema" + ], + [ + "data fusion" + ], + [ + "data fusion" + ], + [ + "data fusion" + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q28.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q28.json new file mode 100644 index 0000000000000..9bbb1a05a6ede --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q28.json @@ -0,0 +1,14 @@ +{ + "rows": [ + [ + 25.666666666666668, + 30, + 62 + ], + [ + 21.875, + 16, + 99 + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q29.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q29.json new file mode 100644 index 0000000000000..1f8e5657e242f --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q29.json @@ -0,0 +1,34 @@ +{ + "rows": [ + [ + 25.0, + 31, + "https://example.com/page1", + "example.com" + ], + [ + 24.654545454545456, + 55, + "https://news.net/article", + "news.net" + ], + [ + 23.0, + 16, + "https://shop.io/product", + "shop.io" + ], + [ + 21.0, + 16, + "https://test.org/home", + "test.org" + ], + [ + 16.0, + 12, + "https://news.net", + "https://news.net" + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q3.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q3.json new file mode 100644 index 0000000000000..3eac8dffc78f1 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q3.json @@ -0,0 +1,9 @@ +{ + "rows": [ + [ + 1385, + 146, + 1689.123287671233 + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q30.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q30.json new file mode 100644 index 0000000000000..c2ebbdc026973 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q30.json @@ -0,0 +1,16 @@ +{ + "rows": [ + [ + 246612, + 246758, + 246904, + 247050, + 247196, + 247342, + 247488, + 247634, + 247780, + 247926 + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q31.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q31.json new file mode 100644 index 0000000000000..f1ae14f7ca73b --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q31.json @@ -0,0 +1,46 @@ +{ + "rows": [ + [ + 2, + 0, + 1310.0, + 1, + 1000 + ], + [ + 2, + 0, + 1320.0, + 2, + 1001 + ], + [ + 1, + 0, + 1320.0, + 1, + 1001 + ], + [ + 1, + 0, + 1300.0, + 1, + 1002 + ], + [ + 1, + 0, + 1310.0, + 2, + 1000 + ], + [ + 1, + 0, + 1330.0, + 2, + 1002 + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q32.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q32.json new file mode 100644 index 0000000000000..5b8736412c6a1 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q32.json @@ -0,0 +1,60 @@ +{ + "rows": [ + [ + 1, + 0, + 1330.0, + 5001, + 1002 + ], + [ + 1, + 0, + 1340.0, + 5002, + 1000 + ], + [ + 1, + 0, + 1350.0, + 5003, + 1001 + ], + [ + 1, + 0, + 1300.0, + 5002, + 1002 + ], + [ + 1, + 0, + 1310.0, + 5003, + 1000 + ], + [ + 1, + 0, + 1290.0, + 5001, + 1001 + ], + [ + 1, + 0, + 1320.0, + 5000, + 1001 + ], + [ + 1, + 0, + 1280.0, + 5000, + 1000 + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q33.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q33.json new file mode 100644 index 0000000000000..96c24c9d94fa0 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q33.json @@ -0,0 +1,74 @@ +{ + "rows": [ + [ + 38, + 0, + 1920.0, + 1, + 100 + ], + [ + 1, + 0, + 1280.0, + 5000, + 1000 + ], + [ + 1, + 0, + 1320.0, + 5000, + 1001 + ], + [ + 1, + 0, + 1290.0, + 5001, + 1001 + ], + [ + 1, + 0, + 1330.0, + 5001, + 1002 + ], + [ + 1, + 0, + 1340.0, + 5002, + 1000 + ], + [ + 1, + 0, + 1300.0, + 5002, + 1002 + ], + [ + 1, + 0, + 1310.0, + 5003, + 1000 + ], + [ + 1, + 0, + 1350.0, + 5003, + 1001 + ], + [ + 1, + 0, + 1535.0, + 66535906249598527, + 1505087744 + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q34.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q34.json new file mode 100644 index 0000000000000..7529920a9c6d3 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q34.json @@ -0,0 +1,44 @@ +{ + "rows": [ + [ + 30, + "https://test.org/home" + ], + [ + 20, + "https://example.com/page2" + ], + [ + 18, + "" + ], + [ + 16, + "https://shop.io/product" + ], + [ + 15, + "https://example.com/page1" + ], + [ + 15, + "https://news.net/article" + ], + [ + 3, + "https://test.org/q41-page-0" + ], + [ + 3, + "https://test.org/q41-page-1" + ], + [ + 3, + "https://test.org/q42-page-0" + ], + [ + 3, + "https://test.org/q42-page-1" + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q35.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q35.json new file mode 100644 index 0000000000000..24277874358d6 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q35.json @@ -0,0 +1,54 @@ +{ + "rows": [ + [ + 30, + 1, + "https://test.org/home" + ], + [ + 20, + 1, + "https://example.com/page2" + ], + [ + 18, + 1, + "" + ], + [ + 16, + 1, + "https://shop.io/product" + ], + [ + 15, + 1, + "https://example.com/page1" + ], + [ + 15, + 1, + "https://news.net/article" + ], + [ + 3, + 1, + "https://test.org/q42-page-1" + ], + [ + 3, + 1, + "https://test.org/q42-page-0" + ], + [ + 3, + 1, + "https://test.org/q41-page-1" + ], + [ + 3, + 1, + "https://test.org/q41-page-0" + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q36.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q36.json new file mode 100644 index 0000000000000..d5375ec3b06e7 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q36.json @@ -0,0 +1,74 @@ +{ + "rows": [ + [ + 38, + 100, + 99, + 98, + 97 + ], + [ + 3, + 1000, + 999, + 998, + 997 + ], + [ + 3, + 1001, + 1000, + 999, + 998 + ], + [ + 2, + 1002, + 1001, + 1000, + 999 + ], + [ + 1, + 10748715, + 10748714, + 10748713, + 10748712 + ], + [ + 1, + 19373410, + 19373409, + 19373408, + 19373407 + ], + [ + 1, + 84011081, + 84011080, + 84011079, + 84011078 + ], + [ + 1, + 195952743, + 195952742, + 195952741, + 195952740 + ], + [ + 1, + 212432663, + 212432662, + 212432661, + 212432660 + ], + [ + 1, + 219244700, + 219244699, + 219244698, + 219244697 + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q37.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q37.json new file mode 100644 index 0000000000000..84cadbc7eefdc --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q37.json @@ -0,0 +1,44 @@ +{ + "rows": [ + [ + 3, + "https://test.org/q41-page-0" + ], + [ + 3, + "https://test.org/q41-page-1" + ], + [ + 3, + "https://test.org/q42-page-0" + ], + [ + 3, + "https://test.org/q42-page-1" + ], + [ + 2, + "https://test.org/page-0" + ], + [ + 2, + "https://test.org/page-1" + ], + [ + 2, + "https://test.org/page-2" + ], + [ + 2, + "https://test.org/page-3" + ], + [ + 2, + "https://test.org/q41-page-2" + ], + [ + 2, + "https://test.org/q42-page-2" + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q38.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q38.json new file mode 100644 index 0000000000000..99bc802df7144 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q38.json @@ -0,0 +1,44 @@ +{ + "rows": [ + [ + 3, + "Q41 Title 0" + ], + [ + 3, + "Q41 Title 1" + ], + [ + 3, + "Q42 Title A" + ], + [ + 3, + "Q42 Title B" + ], + [ + 3, + "Q42 Title C" + ], + [ + 3, + "Q42 Title D" + ], + [ + 2, + "Page Title A" + ], + [ + 2, + "Page Title B" + ], + [ + 2, + "Page Title C" + ], + [ + 2, + "Page Title D" + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q39.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q39.json new file mode 100644 index 0000000000000..cb10e55729838 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q39.json @@ -0,0 +1,40 @@ +{ + "rows": [ + [ + 2, + "https://test.org/page-1" + ], + [ + 2, + "https://test.org/page-2" + ], + [ + 2, + "https://test.org/page-3" + ], + [ + 2, + "https://test.org/q41-page-2" + ], + [ + 2, + "https://test.org/q42-page-2" + ], + [ + 2, + "https://test.org/q42-page-3" + ], + [ + 2, + "https://test.org/q42-page-4" + ], + [ + 1, + "https://test.org/page-4" + ], + [ + 1, + "https://test.org/page-5" + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q4.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q4.json new file mode 100644 index 0000000000000..a60d07a37b03e --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q4.json @@ -0,0 +1,7 @@ +{ + "rows": [ + [ + 8109031835106455 + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q40.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q40.json new file mode 100644 index 0000000000000..e51b551c27581 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q40.json @@ -0,0 +1,84 @@ +{ + "rows": [ + [ + 1, + -1, + 0, + 0, + "https://news.net/article-0", + "https://test.org/page-4" + ], + [ + 1, + -1, + 0, + 0, + "https://news.net/article-0", + "https://test.org/q41-page-0" + ], + [ + 1, + -1, + 0, + 0, + "https://news.net/article-0", + "https://test.org/q41-page-1" + ], + [ + 1, + -1, + 0, + 0, + "https://news.net/article-2", + "https://test.org/page-0" + ], + [ + 1, + -1, + 0, + 0, + "https://news.net/article-2", + "https://test.org/page-2" + ], + [ + 1, + -1, + 0, + 0, + "https://news.net/article-2", + "https://test.org/q41-page-0" + ], + [ + 1, + -1, + 0, + 0, + "https://news.net/article-2", + "https://test.org/q41-page-2" + ], + [ + 1, + -1, + 5, + 2, + "", + "https://test.org/q42-page-0" + ], + [ + 1, + -1, + 5, + 2, + "", + "https://test.org/q42-page-2" + ], + [ + 1, + -1, + 5, + 2, + "", + "https://test.org/q42-page-3" + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q41.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q41.json new file mode 100644 index 0000000000000..5ec4392680b43 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q41.json @@ -0,0 +1,34 @@ +{ + "rows": [ + [ + 1, + 4001, + "2013-07-14 00:00:00" + ], + [ + 1, + 4001, + "2013-07-15 00:00:00" + ], + [ + 1, + 4002, + "2013-07-14 00:00:00" + ], + [ + 1, + 4002, + "2013-07-15 00:00:00" + ], + [ + 1, + 4003, + "2013-07-14 00:00:00" + ], + [ + 1, + 4003, + "2013-07-15 00:00:00" + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q42.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q42.json new file mode 100644 index 0000000000000..10205f830bf55 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q42.json @@ -0,0 +1,39 @@ +{ + "rows": [ + [ + 1, + 1200, + 800 + ], + [ + 1, + 1300, + 750 + ], + [ + 1, + 1300, + 850 + ], + [ + 1, + 1400, + 700 + ], + [ + 1, + 1400, + 800 + ], + [ + 1, + 1500, + 750 + ], + [ + 1, + 1500, + 850 + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q43.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q43.json new file mode 100644 index 0000000000000..e5f7980c63b95 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q43.json @@ -0,0 +1,44 @@ +{ + "rows": [ + [ + 1, + "2013-07-14 10:10:00" + ], + [ + 1, + "2013-07-14 10:12:00" + ], + [ + 1, + "2013-07-14 10:14:00" + ], + [ + 1, + "2013-07-14 10:16:00" + ], + [ + 1, + "2013-07-14 10:18:00" + ], + [ + 1, + "2013-07-14 10:20:00" + ], + [ + 1, + "2013-07-14 10:22:00" + ], + [ + 1, + "2013-07-14 11:00:00" + ], + [ + 1, + "2013-07-14 11:05:00" + ], + [ + 1, + "2013-07-14 11:10:00" + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q5.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q5.json new file mode 100644 index 0000000000000..275a62d0bf55f --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q5.json @@ -0,0 +1,7 @@ +{ + "rows": [ + [ + 112 + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q6.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q6.json new file mode 100644 index 0000000000000..ebcbd002de361 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q6.json @@ -0,0 +1,7 @@ +{ + "rows": [ + [ + 4 + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q7.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q7.json new file mode 100644 index 0000000000000..9ff2572f4a043 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q7.json @@ -0,0 +1,8 @@ +{ + "rows": [ + [ + "2013-07-14 00:00:00", + "2014-07-11 18:38:59.3" + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q8.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q8.json new file mode 100644 index 0000000000000..c18c378339069 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q8.json @@ -0,0 +1,120 @@ +{ + "rows": [ + [ + 11, + 2 + ], + [ + 6, + 15 + ], + [ + 6, + 21 + ], + [ + 6, + 24 + ], + [ + 5, + 4 + ], + [ + 5, + 8 + ], + [ + 5, + 13 + ], + [ + 5, + 19 + ], + [ + 4, + 5 + ], + [ + 4, + 23 + ], + [ + 3, + 1 + ], + [ + 3, + 7 + ], + [ + 3, + 9 + ], + [ + 3, + 10 + ], + [ + 3, + 14 + ], + [ + 3, + 17 + ], + [ + 3, + 27 + ], + [ + 2, + 3 + ], + [ + 2, + 6 + ], + [ + 2, + 11 + ], + [ + 2, + 12 + ], + [ + 2, + 18 + ], + [ + 2, + 20 + ], + [ + 2, + 22 + ], + [ + 2, + 25 + ], + [ + 2, + 26 + ], + [ + 2, + 28 + ], + [ + 2, + 29 + ], + [ + 1, + 16 + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q9.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q9.json new file mode 100644 index 0000000000000..b4fae23926f48 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/expected/q9.json @@ -0,0 +1,44 @@ +{ + "rows": [ + [ + 14, + 1 + ], + [ + 3, + 53 + ], + [ + 3, + 127 + ], + [ + 2, + 8 + ], + [ + 2, + 29 + ], + [ + 2, + 68 + ], + [ + 2, + 76 + ], + [ + 2, + 125 + ], + [ + 2, + 169 + ], + [ + 2, + 174 + ] + ] +} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q10.ppl b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q10.ppl index a7d0c198dbca7..9eebc0c4e371d 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q10.ppl +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q10.ppl @@ -1 +1 @@ -source = clickbench | stats sum(AdvEngineID), count() as c, avg(ResolutionWidth), dc(UserID) by RegionID | sort - c | head 10 +source = clickbench | stats sum(AdvEngineID), count() as c, avg(ResolutionWidth), dc(UserID) by RegionID | sort - c, RegionID | head 10 diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q12.ppl b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q12.ppl index b33534923fe2f..0c92e4dcb81b4 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q12.ppl +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q12.ppl @@ -1 +1 @@ -source = clickbench | where MobilePhoneModel != '' | stats dc(UserID) as u by MobilePhone, MobilePhoneModel | sort - u | head 10 +source = clickbench | where MobilePhoneModel != '' | stats dc(UserID) as u by MobilePhone, MobilePhoneModel | sort - u, MobilePhone, MobilePhoneModel | head 10 diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q15.ppl b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q15.ppl index ff6c5c5f9eb07..28098b1878615 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q15.ppl +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q15.ppl @@ -1 +1 @@ -source = clickbench | where SearchPhrase != '' | stats count() as c by SearchEngineID, SearchPhrase | sort - c | head 10 +source = clickbench | where SearchPhrase != '' | stats count() as c by SearchEngineID, SearchPhrase | sort - c, SearchEngineID, SearchPhrase | head 10 diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q16.ppl b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q16.ppl index 157e75680e1b1..739e54c75d286 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q16.ppl +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q16.ppl @@ -1 +1 @@ -source = clickbench | stats count() by UserID | sort - `count()` | head 10 +source = clickbench | stats count() by UserID | sort - `count()`, UserID | head 10 diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q17.ppl b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q17.ppl index 0ad47efdd3693..df22b10ab127d 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q17.ppl +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q17.ppl @@ -1 +1 @@ -source = clickbench | stats count() by UserID, SearchPhrase | sort - `count()` | head 10 +source = clickbench | stats count() by UserID, SearchPhrase | sort - `count()`, UserID, SearchPhrase | head 10 diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q18.ppl b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q18.ppl index 03f06e60e3259..ade1b99a01cb2 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q18.ppl +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q18.ppl @@ -1 +1 @@ -source = clickbench | stats count() by UserID, SearchPhrase | head 10 +source = clickbench | stats count() by UserID, SearchPhrase | sort UserID, SearchPhrase | head 10 diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q19.ppl b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q19.ppl index ac7c3cc785ac6..0537d77917d76 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q19.ppl +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q19.ppl @@ -1 +1 @@ -source = clickbench | eval m = extract(minute from EventTime) | stats count() by UserID, m, SearchPhrase | sort - `count()` | head 10 +source = clickbench | eval m = extract(minute from EventTime) | stats count() by UserID, m, SearchPhrase | sort - `count()`, UserID, m, SearchPhrase | head 10 diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q28.ppl b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q28.ppl index c93dd211ab90f..f2e60c993ac5a 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q28.ppl +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q28.ppl @@ -1 +1 @@ -source = clickbench | where URL != '' | stats avg(length(URL)) as l, count() as c by CounterID | where c > 100000 | sort - l | head 25 +source = clickbench | where URL != '' | stats avg(length(URL)) as l, count() as c by CounterID | where c > 5 | sort - l | head 25 diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q29.ppl b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q29.ppl index d0f042ef1ef6c..0d3b6d524d502 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q29.ppl +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q29.ppl @@ -1 +1 @@ -source = clickbench | where Referer != '' | eval k = regexp_replace(Referer, '^https?://(?:www\\.)?([^/]+)/.*$', '\\1') | stats avg(length(Referer)) as l, count() as c, min(Referer) by k | where c > 100000 | sort - l | head 25 +source = clickbench | where Referer != '' | eval k = regexp_replace(Referer, '^https?://(?:www\\.)?([^/]+)/.*$', '\\1') | stats avg(length(Referer)) as l, count() as c, min(Referer) by k | where c > 5 | sort - l | head 25 diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q31.ppl b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q31.ppl index 537ec1565bba3..e8541c9b82304 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q31.ppl +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q31.ppl @@ -1 +1 @@ -source = clickbench | where SearchPhrase != '' | stats count() as c, sum(IsRefresh), avg(ResolutionWidth) by SearchEngineID, ClientIP | sort - c | head 10 +source = clickbench | where SearchPhrase != '' | stats count() as c, sum(IsRefresh), avg(ResolutionWidth) by SearchEngineID, ClientIP | sort - c, SearchEngineID, ClientIP | head 10 diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q33.ppl b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q33.ppl index 5cadfab7ef0b7..78ad04cebca2d 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q33.ppl +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q33.ppl @@ -1 +1 @@ -source = clickbench | stats count() as c, sum(IsRefresh), avg(ResolutionWidth) by WatchID, ClientIP | sort - c | head 10 +source = clickbench | stats count() as c, sum(IsRefresh), avg(ResolutionWidth) by WatchID, ClientIP | sort - c, WatchID, ClientIP | head 10 diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q34.ppl b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q34.ppl index f7f147accb219..31fd74dd3a271 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q34.ppl +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q34.ppl @@ -1 +1 @@ -source = clickbench | stats count() as c by URL | sort - c | head 10 +source = clickbench | stats count() as c by URL | sort - c, URL | head 10 diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q36.ppl b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q36.ppl index f9d633e4f5117..5e544a1e54c48 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q36.ppl +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q36.ppl @@ -1 +1 @@ -source = clickbench | eval `ClientIP - 1` = ClientIP - 1, `ClientIP - 2` = ClientIP - 2, `ClientIP - 3` = ClientIP - 3 | stats count() as c by ClientIP, `ClientIP - 1`, `ClientIP - 2`, `ClientIP - 3` | sort - c | head 10 +source = clickbench | eval `ClientIP - 1` = ClientIP - 1, `ClientIP - 2` = ClientIP - 2, `ClientIP - 3` = ClientIP - 3 | stats count() as c by ClientIP, `ClientIP - 1`, `ClientIP - 2`, `ClientIP - 3` | sort - c, ClientIP | head 10 diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q37.ppl b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q37.ppl index 0e7e8563285a1..6dbaa807d0251 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q37.ppl +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q37.ppl @@ -1 +1 @@ -source = clickbench | where CounterID = 62 and EventDate >= '2013-07-01 00:00:00' and EventDate <= '2013-07-31 00:00:00' and DontCountHits = 0 and IsRefresh = 0 and URL != '' | stats count() as PageViews by URL | sort - PageViews | head 10 +source = clickbench | where CounterID = 62 and EventDate >= '2013-07-01' and EventDate <= '2013-07-31' and DontCountHits = 0 and IsRefresh = 0 and URL != '' | stats count() as PageViews by URL | sort - PageViews, URL | head 10 diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q38.ppl b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q38.ppl index ea48c98e2bd35..a75e767fe3525 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q38.ppl +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q38.ppl @@ -1 +1 @@ -source = clickbench | where CounterID = 62 and EventDate >= '2013-07-01 00:00:00' and EventDate <= '2013-07-31 00:00:00' and DontCountHits = 0 and IsRefresh = 0 and Title != '' | stats count() as PageViews by Title | sort - PageViews | head 10 +source = clickbench | where CounterID = 62 and EventDate >= '2013-07-01' and EventDate <= '2013-07-31' and DontCountHits = 0 and IsRefresh = 0 and Title != '' | stats count() as PageViews by Title | sort - PageViews, Title | head 10 diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q39.ppl b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q39.ppl index 32b2d3cc3f7b3..e0e21e498ee6d 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q39.ppl +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q39.ppl @@ -1 +1 @@ -source = clickbench | where CounterID = 62 and EventDate >= '2013-07-01 00:00:00' and EventDate <= '2013-07-31 00:00:00' and IsRefresh = 0 and IsLink != 0 and IsDownload = 0 | stats count() as PageViews by URL | sort - PageViews | head 10 from 1000 +source = clickbench | where CounterID = 62 and EventDate >= '2013-07-01' and EventDate <= '2013-07-31' and IsRefresh = 0 and IsLink != 0 and IsDownload = 0 | stats count() as PageViews by URL | sort - PageViews, URL | head 10 from 5 diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q40.ppl b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q40.ppl index 1327762ad3359..8df5c29cb4ee1 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q40.ppl +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q40.ppl @@ -1 +1 @@ -source = clickbench | where CounterID = 62 and EventDate >= '2013-07-01 00:00:00' and EventDate <= '2013-07-31 00:00:00' and IsRefresh = 0 | eval Src=case(SearchEngineID = 0 and AdvEngineID = 0, Referer else ''), Dst=URL | stats count() as PageViews by TraficSourceID, SearchEngineID, AdvEngineID, Src, Dst | sort - PageViews | head 10 from 1000 +source = clickbench | where CounterID = 62 and EventDate >= '2013-07-01' and EventDate <= '2013-07-31' and IsRefresh = 0 | eval Src=case(SearchEngineID = 0 and AdvEngineID = 0, Referer else ''), Dst=URL | stats count() as PageViews by TraficSourceID, SearchEngineID, AdvEngineID, Src, Dst | sort - PageViews, TraficSourceID, SearchEngineID, AdvEngineID, Src, Dst | head 10 from 5 diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q41.ppl b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q41.ppl index 17a373f376111..ff66476ad44d3 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q41.ppl +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q41.ppl @@ -1 +1 @@ -source = clickbench | where CounterID = 62 and EventDate >= '2013-07-01 00:00:00' and EventDate <= '2013-07-31 00:00:00' and IsRefresh = 0 and TraficSourceID in (-1, 6) and RefererHash = 3594120000172545465 | stats count() as PageViews by URLHash, EventDate | sort - PageViews | head 10 from 100 +source = clickbench | where CounterID = 62 and EventDate >= '2013-07-01' and EventDate <= '2013-07-31' and IsRefresh = 0 and TraficSourceID in (-1, 6) and RefererHash = 3594120000172545465 | stats count() as PageViews by URLHash, EventDate | sort - PageViews, URLHash, EventDate | head 10 from 2 diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q42.ppl b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q42.ppl index cff7ee534ad94..137ebc5b29f2d 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q42.ppl +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q42.ppl @@ -1 +1 @@ -source = clickbench | where CounterID = 62 and EventDate >= '2013-07-01 00:00:00' and EventDate <= '2013-07-31 00:00:00' and IsRefresh = 0 and DontCountHits = 0 and URLHash = 2868770270353813622 | stats count() as PageViews by WindowClientWidth, WindowClientHeight | sort - PageViews | head 10 from 10000 +source = clickbench | where CounterID = 62 and EventDate >= '2013-07-01' and EventDate <= '2013-07-31' and IsRefresh = 0 and DontCountHits = 0 and URLHash = 2868770270353813622 | stats count() as PageViews by WindowClientWidth, WindowClientHeight | sort - PageViews, WindowClientWidth, WindowClientHeight | head 10 from 5 diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q43.ppl b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q43.ppl index 990e3450fa713..3fcfa8fbb8f1a 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q43.ppl +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q43.ppl @@ -1 +1 @@ -source = clickbench | where CounterID = 62 and EventDate >= '2013-07-01 00:00:00' and EventDate <= '2013-07-15 00:00:00' and IsRefresh = 0 and DontCountHits = 0 | eval M = date_format(EventTime, '%Y-%m-%d %H:00:00') | stats count() as PageViews by M | sort M | head 10 from 1000 +source = clickbench | where CounterID = 62 and EventDate >= '2013-07-14' and EventDate <= '2013-07-15' and IsRefresh = 0 and DontCountHits = 0 | eval M = date_format(EventTime, '%Y-%m-%d %H:%i:00') | stats count() as PageViews by M | sort M | head 10 from 5 diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q8.ppl b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q8.ppl index 28c29067cd425..38bfd46f8ae27 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q8.ppl +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q8.ppl @@ -1 +1 @@ -source = clickbench | where AdvEngineID!=0 | stats count() by AdvEngineID | sort - `count()` +source = clickbench | where AdvEngineID!=0 | stats count() by AdvEngineID | sort - `count()`, AdvEngineID diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q9.ppl b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q9.ppl index ac5a40dc2ca06..a909d43141afc 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q9.ppl +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/clickbench/ppl/q9.ppl @@ -1 +1 @@ -source = clickbench | stats dc(UserID) as u by RegionID | sort -u | head 10 +source = clickbench | stats dc(UserID) as u by RegionID | sort -u, RegionID | head 10 diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/ip_multishard/bulk.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/ip_multishard/bulk.json index 5abc1a5eb4cfb..fa5e1ad36c833 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/ip_multishard/bulk.json +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/ip_multishard/bulk.json @@ -1,20 +1,20 @@ -{"index": {"_id": "0"}} +{"index": {}} {"id": 0, "client_ip": "10.0.0.0", "status_code": 200} -{"index": {"_id": "1"}} +{"index": {}} {"id": 1, "client_ip": "10.0.0.1", "status_code": 201} -{"index": {"_id": "2"}} +{"index": {}} {"id": 2, "client_ip": "10.0.0.2", "status_code": 202} -{"index": {"_id": "3"}} +{"index": {}} {"id": 3, "client_ip": "10.0.0.3", "status_code": 200} -{"index": {"_id": "4"}} +{"index": {}} {"id": 4, "client_ip": "10.0.0.4", "status_code": 201} -{"index": {"_id": "5"}} +{"index": {}} {"id": 5, "client_ip": "10.0.0.5", "status_code": 202} -{"index": {"_id": "6"}} +{"index": {}} {"id": 6, "client_ip": "10.0.0.6", "status_code": 200} -{"index": {"_id": "7"}} +{"index": {}} {"id": 7, "client_ip": "10.0.0.7", "status_code": 201} -{"index": {"_id": "8"}} +{"index": {}} {"id": 8, "client_ip": "10.0.0.8", "status_code": 202} -{"index": {"_id": "9"}} +{"index": {}} {"id": 9, "client_ip": "10.0.0.9", "status_code": 200} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/merge_coverage/bulk.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/merge_coverage/bulk.json index d19910527c170..df208340ce1ea 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/merge_coverage/bulk.json +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/merge_coverage/bulk.json @@ -1,60 +1,60 @@ -{"index": {"_id": "0"}} +{"index": {}} {"id": 0, "category": "A", "region": "east", "amount": 1, "price": 1.5, "label": "lbl1", "flag": false, "ts": "2024-01-01", "opt": 1, "payload": "{\"v\": 1}", "big_amount": 3000000000, "client_ip": "10.0.0.1", "opt_ts": "2024-01-01", "@timestamp": "2024-01-01"} -{"index": {"_id": "1"}} +{"index": {}} {"id": 1, "category": "A", "region": "west", "amount": 2, "price": 3.0, "label": "lbl2", "flag": true, "ts": "2024-01-02", "payload": "{\"v\": 2}", "big_amount": 6000000000, "@timestamp": "2024-01-02"} -{"index": {"_id": "2"}} +{"index": {}} {"id": 2, "category": "A", "region": "east", "amount": 3, "price": 4.5, "label": "lbl3", "flag": false, "ts": "2024-01-03", "opt": 3, "payload": "{\"v\": 3}", "big_amount": 9000000000, "client_ip": "192.168.1.1", "opt_ts": "2024-01-03", "@timestamp": "2024-01-03"} -{"index": {"_id": "3"}} +{"index": {}} {"id": 3, "category": "A", "region": "west", "amount": 4, "price": 6.0, "label": "lbl4", "flag": true, "ts": "2024-01-04", "payload": "{\"v\": 4}", "big_amount": 12000000000, "@timestamp": "2024-01-04"} -{"index": {"_id": "4"}} +{"index": {}} {"id": 4, "category": "A", "region": "east", "amount": 5, "price": 7.5, "label": "lbl0", "flag": false, "ts": "2024-01-05", "opt": 5, "payload": "{\"v\": 5}", "big_amount": 15000000000, "client_ip": "172.16.0.1", "opt_ts": "2024-01-05", "@timestamp": "2024-01-05"} -{"index": {"_id": "5"}} +{"index": {}} {"id": 5, "category": "A", "region": "west", "amount": 6, "price": 9.0, "label": "lbl1", "flag": true, "ts": "2024-01-06", "payload": "{\"v\": 6}", "big_amount": 18000000000, "@timestamp": "2024-01-06"} -{"index": {"_id": "6"}} +{"index": {}} {"id": 6, "category": "A", "region": "east", "amount": 7, "price": 10.5, "label": "lbl2", "flag": false, "ts": "2024-01-07", "opt": 7, "payload": "{\"v\": 7}", "big_amount": 21000000000, "client_ip": "10.0.0.2", "opt_ts": "2024-01-07", "@timestamp": "2024-01-07"} -{"index": {"_id": "7"}} +{"index": {}} {"id": 7, "category": "A", "region": "west", "amount": 8, "price": 12.0, "label": "lbl3", "flag": true, "ts": "2024-01-08", "payload": "{\"v\": 8}", "big_amount": 24000000000, "@timestamp": "2024-01-08"} -{"index": {"_id": "8"}} +{"index": {}} {"id": 8, "category": "A", "region": "east", "amount": 9, "price": 13.5, "label": "lbl4", "flag": false, "ts": "2024-01-09", "opt": 9, "payload": "{\"v\": 9}", "big_amount": 27000000000, "client_ip": "192.168.1.2", "opt_ts": "2024-01-09", "@timestamp": "2024-01-09"} -{"index": {"_id": "9"}} +{"index": {}} {"id": 9, "category": "A", "region": "west", "amount": 10, "price": 15.0, "label": "lbl0", "flag": true, "ts": "2024-01-10", "payload": "{\"v\": 10}", "big_amount": 30000000000, "@timestamp": "2024-01-10"} -{"index": {"_id": "10"}} +{"index": {}} {"id": 10, "category": "B", "region": "east", "amount": 11, "price": 16.5, "label": "lbl1", "flag": false, "ts": "2024-01-11", "opt": 11, "payload": "{\"v\": 11}", "big_amount": 33000000000, "client_ip": "10.0.0.1", "opt_ts": "2024-01-11", "@timestamp": "2024-01-11"} -{"index": {"_id": "11"}} +{"index": {}} {"id": 11, "category": "B", "region": "west", "amount": 12, "price": 18.0, "label": "lbl2", "flag": true, "ts": "2024-01-12", "payload": "{\"v\": 12}", "big_amount": 36000000000, "@timestamp": "2024-01-12"} -{"index": {"_id": "12"}} +{"index": {}} {"id": 12, "category": "B", "region": "east", "amount": 13, "price": 19.5, "label": "lbl3", "flag": false, "ts": "2024-01-13", "opt": 13, "payload": "{\"v\": 13}", "big_amount": 39000000000, "client_ip": "192.168.1.1", "opt_ts": "2024-01-13", "@timestamp": "2024-01-13"} -{"index": {"_id": "13"}} +{"index": {}} {"id": 13, "category": "B", "region": "west", "amount": 14, "price": 21.0, "label": "lbl4", "flag": true, "ts": "2024-01-14", "payload": "{\"v\": 14}", "big_amount": 42000000000, "@timestamp": "2024-01-14"} -{"index": {"_id": "14"}} +{"index": {}} {"id": 14, "category": "B", "region": "east", "amount": 15, "price": 22.5, "label": "lbl0", "flag": false, "ts": "2024-01-15", "opt": 15, "payload": "{\"v\": 15}", "big_amount": 45000000000, "client_ip": "172.16.0.1", "opt_ts": "2024-01-15", "@timestamp": "2024-01-15"} -{"index": {"_id": "15"}} +{"index": {}} {"id": 15, "category": "B", "region": "west", "amount": 16, "price": 24.0, "label": "lbl1", "flag": true, "ts": "2024-01-16", "payload": "{\"v\": 16}", "big_amount": 48000000000, "@timestamp": "2024-01-16"} -{"index": {"_id": "16"}} +{"index": {}} {"id": 16, "category": "B", "region": "east", "amount": 17, "price": 25.5, "label": "lbl2", "flag": false, "ts": "2024-01-17", "opt": 17, "payload": "{\"v\": 17}", "big_amount": 51000000000, "client_ip": "10.0.0.2", "opt_ts": "2024-01-17", "@timestamp": "2024-01-17"} -{"index": {"_id": "17"}} +{"index": {}} {"id": 17, "category": "B", "region": "west", "amount": 18, "price": 27.0, "label": "lbl3", "flag": true, "ts": "2024-01-18", "payload": "{\"v\": 18}", "big_amount": 54000000000, "@timestamp": "2024-01-18"} -{"index": {"_id": "18"}} +{"index": {}} {"id": 18, "category": "B", "region": "east", "amount": 19, "price": 28.5, "label": "lbl4", "flag": false, "ts": "2024-01-19", "opt": 19, "payload": "{\"v\": 19}", "big_amount": 57000000000, "client_ip": "192.168.1.2", "opt_ts": "2024-01-19", "@timestamp": "2024-01-19"} -{"index": {"_id": "19"}} +{"index": {}} {"id": 19, "category": "B", "region": "west", "amount": 20, "price": 30.0, "label": "lbl0", "flag": true, "ts": "2024-01-20", "payload": "{\"v\": 20}", "big_amount": 60000000000, "@timestamp": "2024-01-20"} -{"index": {"_id": "20"}} +{"index": {}} {"id": 20, "category": "C", "region": "east", "amount": 21, "price": 31.5, "label": "lbl1", "flag": false, "ts": "2024-01-21", "opt": 21, "payload": "{\"v\": 21}", "big_amount": 63000000000, "client_ip": "10.0.0.1", "opt_ts": "2024-01-21", "@timestamp": "2024-01-21"} -{"index": {"_id": "21"}} +{"index": {}} {"id": 21, "category": "C", "region": "west", "amount": 22, "price": 33.0, "label": "lbl2", "flag": true, "ts": "2024-01-22", "payload": "{\"v\": 22}", "big_amount": 66000000000, "@timestamp": "2024-01-22"} -{"index": {"_id": "22"}} +{"index": {}} {"id": 22, "category": "C", "region": "east", "amount": 23, "price": 34.5, "label": "lbl3", "flag": false, "ts": "2024-01-23", "opt": 23, "payload": "{\"v\": 23}", "big_amount": 69000000000, "client_ip": "192.168.1.1", "opt_ts": "2024-01-23", "@timestamp": "2024-01-23"} -{"index": {"_id": "23"}} +{"index": {}} {"id": 23, "category": "C", "region": "west", "amount": 24, "price": 36.0, "label": "lbl4", "flag": true, "ts": "2024-01-24", "payload": "{\"v\": 24}", "big_amount": 72000000000, "@timestamp": "2024-01-24"} -{"index": {"_id": "24"}} +{"index": {}} {"id": 24, "category": "C", "region": "east", "amount": 25, "price": 37.5, "label": "lbl0", "flag": false, "ts": "2024-01-25", "opt": 25, "payload": "{\"v\": 25}", "big_amount": 75000000000, "client_ip": "172.16.0.1", "opt_ts": "2024-01-25", "@timestamp": "2024-01-25"} -{"index": {"_id": "25"}} +{"index": {}} {"id": 25, "category": "C", "region": "west", "amount": 26, "price": 39.0, "label": "lbl1", "flag": true, "ts": "2024-01-26", "payload": "{\"v\": 26}", "big_amount": 78000000000, "@timestamp": "2024-01-26"} -{"index": {"_id": "26"}} +{"index": {}} {"id": 26, "category": "C", "region": "east", "amount": 27, "price": 40.5, "label": "lbl2", "flag": false, "ts": "2024-01-27", "opt": 27, "payload": "{\"v\": 27}", "big_amount": 81000000000, "client_ip": "10.0.0.2", "opt_ts": "2024-01-27", "@timestamp": "2024-01-27"} -{"index": {"_id": "27"}} +{"index": {}} {"id": 27, "category": "C", "region": "west", "amount": 28, "price": 42.0, "label": "lbl3", "flag": true, "ts": "2024-01-28", "payload": "{\"v\": 28}", "big_amount": 84000000000, "@timestamp": "2024-01-28"} -{"index": {"_id": "28"}} +{"index": {}} {"id": 28, "category": "C", "region": "east", "amount": 29, "price": 43.5, "label": "lbl4", "flag": false, "ts": "2024-01-29", "opt": 29, "payload": "{\"v\": 29}", "big_amount": 87000000000, "client_ip": "192.168.1.2", "opt_ts": "2024-01-29", "@timestamp": "2024-01-29"} -{"index": {"_id": "29"}} +{"index": {}} {"id": 29, "category": "C", "region": "west", "amount": 30, "price": 45.0, "label": "lbl0", "flag": true, "ts": "2024-01-30", "payload": "{\"v\": 30}", "big_amount": 90000000000, "@timestamp": "2024-01-30"} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/object_fields/bulk.json b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/object_fields/bulk.json index 323018b2c91be..b29e9a9b1ed86 100644 --- a/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/object_fields/bulk.json +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/datasets/object_fields/bulk.json @@ -1,7 +1,7 @@ -{"index": {"_id": "1"}} +{"index": {}} {"id": "1", "city": {"name": "Seattle", "population": 750000, "location": {"latitude": 47.6062, "longitude": -122.3321}}, "account": {"owner": "alice", "balance": 1000.50}} -{"index": {"_id": "2"}} +{"index": {}} {"id": "2", "city": {"name": "Portland", "population": 650000, "location": {"latitude": 45.5152, "longitude": -122.6784}}, "account": {"owner": "bob", "balance": 2500.00}} -{"index": {"_id": "3"}} +{"index": {}} {"id": "3", "city": {"name": "Austin", "population": 980000, "location": {"latitude": 30.2672, "longitude": -97.7431}}, "account": {"owner": "carol", "balance": 300.25}} diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q1.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q1.plan.yaml new file mode 100644 index 0000000000000..a50665788992a --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q1.plan.yaml @@ -0,0 +1,37 @@ +# Lucene-only shard path: no filter/expression means lucene metadata doc-count suffices. +# No shard_physical emitted (lucene handles natively); only the exchange/reduce stage is datafusion-only. +query: q1 +ppl_file: q1.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchProject(count()=[CAST($0):BIGINT NOT NULL], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{}], count()=[SUM($0)], mode=[FINAL], viableBackends=[[lucene, datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchAggregate(group=[{}], count()=[COUNT()], mode=[PARTIAL], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=lucene tree_shape=NONE] + OpenSearchAggregate(group=[{}], count()=[COUNT()], mode=[PARTIAL], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchProject(count()=[CAST($0):BIGINT NOT NULL], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{}], count()=[SUM($0)], mode=[FINAL], viableBackends=[[lucene, datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + coord_physical: | + ProjectionExec: expr=[sum(input-0.count())@0 as count()] + AggregateExec: mode=Final, gby=[], aggr=[sum(input-0.count())] + CoalescePartitionsExec + AggregateExec: mode=Partial, gby=[], aggr=[sum(input-0.count())] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + StreamingTableExec: partition_sizes=1, projection=[count()] + prod1s: + post_cbo: | + OpenSearchAggregate(group=[{}], count()=[COUNT()], mode=[SINGLE], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=lucene tree_shape=NONE] + OpenSearchAggregate(group=[{}], count()=[COUNT()], mode=[SINGLE], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q10.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q10.plan.yaml new file mode 100644 index 0000000000000..6a429ac754da8 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q10.plan.yaml @@ -0,0 +1,86 @@ +# Most complex aggregate combo: 5 partials (sum + count + sum-for-avg + count-for-avg + HLL). +# TopK on count column with fetch=30, AVG + dc coexist in grouped FinalPartitioned aggregate. +query: q10 +ppl_file: q10.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$1], sort1=[$4], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$1], sort1=[$4], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(sum(AdvEngineID)=[$1], c=[$2], avg(ResolutionWidth)=[$3], dc(UserID)=[$4], RegionID=[$0], viableBackends=[[datafusion]]) + OpenSearchProject(RegionID=[$0], sum(AdvEngineID)=[$1], c=[$2], avg(ResolutionWidth)=[ANNOTATED_PROJECT_EXPR(id=6, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], CAST($3):DOUBLE), $4))], dc(UserID)=[$5], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], sum(AdvEngineID)=[SUM($1)], c=[SUM($2)], $f3=[SUM($3)], $f4=[SUM($4)], dc(UserID)=[APPROX_COUNT_DISTINCT($5)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchSort(sort0=[$2], sort1=[$0], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], sum(AdvEngineID)=[SUM($1)], c=[COUNT()], agg#2=[SUM($2)], agg#3=[COUNT($2)], dc(UserID)=[APPROX_COUNT_DISTINCT($3)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(RegionID=[$65], AdvEngineID=[$0], ResolutionWidth=[$69], UserID=[$97], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$2], sort1=[$0], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], sum(AdvEngineID)=[SUM($1)], c=[COUNT()], agg#2=[SUM($2)], agg#3=[COUNT($2)], dc(UserID)=[APPROX_COUNT_DISTINCT($3)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(RegionID=[$65], AdvEngineID=[$0], ResolutionWidth=[$69], UserID=[$97], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$1], sort1=[$4], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$1], sort1=[$4], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(sum(AdvEngineID)=[$1], c=[$2], avg(ResolutionWidth)=[$3], dc(UserID)=[$4], RegionID=[$0], viableBackends=[[datafusion]]) + OpenSearchProject(RegionID=[$0], sum(AdvEngineID)=[$1], c=[$2], avg(ResolutionWidth)=[ANNOTATED_PROJECT_EXPR(id=6, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], CAST($3):DOUBLE), $4))], dc(UserID)=[$5], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], sum(AdvEngineID)=[SUM($1)], c=[SUM($2)], $f3=[SUM($3)], $f4=[SUM($4)], dc(UserID)=[APPROX_COUNT_DISTINCT($5)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[RegionID@0 as RegionID, sum(.AdvEngineID)[sum]@1 as sum(AdvEngineID), count(Int64(1))[count]@2 as c, sum(.ResolutionWidth)[sum]@3 as $f3, count(.ResolutionWidth)[count]@4 as $f4, approx_distinct(.UserID)[hll_registers]@5 as dc(UserID)] + SortPreservingMergeExec: [count(Int64(1))@2 DESC NULLS LAST, RegionID@0 ASC], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@2 DESC NULLS LAST, RegionID@0 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[RegionID@0 as RegionID], aggr=[sum(.AdvEngineID), count(Int64(1)), sum(.ResolutionWidth), count(.ResolutionWidth), approx_distinct(.UserID)] + RepartitionExec: partitioning=Hash([RegionID@0], 4), input_partitions=1 + AggregateExec: mode=Partial, gby=[RegionID@0 as RegionID], aggr=[sum(.AdvEngineID), count(Int64(1)), sum(.ResolutionWidth), count(.ResolutionWidth), approx_distinct(.UserID)] + DataSourceExec: file_groups={}, projection=[RegionID, AdvEngineID, ResolutionWidth, UserID], file_type=parquet + shard_physical_nseg: | + ProjectionExec: expr=[RegionID@0 as RegionID, sum(.AdvEngineID)[sum]@1 as sum(AdvEngineID), count(Int64(1))[count]@2 as c, sum(.ResolutionWidth)[sum]@3 as $f3, count(.ResolutionWidth)[count]@4 as $f4, approx_distinct(.UserID)[hll_registers]@5 as dc(UserID)] + SortPreservingMergeExec: [count(Int64(1))@2 DESC NULLS LAST, RegionID@0 ASC], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@2 DESC NULLS LAST, RegionID@0 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[RegionID@0 as RegionID], aggr=[sum(.AdvEngineID), count(Int64(1)), sum(.ResolutionWidth), count(.ResolutionWidth), approx_distinct(.UserID)] + RepartitionExec: partitioning=Hash([RegionID@0], 4), input_partitions=2 + AggregateExec: mode=Partial, gby=[RegionID@0 as RegionID], aggr=[sum(.AdvEngineID), count(Int64(1)), sum(.ResolutionWidth), count(.ResolutionWidth), approx_distinct(.UserID)] + DataSourceExec: file_groups={}, projection=[RegionID, AdvEngineID, ResolutionWidth, UserID], file_type=parquet + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$1], sort1=[$4], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$1], sort1=[$4], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(sum(AdvEngineID)=[$1], c=[$2], avg(ResolutionWidth)=[$3], dc(UserID)=[$4], RegionID=[$0], viableBackends=[[datafusion]]) + OpenSearchProject(RegionID=[$0], sum(AdvEngineID)=[$1], c=[$2], avg(ResolutionWidth)=[ANNOTATED_PROJECT_EXPR(id=6, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], CAST($3):DOUBLE), $4))], dc(UserID)=[$5], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], sum(AdvEngineID)=[SUM($1)], c=[COUNT()], agg#2=[SUM($2)], agg#3=[COUNT($2)], dc(UserID)=[APPROX_COUNT_DISTINCT($3)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(RegionID=[$65], AdvEngineID=[$0], ResolutionWidth=[$69], UserID=[$97], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$1], sort1=[$4], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$1], sort1=[$4], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(sum(AdvEngineID)=[$1], c=[$2], avg(ResolutionWidth)=[$3], dc(UserID)=[$4], RegionID=[$0], viableBackends=[[datafusion]]) + OpenSearchProject(RegionID=[$0], sum(AdvEngineID)=[$1], c=[$2], avg(ResolutionWidth)=[ANNOTATED_PROJECT_EXPR(id=6, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], CAST($3):DOUBLE), $4))], dc(UserID)=[$5], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], sum(AdvEngineID)=[SUM($1)], c=[COUNT()], agg#2=[SUM($2)], agg#3=[COUNT($2)], dc(UserID)=[APPROX_COUNT_DISTINCT($3)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(RegionID=[$65], AdvEngineID=[$0], ResolutionWidth=[$69], UserID=[$97], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + RelabelExec: schema=Schema { fields: [Field { name: "sum(AdvEngineID)", data_type: Int64, nullable: true }, Field { name: "c", data_type: Int64 }, Field { name: "avg(ResolutionWidth)", data_type: Float64, nullable: true }, Field { name: "dc(UserID)", data_type: Int64, nullable: true }, Field { name: "RegionID", data_type: Int32, nullable: true }], metadata: {} } + ProjectionExec: expr=[sum(.AdvEngineID)@0 as sum(AdvEngineID), count(Int64(1))@1 as c, CASE WHEN count(.ResolutionWidth) = Int64(0) THEN Float64(NULL) ELSE sum(.ResolutionWidth) / count(.ResolutionWidth) END@2 as avg(ResolutionWidth), approx_distinct(.UserID)@3 as dc(UserID), RegionID@4 as RegionID] + SortPreservingMergeExec: [count(Int64(1))@1 DESC NULLS LAST, RegionID@4 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@1 DESC NULLS LAST, RegionID@4 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[sum(.AdvEngineID)@1 as sum(.AdvEngineID), count(Int64(1))@2 as count(Int64(1)), CASE WHEN count(.ResolutionWidth)@4 = 0 THEN NULL ELSE CAST(sum(.ResolutionWidth)@3 AS Float64) / CAST(count(.ResolutionWidth)@4 AS Float64) END as CASE WHEN count(.ResolutionWidth) = Int64(0) THEN Float64(NULL) ELSE sum(.ResolutionWidth) / count(.ResolutionWidth) END, approx_distinct(.UserID)@5 as approx_distinct(.UserID), RegionID@0 as RegionID] + AggregateExec: mode=FinalPartitioned, gby=[RegionID@0 as RegionID], aggr=[sum(.AdvEngineID), count(Int64(1)), sum(.ResolutionWidth), count(.ResolutionWidth), approx_distinct(.UserID)] + RepartitionExec: partitioning=Hash([RegionID@0], 4), input_partitions=1 + AggregateExec: mode=Partial, gby=[RegionID@0 as RegionID], aggr=[sum(.AdvEngineID), count(Int64(1)), sum(.ResolutionWidth), count(.ResolutionWidth), approx_distinct(.UserID)] + DataSourceExec: file_groups={}, projection=[RegionID, AdvEngineID, ResolutionWidth, UserID], file_type=parquet + shard_physical_nseg: | + RelabelExec: schema=Schema { fields: [Field { name: "sum(AdvEngineID)", data_type: Int64, nullable: true }, Field { name: "c", data_type: Int64 }, Field { name: "avg(ResolutionWidth)", data_type: Float64, nullable: true }, Field { name: "dc(UserID)", data_type: Int64, nullable: true }, Field { name: "RegionID", data_type: Int32, nullable: true }], metadata: {} } + ProjectionExec: expr=[sum(.AdvEngineID)@0 as sum(AdvEngineID), count(Int64(1))@1 as c, CASE WHEN count(.ResolutionWidth) = Int64(0) THEN Float64(NULL) ELSE sum(.ResolutionWidth) / count(.ResolutionWidth) END@2 as avg(ResolutionWidth), approx_distinct(.UserID)@3 as dc(UserID), RegionID@4 as RegionID] + SortPreservingMergeExec: [count(Int64(1))@1 DESC NULLS LAST, RegionID@4 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@1 DESC NULLS LAST, RegionID@4 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[sum(.AdvEngineID)@1 as sum(.AdvEngineID), count(Int64(1))@2 as count(Int64(1)), CASE WHEN count(.ResolutionWidth)@4 = 0 THEN NULL ELSE CAST(sum(.ResolutionWidth)@3 AS Float64) / CAST(count(.ResolutionWidth)@4 AS Float64) END as CASE WHEN count(.ResolutionWidth) = Int64(0) THEN Float64(NULL) ELSE sum(.ResolutionWidth) / count(.ResolutionWidth) END, approx_distinct(.UserID)@5 as approx_distinct(.UserID), RegionID@0 as RegionID] + AggregateExec: mode=FinalPartitioned, gby=[RegionID@0 as RegionID], aggr=[sum(.AdvEngineID), count(Int64(1)), sum(.ResolutionWidth), count(.ResolutionWidth), approx_distinct(.UserID)] + RepartitionExec: partitioning=Hash([RegionID@0], 4), input_partitions=2 + AggregateExec: mode=Partial, gby=[RegionID@0 as RegionID], aggr=[sum(.AdvEngineID), count(Int64(1)), sum(.ResolutionWidth), count(.ResolutionWidth), approx_distinct(.UserID)] + DataSourceExec: file_groups={}, projection=[RegionID, AdvEngineID, ResolutionWidth, UserID], file_type=parquet diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q11.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q11.plan.yaml new file mode 100644 index 0000000000000..f9e3a34107fd0 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q11.plan.yaml @@ -0,0 +1,100 @@ +# Grouped dc(UserID) by MobilePhoneModel with != '' filter and TopK. +# != blocked from lucene (production blocklist); parquet DataSourceExec with predicate pushdown. +query: q11 +ppl_file: q11.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(u=[$1], MobilePhoneModel=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], u=[APPROX_COUNT_DISTINCT($1)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchProject(MobilePhoneModel=[$0], u=[$1], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$2], dir0=[DESC-nulls-last], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchProject(MobilePhoneModel=[$0], u=[$1], __reduce_eval_1=[reduce_eval('approx_distinct', $1)], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], u=[APPROX_COUNT_DISTINCT($1)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(MobilePhoneModel=[$45], UserID=[$97], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($45, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchProject(MobilePhoneModel=[$0], u=[$1], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$2], dir0=[DESC-nulls-last], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchProject(MobilePhoneModel=[$0], u=[$1], __reduce_eval_1=[reduce_eval('approx_distinct', $1)], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], u=[APPROX_COUNT_DISTINCT($1)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(MobilePhoneModel=[$45], UserID=[$97], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($45, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(u=[$1], MobilePhoneModel=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], u=[APPROX_COUNT_DISTINCT($1)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[MobilePhoneModel@0 as MobilePhoneModel, approx_distinct(.UserID)@1 as u] + SortPreservingMergeExec: [reduce_eval(Utf8("approx_distinct"),approx_distinct(.UserID))@2 DESC NULLS LAST], fetch=30 + SortExec: TopK(fetch=30), expr=[reduce_eval(Utf8("approx_distinct"),approx_distinct(.UserID))@2 DESC NULLS LAST], preserve_partitioning=[true] + ProjectionExec: expr=[MobilePhoneModel@0 as MobilePhoneModel, approx_distinct(.UserID)[hll_registers]@1 as approx_distinct(.UserID), reduce_eval(approx_distinct, approx_distinct(.UserID)[hll_registers]@1) as reduce_eval(Utf8("approx_distinct"),approx_distinct(.UserID))] + AggregateExec: mode=PartialReduce, gby=[MobilePhoneModel@0 as MobilePhoneModel], aggr=[approx_distinct(.UserID)] + RepartitionExec: partitioning=Hash([MobilePhoneModel@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[MobilePhoneModel@0 as MobilePhoneModel], aggr=[approx_distinct(.UserID)] + FilterExec: MobilePhoneModel@0 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[MobilePhoneModel, UserID], file_type=parquet, predicate=MobilePhoneModel@33 != , pruning_predicate=MobilePhoneModel_null_count@2 != row_count@3 AND (MobilePhoneModel_min@0 != OR != MobilePhoneModel_max@1), required_guarantees=[MobilePhoneModel not in ()] + shard_physical_nseg: | + ProjectionExec: expr=[MobilePhoneModel@0 as MobilePhoneModel, approx_distinct(.UserID)@1 as u] + SortPreservingMergeExec: [reduce_eval(Utf8("approx_distinct"),approx_distinct(.UserID))@2 DESC NULLS LAST], fetch=30 + SortExec: TopK(fetch=30), expr=[reduce_eval(Utf8("approx_distinct"),approx_distinct(.UserID))@2 DESC NULLS LAST], preserve_partitioning=[true] + ProjectionExec: expr=[MobilePhoneModel@0 as MobilePhoneModel, approx_distinct(.UserID)[hll_registers]@1 as approx_distinct(.UserID), reduce_eval(approx_distinct, approx_distinct(.UserID)[hll_registers]@1) as reduce_eval(Utf8("approx_distinct"),approx_distinct(.UserID))] + AggregateExec: mode=PartialReduce, gby=[MobilePhoneModel@0 as MobilePhoneModel], aggr=[approx_distinct(.UserID)] + RepartitionExec: partitioning=Hash([MobilePhoneModel@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[MobilePhoneModel@0 as MobilePhoneModel], aggr=[approx_distinct(.UserID)] + FilterExec: MobilePhoneModel@0 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[MobilePhoneModel, UserID], file_type=parquet, predicate=MobilePhoneModel@33 != , pruning_predicate=MobilePhoneModel_null_count@2 != row_count@3 AND (MobilePhoneModel_min@0 != OR != MobilePhoneModel_max@1), required_guarantees=[MobilePhoneModel not in ()] + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(u=[$1], MobilePhoneModel=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], u=[APPROX_COUNT_DISTINCT($1)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(MobilePhoneModel=[$45], UserID=[$97], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($45, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(u=[$1], MobilePhoneModel=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], u=[APPROX_COUNT_DISTINCT($1)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(MobilePhoneModel=[$45], UserID=[$97], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($45, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + RelabelExec: schema=Schema { fields: [Field { name: "u", data_type: Int64, nullable: true }, Field { name: "MobilePhoneModel", data_type: Utf8View, nullable: true }], metadata: {} } + ProjectionExec: expr=[approx_distinct(.UserID)@0 as u, MobilePhoneModel@1 as MobilePhoneModel] + SortPreservingMergeExec: [approx_distinct(.UserID)@0 DESC NULLS LAST], fetch=10 + SortExec: TopK(fetch=10), expr=[approx_distinct(.UserID)@0 DESC NULLS LAST], preserve_partitioning=[true] + ProjectionExec: expr=[approx_distinct(.UserID)@1 as approx_distinct(.UserID), MobilePhoneModel@0 as MobilePhoneModel] + AggregateExec: mode=FinalPartitioned, gby=[MobilePhoneModel@0 as MobilePhoneModel], aggr=[approx_distinct(.UserID)] + RepartitionExec: partitioning=Hash([MobilePhoneModel@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[MobilePhoneModel@0 as MobilePhoneModel], aggr=[approx_distinct(.UserID)] + FilterExec: MobilePhoneModel@0 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[MobilePhoneModel, UserID], file_type=parquet, predicate=MobilePhoneModel@33 != , pruning_predicate=MobilePhoneModel_null_count@2 != row_count@3 AND (MobilePhoneModel_min@0 != OR != MobilePhoneModel_max@1), required_guarantees=[MobilePhoneModel not in ()] + shard_physical_nseg: | + RelabelExec: schema=Schema { fields: [Field { name: "u", data_type: Int64, nullable: true }, Field { name: "MobilePhoneModel", data_type: Utf8View, nullable: true }], metadata: {} } + ProjectionExec: expr=[approx_distinct(.UserID)@0 as u, MobilePhoneModel@1 as MobilePhoneModel] + SortPreservingMergeExec: [approx_distinct(.UserID)@0 DESC NULLS LAST], fetch=10 + SortExec: TopK(fetch=10), expr=[approx_distinct(.UserID)@0 DESC NULLS LAST], preserve_partitioning=[true] + ProjectionExec: expr=[approx_distinct(.UserID)@1 as approx_distinct(.UserID), MobilePhoneModel@0 as MobilePhoneModel] + AggregateExec: mode=FinalPartitioned, gby=[MobilePhoneModel@0 as MobilePhoneModel], aggr=[approx_distinct(.UserID)] + RepartitionExec: partitioning=Hash([MobilePhoneModel@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[MobilePhoneModel@0 as MobilePhoneModel], aggr=[approx_distinct(.UserID)] + FilterExec: MobilePhoneModel@0 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[MobilePhoneModel, UserID], file_type=parquet, predicate=MobilePhoneModel@33 != , pruning_predicate=MobilePhoneModel_null_count@2 != row_count@3 AND (MobilePhoneModel_min@0 != OR != MobilePhoneModel_max@1), required_guarantees=[MobilePhoneModel not in ()] diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q12.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q12.plan.yaml new file mode 100644 index 0000000000000..be472def9d44e --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q12.plan.yaml @@ -0,0 +1,100 @@ +# Multi-key grouped dc: (MobilePhone, MobilePhoneModel) with != '' and multi-column sort. +# HLL sketch merge with composite sort; parquet DataSourceExec. +query: q12 +ppl_file: q12.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(u=[$2], MobilePhone=[$0], MobilePhoneModel=[$1], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], u=[APPROX_COUNT_DISTINCT($2)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchProject(MobilePhone=[$0], MobilePhoneModel=[$1], u=[$2], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$3], sort1=[$0], sort2=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchProject(MobilePhone=[$0], MobilePhoneModel=[$1], u=[$2], __reduce_eval_2=[reduce_eval('approx_distinct', $2)], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], u=[APPROX_COUNT_DISTINCT($2)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(MobilePhone=[$44], MobilePhoneModel=[$45], UserID=[$97], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($45, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchProject(MobilePhone=[$0], MobilePhoneModel=[$1], u=[$2], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$3], sort1=[$0], sort2=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchProject(MobilePhone=[$0], MobilePhoneModel=[$1], u=[$2], __reduce_eval_2=[reduce_eval('approx_distinct', $2)], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], u=[APPROX_COUNT_DISTINCT($2)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(MobilePhone=[$44], MobilePhoneModel=[$45], UserID=[$97], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($45, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(u=[$2], MobilePhone=[$0], MobilePhoneModel=[$1], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], u=[APPROX_COUNT_DISTINCT($2)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[MobilePhone@0 as MobilePhone, MobilePhoneModel@1 as MobilePhoneModel, approx_distinct(.UserID)@2 as u] + SortPreservingMergeExec: [reduce_eval(Utf8("approx_distinct"),approx_distinct(.UserID))@3 DESC NULLS LAST, MobilePhone@0 ASC, MobilePhoneModel@1 ASC], fetch=30 + SortExec: TopK(fetch=30), expr=[reduce_eval(Utf8("approx_distinct"),approx_distinct(.UserID))@3 DESC NULLS LAST, MobilePhone@0 ASC, MobilePhoneModel@1 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[MobilePhone@0 as MobilePhone, MobilePhoneModel@1 as MobilePhoneModel, approx_distinct(.UserID)[hll_registers]@2 as approx_distinct(.UserID), reduce_eval(approx_distinct, approx_distinct(.UserID)[hll_registers]@2) as reduce_eval(Utf8("approx_distinct"),approx_distinct(.UserID))] + AggregateExec: mode=PartialReduce, gby=[MobilePhone@0 as MobilePhone, MobilePhoneModel@1 as MobilePhoneModel], aggr=[approx_distinct(.UserID)] + RepartitionExec: partitioning=Hash([MobilePhone@0, MobilePhoneModel@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[MobilePhone@0 as MobilePhone, MobilePhoneModel@1 as MobilePhoneModel], aggr=[approx_distinct(.UserID)] + FilterExec: MobilePhoneModel@1 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[MobilePhone, MobilePhoneModel, UserID], file_type=parquet, predicate=MobilePhoneModel@33 != , pruning_predicate=MobilePhoneModel_null_count@2 != row_count@3 AND (MobilePhoneModel_min@0 != OR != MobilePhoneModel_max@1), required_guarantees=[MobilePhoneModel not in ()] + shard_physical_nseg: | + ProjectionExec: expr=[MobilePhone@0 as MobilePhone, MobilePhoneModel@1 as MobilePhoneModel, approx_distinct(.UserID)@2 as u] + SortPreservingMergeExec: [reduce_eval(Utf8("approx_distinct"),approx_distinct(.UserID))@3 DESC NULLS LAST, MobilePhone@0 ASC, MobilePhoneModel@1 ASC], fetch=30 + SortExec: TopK(fetch=30), expr=[reduce_eval(Utf8("approx_distinct"),approx_distinct(.UserID))@3 DESC NULLS LAST, MobilePhone@0 ASC, MobilePhoneModel@1 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[MobilePhone@0 as MobilePhone, MobilePhoneModel@1 as MobilePhoneModel, approx_distinct(.UserID)[hll_registers]@2 as approx_distinct(.UserID), reduce_eval(approx_distinct, approx_distinct(.UserID)[hll_registers]@2) as reduce_eval(Utf8("approx_distinct"),approx_distinct(.UserID))] + AggregateExec: mode=PartialReduce, gby=[MobilePhone@0 as MobilePhone, MobilePhoneModel@1 as MobilePhoneModel], aggr=[approx_distinct(.UserID)] + RepartitionExec: partitioning=Hash([MobilePhone@0, MobilePhoneModel@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[MobilePhone@0 as MobilePhone, MobilePhoneModel@1 as MobilePhoneModel], aggr=[approx_distinct(.UserID)] + FilterExec: MobilePhoneModel@1 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[MobilePhone, MobilePhoneModel, UserID], file_type=parquet, predicate=MobilePhoneModel@33 != , pruning_predicate=MobilePhoneModel_null_count@2 != row_count@3 AND (MobilePhoneModel_min@0 != OR != MobilePhoneModel_max@1), required_guarantees=[MobilePhoneModel not in ()] + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(u=[$2], MobilePhone=[$0], MobilePhoneModel=[$1], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], u=[APPROX_COUNT_DISTINCT($2)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(MobilePhone=[$44], MobilePhoneModel=[$45], UserID=[$97], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($45, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(u=[$2], MobilePhone=[$0], MobilePhoneModel=[$1], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], u=[APPROX_COUNT_DISTINCT($2)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(MobilePhone=[$44], MobilePhoneModel=[$45], UserID=[$97], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($45, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + RelabelExec: schema=Schema { fields: [Field { name: "u", data_type: Int64, nullable: true }, Field { name: "MobilePhone", data_type: Int16, nullable: true }, Field { name: "MobilePhoneModel", data_type: Utf8View, nullable: true }], metadata: {} } + ProjectionExec: expr=[approx_distinct(.UserID)@0 as u, MobilePhone@1 as MobilePhone, MobilePhoneModel@2 as MobilePhoneModel] + SortPreservingMergeExec: [approx_distinct(.UserID)@0 DESC NULLS LAST, MobilePhone@1 ASC, MobilePhoneModel@2 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[approx_distinct(.UserID)@0 DESC NULLS LAST, MobilePhone@1 ASC, MobilePhoneModel@2 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[approx_distinct(.UserID)@2 as approx_distinct(.UserID), MobilePhone@0 as MobilePhone, MobilePhoneModel@1 as MobilePhoneModel] + AggregateExec: mode=FinalPartitioned, gby=[MobilePhone@0 as MobilePhone, MobilePhoneModel@1 as MobilePhoneModel], aggr=[approx_distinct(.UserID)] + RepartitionExec: partitioning=Hash([MobilePhone@0, MobilePhoneModel@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[MobilePhone@0 as MobilePhone, MobilePhoneModel@1 as MobilePhoneModel], aggr=[approx_distinct(.UserID)] + FilterExec: MobilePhoneModel@1 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[MobilePhone, MobilePhoneModel, UserID], file_type=parquet, predicate=MobilePhoneModel@33 != , pruning_predicate=MobilePhoneModel_null_count@2 != row_count@3 AND (MobilePhoneModel_min@0 != OR != MobilePhoneModel_max@1), required_guarantees=[MobilePhoneModel not in ()] + shard_physical_nseg: | + RelabelExec: schema=Schema { fields: [Field { name: "u", data_type: Int64, nullable: true }, Field { name: "MobilePhone", data_type: Int16, nullable: true }, Field { name: "MobilePhoneModel", data_type: Utf8View, nullable: true }], metadata: {} } + ProjectionExec: expr=[approx_distinct(.UserID)@0 as u, MobilePhone@1 as MobilePhone, MobilePhoneModel@2 as MobilePhoneModel] + SortPreservingMergeExec: [approx_distinct(.UserID)@0 DESC NULLS LAST, MobilePhone@1 ASC, MobilePhoneModel@2 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[approx_distinct(.UserID)@0 DESC NULLS LAST, MobilePhone@1 ASC, MobilePhoneModel@2 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[approx_distinct(.UserID)@2 as approx_distinct(.UserID), MobilePhone@0 as MobilePhone, MobilePhoneModel@1 as MobilePhoneModel] + AggregateExec: mode=FinalPartitioned, gby=[MobilePhone@0 as MobilePhone, MobilePhoneModel@1 as MobilePhoneModel], aggr=[approx_distinct(.UserID)] + RepartitionExec: partitioning=Hash([MobilePhone@0, MobilePhoneModel@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[MobilePhone@0 as MobilePhone, MobilePhoneModel@1 as MobilePhoneModel], aggr=[approx_distinct(.UserID)] + FilterExec: MobilePhoneModel@1 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[MobilePhone, MobilePhoneModel, UserID], file_type=parquet, predicate=MobilePhoneModel@33 != , pruning_predicate=MobilePhoneModel_null_count@2 != row_count@3 AND (MobilePhoneModel_min@0 != OR != MobilePhoneModel_max@1), required_guarantees=[MobilePhoneModel not in ()] diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q13.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q13.plan.yaml new file mode 100644 index 0000000000000..55c166f8b6f69 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q13.plan.yaml @@ -0,0 +1,102 @@ +# Grouped count by SearchPhrase (keyword) with != '' filter and TopK (3x fetch). +# != blocked from lucene (production blocklist); parquet DataSourceExec with predicate pushdown. +query: q13 +ppl_file: q13.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$1], SearchPhrase=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], c=[SUM($1)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchSort(sort0=[$1], dir0=[DESC-nulls-last], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], c=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$1], dir0=[DESC-nulls-last], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], c=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$1], SearchPhrase=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], c=[SUM($1)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[SearchPhrase@0 as SearchPhrase, count(Int64(1))[count]@1 as c] + SortPreservingMergeExec: [count(Int64(1))@1 DESC NULLS LAST], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@1 DESC NULLS LAST], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[SearchPhrase@0 as SearchPhrase], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([SearchPhrase@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[SearchPhrase@0 as SearchPhrase], aggr=[count(Int64(1))] + FilterExec: SearchPhrase@0 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[SearchPhrase], file_type=parquet, predicate=SearchPhrase@63 != , pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] + shard_physical_nseg: | + ProjectionExec: expr=[SearchPhrase@0 as SearchPhrase, count(Int64(1))[count]@1 as c] + SortPreservingMergeExec: [count(Int64(1))@1 DESC NULLS LAST], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@1 DESC NULLS LAST], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[SearchPhrase@0 as SearchPhrase], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([SearchPhrase@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[SearchPhrase@0 as SearchPhrase], aggr=[count(Int64(1))] + FilterExec: SearchPhrase@0 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[SearchPhrase], file_type=parquet, predicate=SearchPhrase@63 != , pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] + coord_physical: | + ProjectionExec: expr=[sum(input-0.c)@0 as c, SearchPhrase@1 as SearchPhrase] + SortPreservingMergeExec: [sum(input-0.c)@0 DESC NULLS LAST], fetch=10 + SortExec: TopK(fetch=10), expr=[sum(input-0.c)@0 DESC NULLS LAST], preserve_partitioning=[true] + ProjectionExec: expr=[sum(input-0.c)@1 as sum(input-0.c), SearchPhrase@0 as SearchPhrase] + AggregateExec: mode=FinalPartitioned, gby=[SearchPhrase@0 as SearchPhrase], aggr=[sum(input-0.c)] + RepartitionExec: partitioning=Hash([SearchPhrase@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[SearchPhrase@0 as SearchPhrase], aggr=[sum(input-0.c)] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + StreamingTableExec: partition_sizes=1, projection=[SearchPhrase, c] + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$1], SearchPhrase=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], c=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$1], SearchPhrase=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], c=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[count(Int64(1))@0 as c, SearchPhrase@1 as SearchPhrase] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@0 DESC NULLS LAST], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@1 as count(Int64(1)), SearchPhrase@0 as SearchPhrase] + AggregateExec: mode=FinalPartitioned, gby=[SearchPhrase@0 as SearchPhrase], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([SearchPhrase@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[SearchPhrase@0 as SearchPhrase], aggr=[count(Int64(1))] + FilterExec: SearchPhrase@0 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[SearchPhrase], file_type=parquet, predicate=SearchPhrase@63 != , pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] + shard_physical_nseg: | + ProjectionExec: expr=[count(Int64(1))@0 as c, SearchPhrase@1 as SearchPhrase] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@0 DESC NULLS LAST], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@1 as count(Int64(1)), SearchPhrase@0 as SearchPhrase] + AggregateExec: mode=FinalPartitioned, gby=[SearchPhrase@0 as SearchPhrase], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([SearchPhrase@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[SearchPhrase@0 as SearchPhrase], aggr=[count(Int64(1))] + FilterExec: SearchPhrase@0 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[SearchPhrase], file_type=parquet, predicate=SearchPhrase@63 != , pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q14.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q14.plan.yaml new file mode 100644 index 0000000000000..d6a98b957524e --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q14.plan.yaml @@ -0,0 +1,100 @@ +# Grouped dc(UserID) by SearchPhrase with != '' filter and TopK. +# HLL + reduce_eval with text-field grouping; parquet DataSourceExec. +query: q14 +ppl_file: q14.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(u=[$1], SearchPhrase=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], u=[APPROX_COUNT_DISTINCT($1)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchProject(SearchPhrase=[$0], u=[$1], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$2], dir0=[DESC-nulls-last], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchProject(SearchPhrase=[$0], u=[$1], __reduce_eval_1=[reduce_eval('approx_distinct', $1)], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], u=[APPROX_COUNT_DISTINCT($1)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(SearchPhrase=[$74], UserID=[$97], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchProject(SearchPhrase=[$0], u=[$1], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$2], dir0=[DESC-nulls-last], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchProject(SearchPhrase=[$0], u=[$1], __reduce_eval_1=[reduce_eval('approx_distinct', $1)], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], u=[APPROX_COUNT_DISTINCT($1)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(SearchPhrase=[$74], UserID=[$97], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(u=[$1], SearchPhrase=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], u=[APPROX_COUNT_DISTINCT($1)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[SearchPhrase@0 as SearchPhrase, approx_distinct(.UserID)@1 as u] + SortPreservingMergeExec: [reduce_eval(Utf8("approx_distinct"),approx_distinct(.UserID))@2 DESC NULLS LAST], fetch=30 + SortExec: TopK(fetch=30), expr=[reduce_eval(Utf8("approx_distinct"),approx_distinct(.UserID))@2 DESC NULLS LAST], preserve_partitioning=[true] + ProjectionExec: expr=[SearchPhrase@0 as SearchPhrase, approx_distinct(.UserID)[hll_registers]@1 as approx_distinct(.UserID), reduce_eval(approx_distinct, approx_distinct(.UserID)[hll_registers]@1) as reduce_eval(Utf8("approx_distinct"),approx_distinct(.UserID))] + AggregateExec: mode=PartialReduce, gby=[SearchPhrase@0 as SearchPhrase], aggr=[approx_distinct(.UserID)] + RepartitionExec: partitioning=Hash([SearchPhrase@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[SearchPhrase@0 as SearchPhrase], aggr=[approx_distinct(.UserID)] + FilterExec: SearchPhrase@0 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[SearchPhrase, UserID], file_type=parquet, predicate=SearchPhrase@63 != , pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] + shard_physical_nseg: | + ProjectionExec: expr=[SearchPhrase@0 as SearchPhrase, approx_distinct(.UserID)@1 as u] + SortPreservingMergeExec: [reduce_eval(Utf8("approx_distinct"),approx_distinct(.UserID))@2 DESC NULLS LAST], fetch=30 + SortExec: TopK(fetch=30), expr=[reduce_eval(Utf8("approx_distinct"),approx_distinct(.UserID))@2 DESC NULLS LAST], preserve_partitioning=[true] + ProjectionExec: expr=[SearchPhrase@0 as SearchPhrase, approx_distinct(.UserID)[hll_registers]@1 as approx_distinct(.UserID), reduce_eval(approx_distinct, approx_distinct(.UserID)[hll_registers]@1) as reduce_eval(Utf8("approx_distinct"),approx_distinct(.UserID))] + AggregateExec: mode=PartialReduce, gby=[SearchPhrase@0 as SearchPhrase], aggr=[approx_distinct(.UserID)] + RepartitionExec: partitioning=Hash([SearchPhrase@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[SearchPhrase@0 as SearchPhrase], aggr=[approx_distinct(.UserID)] + FilterExec: SearchPhrase@0 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[SearchPhrase, UserID], file_type=parquet, predicate=SearchPhrase@63 != , pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(u=[$1], SearchPhrase=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], u=[APPROX_COUNT_DISTINCT($1)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(SearchPhrase=[$74], UserID=[$97], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(u=[$1], SearchPhrase=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], u=[APPROX_COUNT_DISTINCT($1)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(SearchPhrase=[$74], UserID=[$97], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + RelabelExec: schema=Schema { fields: [Field { name: "u", data_type: Int64, nullable: true }, Field { name: "SearchPhrase", data_type: Utf8View, nullable: true }], metadata: {} } + ProjectionExec: expr=[approx_distinct(.UserID)@0 as u, SearchPhrase@1 as SearchPhrase] + SortPreservingMergeExec: [approx_distinct(.UserID)@0 DESC NULLS LAST], fetch=10 + SortExec: TopK(fetch=10), expr=[approx_distinct(.UserID)@0 DESC NULLS LAST], preserve_partitioning=[true] + ProjectionExec: expr=[approx_distinct(.UserID)@1 as approx_distinct(.UserID), SearchPhrase@0 as SearchPhrase] + AggregateExec: mode=FinalPartitioned, gby=[SearchPhrase@0 as SearchPhrase], aggr=[approx_distinct(.UserID)] + RepartitionExec: partitioning=Hash([SearchPhrase@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[SearchPhrase@0 as SearchPhrase], aggr=[approx_distinct(.UserID)] + FilterExec: SearchPhrase@0 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[SearchPhrase, UserID], file_type=parquet, predicate=SearchPhrase@63 != , pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] + shard_physical_nseg: | + RelabelExec: schema=Schema { fields: [Field { name: "u", data_type: Int64, nullable: true }, Field { name: "SearchPhrase", data_type: Utf8View, nullable: true }], metadata: {} } + ProjectionExec: expr=[approx_distinct(.UserID)@0 as u, SearchPhrase@1 as SearchPhrase] + SortPreservingMergeExec: [approx_distinct(.UserID)@0 DESC NULLS LAST], fetch=10 + SortExec: TopK(fetch=10), expr=[approx_distinct(.UserID)@0 DESC NULLS LAST], preserve_partitioning=[true] + ProjectionExec: expr=[approx_distinct(.UserID)@1 as approx_distinct(.UserID), SearchPhrase@0 as SearchPhrase] + AggregateExec: mode=FinalPartitioned, gby=[SearchPhrase@0 as SearchPhrase], aggr=[approx_distinct(.UserID)] + RepartitionExec: partitioning=Hash([SearchPhrase@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[SearchPhrase@0 as SearchPhrase], aggr=[approx_distinct(.UserID)] + FilterExec: SearchPhrase@0 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[SearchPhrase, UserID], file_type=parquet, predicate=SearchPhrase@63 != , pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q15.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q15.plan.yaml new file mode 100644 index 0000000000000..c49bb90836312 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q15.plan.yaml @@ -0,0 +1,102 @@ +# Multi-key grouped count by (SearchEngineID, SearchPhrase) with != '' filter. +# Composite sort key (count DESC, SearchEngineID, SearchPhrase) in TopK; parquet DataSourceExec. +query: q15 +ppl_file: q15.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$2], SearchEngineID=[$0], SearchPhrase=[$1], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], c=[SUM($2)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchSort(sort0=[$2], sort1=[$0], sort2=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], c=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(SearchEngineID=[$73], SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$2], sort1=[$0], sort2=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], c=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(SearchEngineID=[$73], SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$2], SearchEngineID=[$0], SearchPhrase=[$1], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], c=[SUM($2)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[SearchEngineID@0 as SearchEngineID, SearchPhrase@1 as SearchPhrase, count(Int64(1))[count]@2 as c] + SortPreservingMergeExec: [count(Int64(1))@2 DESC NULLS LAST, SearchEngineID@0 ASC, SearchPhrase@1 ASC], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@2 DESC NULLS LAST, SearchEngineID@0 ASC, SearchPhrase@1 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[SearchEngineID@0 as SearchEngineID, SearchPhrase@1 as SearchPhrase], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([SearchEngineID@0, SearchPhrase@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[SearchEngineID@0 as SearchEngineID, SearchPhrase@1 as SearchPhrase], aggr=[count(Int64(1))] + FilterExec: SearchPhrase@1 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[SearchEngineID, SearchPhrase], file_type=parquet, predicate=SearchPhrase@63 != , pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] + shard_physical_nseg: | + ProjectionExec: expr=[SearchEngineID@0 as SearchEngineID, SearchPhrase@1 as SearchPhrase, count(Int64(1))[count]@2 as c] + SortPreservingMergeExec: [count(Int64(1))@2 DESC NULLS LAST, SearchEngineID@0 ASC, SearchPhrase@1 ASC], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@2 DESC NULLS LAST, SearchEngineID@0 ASC, SearchPhrase@1 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[SearchEngineID@0 as SearchEngineID, SearchPhrase@1 as SearchPhrase], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([SearchEngineID@0, SearchPhrase@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[SearchEngineID@0 as SearchEngineID, SearchPhrase@1 as SearchPhrase], aggr=[count(Int64(1))] + FilterExec: SearchPhrase@1 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[SearchEngineID, SearchPhrase], file_type=parquet, predicate=SearchPhrase@63 != , pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] + coord_physical: | + ProjectionExec: expr=[sum(input-0.c)@0 as c, SearchEngineID@1 as SearchEngineID, SearchPhrase@2 as SearchPhrase] + SortPreservingMergeExec: [sum(input-0.c)@0 DESC NULLS LAST, SearchEngineID@1 ASC, SearchPhrase@2 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[sum(input-0.c)@0 DESC NULLS LAST, SearchEngineID@1 ASC, SearchPhrase@2 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[sum(input-0.c)@2 as sum(input-0.c), SearchEngineID@0 as SearchEngineID, SearchPhrase@1 as SearchPhrase] + AggregateExec: mode=FinalPartitioned, gby=[SearchEngineID@0 as SearchEngineID, SearchPhrase@1 as SearchPhrase], aggr=[sum(input-0.c)] + RepartitionExec: partitioning=Hash([SearchEngineID@0, SearchPhrase@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[SearchEngineID@0 as SearchEngineID, SearchPhrase@1 as SearchPhrase], aggr=[sum(input-0.c)] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + StreamingTableExec: partition_sizes=1, projection=[SearchEngineID, SearchPhrase, c] + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$2], SearchEngineID=[$0], SearchPhrase=[$1], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], c=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(SearchEngineID=[$73], SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$2], SearchEngineID=[$0], SearchPhrase=[$1], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], c=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(SearchEngineID=[$73], SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[count(Int64(1))@0 as c, SearchEngineID@1 as SearchEngineID, SearchPhrase@2 as SearchPhrase] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST, SearchEngineID@1 ASC, SearchPhrase@2 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@0 DESC NULLS LAST, SearchEngineID@1 ASC, SearchPhrase@2 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@2 as count(Int64(1)), SearchEngineID@0 as SearchEngineID, SearchPhrase@1 as SearchPhrase] + AggregateExec: mode=FinalPartitioned, gby=[SearchEngineID@0 as SearchEngineID, SearchPhrase@1 as SearchPhrase], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([SearchEngineID@0, SearchPhrase@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[SearchEngineID@0 as SearchEngineID, SearchPhrase@1 as SearchPhrase], aggr=[count(Int64(1))] + FilterExec: SearchPhrase@1 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[SearchEngineID, SearchPhrase], file_type=parquet, predicate=SearchPhrase@63 != , pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] + shard_physical_nseg: | + ProjectionExec: expr=[count(Int64(1))@0 as c, SearchEngineID@1 as SearchEngineID, SearchPhrase@2 as SearchPhrase] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST, SearchEngineID@1 ASC, SearchPhrase@2 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@0 DESC NULLS LAST, SearchEngineID@1 ASC, SearchPhrase@2 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@2 as count(Int64(1)), SearchEngineID@0 as SearchEngineID, SearchPhrase@1 as SearchPhrase] + AggregateExec: mode=FinalPartitioned, gby=[SearchEngineID@0 as SearchEngineID, SearchPhrase@1 as SearchPhrase], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([SearchEngineID@0, SearchPhrase@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[SearchEngineID@0 as SearchEngineID, SearchPhrase@1 as SearchPhrase], aggr=[count(Int64(1))] + FilterExec: SearchPhrase@1 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[SearchEngineID, SearchPhrase], file_type=parquet, predicate=SearchPhrase@63 != , pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q16.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q16.plan.yaml new file mode 100644 index 0000000000000..b7e3bbf32f926 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q16.plan.yaml @@ -0,0 +1,90 @@ +# Baseline TopK pattern: single group key, single aggregate, 3x fetch multiplier (30 for head 10). +# Double-sort at coordinator (inner fetch=10, outer fetch=10000 safety ceiling). +query: q16 +ppl_file: q16.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(count()=[$1], UserID=[$0], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{0}], count()=[SUM($1)], mode=[FINAL], viableBackends=[[lucene, datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchSort(sort0=[$1], sort1=[$0], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[30], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{0}], count()=[COUNT()], mode=[PARTIAL], viableBackends=[[lucene, datafusion]]) + OpenSearchProject(UserID=[$97], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$1], sort1=[$0], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[30], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{0}], count()=[COUNT()], mode=[PARTIAL], viableBackends=[[lucene, datafusion]]) + OpenSearchProject(UserID=[$97], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(count()=[$1], UserID=[$0], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{0}], count()=[SUM($1)], mode=[FINAL], viableBackends=[[lucene, datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[UserID@0 as UserID, count(Int64(1))[count]@1 as count()] + SortPreservingMergeExec: [count(Int64(1))@1 DESC NULLS LAST, UserID@0 ASC], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@1 DESC NULLS LAST, UserID@0 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[UserID@0 as UserID], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([UserID@0], 4), input_partitions=1 + AggregateExec: mode=Partial, gby=[UserID@0 as UserID], aggr=[count(Int64(1))] + DataSourceExec: file_groups={}, projection=[UserID], file_type=parquet + shard_physical_nseg: | + ProjectionExec: expr=[UserID@0 as UserID, count(Int64(1))[count]@1 as count()] + SortPreservingMergeExec: [count(Int64(1))@1 DESC NULLS LAST, UserID@0 ASC], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@1 DESC NULLS LAST, UserID@0 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[UserID@0 as UserID], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([UserID@0], 4), input_partitions=2 + AggregateExec: mode=Partial, gby=[UserID@0 as UserID], aggr=[count(Int64(1))] + DataSourceExec: file_groups={}, projection=[UserID], file_type=parquet + coord_physical: | + ProjectionExec: expr=[sum(input-0.count())@0 as count(), UserID@1 as UserID] + SortPreservingMergeExec: [sum(input-0.count())@0 DESC NULLS LAST, UserID@1 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[sum(input-0.count())@0 DESC NULLS LAST, UserID@1 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[sum(input-0.count())@1 as sum(input-0.count()), UserID@0 as UserID] + AggregateExec: mode=FinalPartitioned, gby=[UserID@0 as UserID], aggr=[sum(input-0.count())] + RepartitionExec: partitioning=Hash([UserID@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[UserID@0 as UserID], aggr=[sum(input-0.count())] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + StreamingTableExec: partition_sizes=1, projection=[UserID, count()] + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(count()=[$1], UserID=[$0], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{0}], count()=[COUNT()], mode=[SINGLE], viableBackends=[[lucene, datafusion]]) + OpenSearchProject(UserID=[$97], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(count()=[$1], UserID=[$0], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{0}], count()=[COUNT()], mode=[SINGLE], viableBackends=[[lucene, datafusion]]) + OpenSearchProject(UserID=[$97], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[count(Int64(1))@0 as count(), UserID@1 as UserID] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST, UserID@1 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@0 DESC NULLS LAST, UserID@1 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@1 as count(Int64(1)), UserID@0 as UserID] + AggregateExec: mode=FinalPartitioned, gby=[UserID@0 as UserID], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([UserID@0], 4), input_partitions=1 + AggregateExec: mode=Partial, gby=[UserID@0 as UserID], aggr=[count(Int64(1))] + DataSourceExec: file_groups={}, projection=[UserID], file_type=parquet + shard_physical_nseg: | + ProjectionExec: expr=[count(Int64(1))@0 as count(), UserID@1 as UserID] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST, UserID@1 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@0 DESC NULLS LAST, UserID@1 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@1 as count(Int64(1)), UserID@0 as UserID] + AggregateExec: mode=FinalPartitioned, gby=[UserID@0 as UserID], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([UserID@0], 4), input_partitions=2 + AggregateExec: mode=Partial, gby=[UserID@0 as UserID], aggr=[count(Int64(1))] + DataSourceExec: file_groups={}, projection=[UserID], file_type=parquet diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q17.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q17.plan.yaml new file mode 100644 index 0000000000000..3130f1842d8d0 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q17.plan.yaml @@ -0,0 +1,90 @@ +# Multi-key grouped TopK: composite (UserID, SearchPhrase) hash-repartition at coordinator. +# Same structure as q16 — planner treats N-key group-by identically to 1-key. +query: q17 +ppl_file: q17.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(count()=[$2], UserID=[$0], SearchPhrase=[$1], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{0, 1}], count()=[SUM($2)], mode=[FINAL], viableBackends=[[lucene, datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchSort(sort0=[$2], sort1=[$0], sort2=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[30], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{0, 1}], count()=[COUNT()], mode=[PARTIAL], viableBackends=[[lucene, datafusion]]) + OpenSearchProject(UserID=[$97], SearchPhrase=[$74], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$2], sort1=[$0], sort2=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[30], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{0, 1}], count()=[COUNT()], mode=[PARTIAL], viableBackends=[[lucene, datafusion]]) + OpenSearchProject(UserID=[$97], SearchPhrase=[$74], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(count()=[$2], UserID=[$0], SearchPhrase=[$1], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{0, 1}], count()=[SUM($2)], mode=[FINAL], viableBackends=[[lucene, datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[UserID@0 as UserID, SearchPhrase@1 as SearchPhrase, count(Int64(1))[count]@2 as count()] + SortPreservingMergeExec: [count(Int64(1))@2 DESC NULLS LAST, UserID@0 ASC, SearchPhrase@1 ASC], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@2 DESC NULLS LAST, UserID@0 ASC, SearchPhrase@1 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[UserID@0 as UserID, SearchPhrase@1 as SearchPhrase], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([UserID@0, SearchPhrase@1], 4), input_partitions=1 + AggregateExec: mode=Partial, gby=[UserID@0 as UserID, SearchPhrase@1 as SearchPhrase], aggr=[count(Int64(1))] + DataSourceExec: file_groups={}, projection=[UserID, SearchPhrase], file_type=parquet + shard_physical_nseg: | + ProjectionExec: expr=[UserID@0 as UserID, SearchPhrase@1 as SearchPhrase, count(Int64(1))[count]@2 as count()] + SortPreservingMergeExec: [count(Int64(1))@2 DESC NULLS LAST, UserID@0 ASC, SearchPhrase@1 ASC], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@2 DESC NULLS LAST, UserID@0 ASC, SearchPhrase@1 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[UserID@0 as UserID, SearchPhrase@1 as SearchPhrase], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([UserID@0, SearchPhrase@1], 4), input_partitions=2 + AggregateExec: mode=Partial, gby=[UserID@0 as UserID, SearchPhrase@1 as SearchPhrase], aggr=[count(Int64(1))] + DataSourceExec: file_groups={}, projection=[UserID, SearchPhrase], file_type=parquet + coord_physical: | + ProjectionExec: expr=[sum(input-0.count())@0 as count(), UserID@1 as UserID, SearchPhrase@2 as SearchPhrase] + SortPreservingMergeExec: [sum(input-0.count())@0 DESC NULLS LAST, UserID@1 ASC, SearchPhrase@2 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[sum(input-0.count())@0 DESC NULLS LAST, UserID@1 ASC, SearchPhrase@2 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[sum(input-0.count())@2 as sum(input-0.count()), UserID@0 as UserID, SearchPhrase@1 as SearchPhrase] + AggregateExec: mode=FinalPartitioned, gby=[UserID@0 as UserID, SearchPhrase@1 as SearchPhrase], aggr=[sum(input-0.count())] + RepartitionExec: partitioning=Hash([UserID@0, SearchPhrase@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[UserID@0 as UserID, SearchPhrase@1 as SearchPhrase], aggr=[sum(input-0.count())] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + StreamingTableExec: partition_sizes=1, projection=[UserID, SearchPhrase, count()] + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(count()=[$2], UserID=[$0], SearchPhrase=[$1], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{0, 1}], count()=[COUNT()], mode=[SINGLE], viableBackends=[[lucene, datafusion]]) + OpenSearchProject(UserID=[$97], SearchPhrase=[$74], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(count()=[$2], UserID=[$0], SearchPhrase=[$1], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{0, 1}], count()=[COUNT()], mode=[SINGLE], viableBackends=[[lucene, datafusion]]) + OpenSearchProject(UserID=[$97], SearchPhrase=[$74], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[count(Int64(1))@0 as count(), UserID@1 as UserID, SearchPhrase@2 as SearchPhrase] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST, UserID@1 ASC, SearchPhrase@2 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@0 DESC NULLS LAST, UserID@1 ASC, SearchPhrase@2 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@2 as count(Int64(1)), UserID@0 as UserID, SearchPhrase@1 as SearchPhrase] + AggregateExec: mode=FinalPartitioned, gby=[UserID@0 as UserID, SearchPhrase@1 as SearchPhrase], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([UserID@0, SearchPhrase@1], 4), input_partitions=1 + AggregateExec: mode=Partial, gby=[UserID@0 as UserID, SearchPhrase@1 as SearchPhrase], aggr=[count(Int64(1))] + DataSourceExec: file_groups={}, projection=[UserID, SearchPhrase], file_type=parquet + shard_physical_nseg: | + ProjectionExec: expr=[count(Int64(1))@0 as count(), UserID@1 as UserID, SearchPhrase@2 as SearchPhrase] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST, UserID@1 ASC, SearchPhrase@2 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@0 DESC NULLS LAST, UserID@1 ASC, SearchPhrase@2 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@2 as count(Int64(1)), UserID@0 as UserID, SearchPhrase@1 as SearchPhrase] + AggregateExec: mode=FinalPartitioned, gby=[UserID@0 as UserID, SearchPhrase@1 as SearchPhrase], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([UserID@0, SearchPhrase@1], 4), input_partitions=2 + AggregateExec: mode=Partial, gby=[UserID@0 as UserID, SearchPhrase@1 as SearchPhrase], aggr=[count(Int64(1))] + DataSourceExec: file_groups={}, projection=[UserID, SearchPhrase], file_type=parquet diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q18.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q18.plan.yaml new file mode 100644 index 0000000000000..6f107ca7318d3 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q18.plan.yaml @@ -0,0 +1,90 @@ +# Sort-by-key TopK: sorts on group keys (UserID, SearchPhrase ASC), not on count() DESC like q17. +# TopK fires (shard fetch=30); DynamicFilter scrubbed (runtime TopK boundary in DataSourceExec). +query: q18 +ppl_file: q18.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$1], sort1=[$2], dir0=[ASC-nulls-first], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$1], sort1=[$2], dir0=[ASC-nulls-first], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(count()=[$2], UserID=[$0], SearchPhrase=[$1], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{0, 1}], count()=[SUM($2)], mode=[FINAL], viableBackends=[[lucene, datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[ASC-nulls-first], dir1=[ASC-nulls-first], fetch=[30], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{0, 1}], count()=[COUNT()], mode=[PARTIAL], viableBackends=[[lucene, datafusion]]) + OpenSearchProject(UserID=[$97], SearchPhrase=[$74], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[ASC-nulls-first], dir1=[ASC-nulls-first], fetch=[30], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{0, 1}], count()=[COUNT()], mode=[PARTIAL], viableBackends=[[lucene, datafusion]]) + OpenSearchProject(UserID=[$97], SearchPhrase=[$74], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$1], sort1=[$2], dir0=[ASC-nulls-first], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$1], sort1=[$2], dir0=[ASC-nulls-first], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(count()=[$2], UserID=[$0], SearchPhrase=[$1], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{0, 1}], count()=[SUM($2)], mode=[FINAL], viableBackends=[[lucene, datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[UserID@0 as UserID, SearchPhrase@1 as SearchPhrase, count(Int64(1))[count]@2 as count()] + SortPreservingMergeExec: [UserID@0 ASC, SearchPhrase@1 ASC], fetch=30 + SortExec: TopK(fetch=30), expr=[UserID@0 ASC, SearchPhrase@1 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[UserID@0 as UserID, SearchPhrase@1 as SearchPhrase], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([UserID@0, SearchPhrase@1], 4), input_partitions=1 + AggregateExec: mode=Partial, gby=[UserID@0 as UserID, SearchPhrase@1 as SearchPhrase], aggr=[count(Int64(1))] + DataSourceExec: file_groups={}, projection=[UserID, SearchPhrase], file_type=parquet, predicate=DynamicFilter [ ] + shard_physical_nseg: | + ProjectionExec: expr=[UserID@0 as UserID, SearchPhrase@1 as SearchPhrase, count(Int64(1))[count]@2 as count()] + SortPreservingMergeExec: [UserID@0 ASC, SearchPhrase@1 ASC], fetch=30 + SortExec: TopK(fetch=30), expr=[UserID@0 ASC, SearchPhrase@1 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[UserID@0 as UserID, SearchPhrase@1 as SearchPhrase], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([UserID@0, SearchPhrase@1], 4), input_partitions=2 + AggregateExec: mode=Partial, gby=[UserID@0 as UserID, SearchPhrase@1 as SearchPhrase], aggr=[count(Int64(1))] + DataSourceExec: file_groups={}, projection=[UserID, SearchPhrase], file_type=parquet, predicate=DynamicFilter [ ] + coord_physical: | + ProjectionExec: expr=[sum(input-0.count())@0 as count(), UserID@1 as UserID, SearchPhrase@2 as SearchPhrase] + SortPreservingMergeExec: [UserID@1 ASC, SearchPhrase@2 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[UserID@1 ASC, SearchPhrase@2 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[sum(input-0.count())@2 as sum(input-0.count()), UserID@0 as UserID, SearchPhrase@1 as SearchPhrase] + AggregateExec: mode=FinalPartitioned, gby=[UserID@0 as UserID, SearchPhrase@1 as SearchPhrase], aggr=[sum(input-0.count())] + RepartitionExec: partitioning=Hash([UserID@0, SearchPhrase@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[UserID@0 as UserID, SearchPhrase@1 as SearchPhrase], aggr=[sum(input-0.count())] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + StreamingTableExec: partition_sizes=1, projection=[UserID, SearchPhrase, count()] + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$1], sort1=[$2], dir0=[ASC-nulls-first], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$1], sort1=[$2], dir0=[ASC-nulls-first], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(count()=[$2], UserID=[$0], SearchPhrase=[$1], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{0, 1}], count()=[COUNT()], mode=[SINGLE], viableBackends=[[lucene, datafusion]]) + OpenSearchProject(UserID=[$97], SearchPhrase=[$74], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$1], sort1=[$2], dir0=[ASC-nulls-first], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$1], sort1=[$2], dir0=[ASC-nulls-first], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(count()=[$2], UserID=[$0], SearchPhrase=[$1], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{0, 1}], count()=[COUNT()], mode=[SINGLE], viableBackends=[[lucene, datafusion]]) + OpenSearchProject(UserID=[$97], SearchPhrase=[$74], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[count(Int64(1))@0 as count(), UserID@1 as UserID, SearchPhrase@2 as SearchPhrase] + SortPreservingMergeExec: [UserID@1 ASC, SearchPhrase@2 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[UserID@1 ASC, SearchPhrase@2 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@2 as count(Int64(1)), UserID@0 as UserID, SearchPhrase@1 as SearchPhrase] + AggregateExec: mode=FinalPartitioned, gby=[UserID@0 as UserID, SearchPhrase@1 as SearchPhrase], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([UserID@0, SearchPhrase@1], 4), input_partitions=1 + AggregateExec: mode=Partial, gby=[UserID@0 as UserID, SearchPhrase@1 as SearchPhrase], aggr=[count(Int64(1))] + DataSourceExec: file_groups={}, projection=[UserID, SearchPhrase], file_type=parquet, predicate=DynamicFilter [ ], pruning_predicate=, required_guarantees=[] + shard_physical_nseg: | + ProjectionExec: expr=[count(Int64(1))@0 as count(), UserID@1 as UserID, SearchPhrase@2 as SearchPhrase] + SortPreservingMergeExec: [UserID@1 ASC, SearchPhrase@2 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[UserID@1 ASC, SearchPhrase@2 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@2 as count(Int64(1)), UserID@0 as UserID, SearchPhrase@1 as SearchPhrase] + AggregateExec: mode=FinalPartitioned, gby=[UserID@0 as UserID, SearchPhrase@1 as SearchPhrase], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([UserID@0, SearchPhrase@1], 4), input_partitions=2 + AggregateExec: mode=Partial, gby=[UserID@0 as UserID, SearchPhrase@1 as SearchPhrase], aggr=[count(Int64(1))] + DataSourceExec: file_groups={}, projection=[UserID, SearchPhrase], file_type=parquet, predicate=DynamicFilter [ ], pruning_predicate=, required_guarantees=[] diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q19.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q19.plan.yaml new file mode 100644 index 0000000000000..8c458adde5771 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q19.plan.yaml @@ -0,0 +1,90 @@ +# EXTRACT expression pushed into DataSourceExec projection (computed at parquet scan level). +# Project node annotated datafusion-only (lucene cannot evaluate temporal extraction). +query: q19 +ppl_file: q19.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], sort3=[$3], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], dir3=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], sort3=[$3], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], dir3=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(count()=[$3], UserID=[$0], m=[$1], SearchPhrase=[$2], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1, 2}], count()=[SUM($3)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchSort(sort0=[$3], sort1=[$0], sort2=[$1], sort3=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], dir3=[ASC-nulls-first], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1, 2}], count()=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(UserID=[$97], m=[ANNOTATED_PROJECT_EXPR(id=0, backends=[datafusion], EXTRACT('minute':VARCHAR, $16))], SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$3], sort1=[$0], sort2=[$1], sort3=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], dir3=[ASC-nulls-first], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1, 2}], count()=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(UserID=[$97], m=[ANNOTATED_PROJECT_EXPR(id=0, backends=[datafusion], EXTRACT('minute':VARCHAR, $16))], SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], sort3=[$3], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], dir3=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], sort3=[$3], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], dir3=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(count()=[$3], UserID=[$0], m=[$1], SearchPhrase=[$2], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1, 2}], count()=[SUM($3)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[UserID@0 as UserID, opensearch_extract(Utf8("minute"),.EventTime)@1 as m, SearchPhrase@2 as SearchPhrase, count(Int64(1))[count]@3 as count()] + SortPreservingMergeExec: [count(Int64(1))@3 DESC NULLS LAST, UserID@0 ASC, opensearch_extract(Utf8("minute"),.EventTime)@1 ASC, SearchPhrase@2 ASC], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@3 DESC NULLS LAST, UserID@0 ASC, opensearch_extract(Utf8("minute"),.EventTime)@1 ASC, SearchPhrase@2 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[UserID@0 as UserID, opensearch_extract(Utf8("minute"),.EventTime)@1 as opensearch_extract(Utf8("minute"),.EventTime), SearchPhrase@2 as SearchPhrase], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([UserID@0, opensearch_extract(Utf8("minute"),.EventTime)@1, SearchPhrase@2], 4), input_partitions=1 + AggregateExec: mode=Partial, gby=[UserID@0 as UserID, opensearch_extract(Utf8("minute"),.EventTime)@1 as opensearch_extract(Utf8("minute"),.EventTime), SearchPhrase@2 as SearchPhrase], aggr=[count(Int64(1))] + DataSourceExec: file_groups={}, projection=[UserID, opensearch_extract(minute, CAST(EventTime@18 AS Timestamp(µs))) as opensearch_extract(Utf8("minute"),.EventTime), SearchPhrase], file_type=parquet + shard_physical_nseg: | + ProjectionExec: expr=[UserID@0 as UserID, opensearch_extract(Utf8("minute"),.EventTime)@1 as m, SearchPhrase@2 as SearchPhrase, count(Int64(1))[count]@3 as count()] + SortPreservingMergeExec: [count(Int64(1))@3 DESC NULLS LAST, UserID@0 ASC, opensearch_extract(Utf8("minute"),.EventTime)@1 ASC, SearchPhrase@2 ASC], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@3 DESC NULLS LAST, UserID@0 ASC, opensearch_extract(Utf8("minute"),.EventTime)@1 ASC, SearchPhrase@2 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[UserID@0 as UserID, opensearch_extract(Utf8("minute"),.EventTime)@1 as opensearch_extract(Utf8("minute"),.EventTime), SearchPhrase@2 as SearchPhrase], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([UserID@0, opensearch_extract(Utf8("minute"),.EventTime)@1, SearchPhrase@2], 4), input_partitions=2 + AggregateExec: mode=Partial, gby=[UserID@0 as UserID, opensearch_extract(Utf8("minute"),.EventTime)@1 as opensearch_extract(Utf8("minute"),.EventTime), SearchPhrase@2 as SearchPhrase], aggr=[count(Int64(1))] + DataSourceExec: file_groups={}, projection=[UserID, opensearch_extract(minute, CAST(EventTime@18 AS Timestamp(µs))) as opensearch_extract(Utf8("minute"),.EventTime), SearchPhrase], file_type=parquet + coord_physical: | + ProjectionExec: expr=[sum(input-0.count())@0 as count(), UserID@1 as UserID, m@2 as m, SearchPhrase@3 as SearchPhrase] + SortPreservingMergeExec: [sum(input-0.count())@0 DESC NULLS LAST, UserID@1 ASC, m@2 ASC, SearchPhrase@3 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[sum(input-0.count())@0 DESC NULLS LAST, UserID@1 ASC, m@2 ASC, SearchPhrase@3 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[sum(input-0.count())@3 as sum(input-0.count()), UserID@0 as UserID, m@1 as m, SearchPhrase@2 as SearchPhrase] + AggregateExec: mode=FinalPartitioned, gby=[UserID@0 as UserID, m@1 as m, SearchPhrase@2 as SearchPhrase], aggr=[sum(input-0.count())] + RepartitionExec: partitioning=Hash([UserID@0, m@1, SearchPhrase@2], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[UserID@0 as UserID, m@1 as m, SearchPhrase@2 as SearchPhrase], aggr=[sum(input-0.count())] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + StreamingTableExec: partition_sizes=1, projection=[UserID, m, SearchPhrase, count()] + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], sort3=[$3], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], dir3=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], sort3=[$3], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], dir3=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(count()=[$3], UserID=[$0], m=[$1], SearchPhrase=[$2], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1, 2}], count()=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(UserID=[$97], m=[ANNOTATED_PROJECT_EXPR(id=0, backends=[datafusion], EXTRACT('minute':VARCHAR, $16))], SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], sort3=[$3], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], dir3=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], sort3=[$3], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], dir3=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(count()=[$3], UserID=[$0], m=[$1], SearchPhrase=[$2], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1, 2}], count()=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(UserID=[$97], m=[ANNOTATED_PROJECT_EXPR(id=0, backends=[datafusion], EXTRACT('minute':VARCHAR, $16))], SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[count(Int64(1))@0 as count(), UserID@1 as UserID, opensearch_extract(Utf8("minute"),.EventTime)@2 as m, SearchPhrase@3 as SearchPhrase] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST, UserID@1 ASC, opensearch_extract(Utf8("minute"),.EventTime)@2 ASC, SearchPhrase@3 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@0 DESC NULLS LAST, UserID@1 ASC, opensearch_extract(Utf8("minute"),.EventTime)@2 ASC, SearchPhrase@3 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@3 as count(Int64(1)), UserID@0 as UserID, opensearch_extract(Utf8("minute"),.EventTime)@1 as opensearch_extract(Utf8("minute"),.EventTime), SearchPhrase@2 as SearchPhrase] + AggregateExec: mode=FinalPartitioned, gby=[UserID@0 as UserID, opensearch_extract(Utf8("minute"),.EventTime)@1 as opensearch_extract(Utf8("minute"),.EventTime), SearchPhrase@2 as SearchPhrase], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([UserID@0, opensearch_extract(Utf8("minute"),.EventTime)@1, SearchPhrase@2], 4), input_partitions=1 + AggregateExec: mode=Partial, gby=[UserID@0 as UserID, opensearch_extract(Utf8("minute"),.EventTime)@1 as opensearch_extract(Utf8("minute"),.EventTime), SearchPhrase@2 as SearchPhrase], aggr=[count(Int64(1))] + DataSourceExec: file_groups={}, projection=[UserID, opensearch_extract(minute, CAST(EventTime@18 AS Timestamp(µs))) as opensearch_extract(Utf8("minute"),.EventTime), SearchPhrase], file_type=parquet + shard_physical_nseg: | + ProjectionExec: expr=[count(Int64(1))@0 as count(), UserID@1 as UserID, opensearch_extract(Utf8("minute"),.EventTime)@2 as m, SearchPhrase@3 as SearchPhrase] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST, UserID@1 ASC, opensearch_extract(Utf8("minute"),.EventTime)@2 ASC, SearchPhrase@3 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@0 DESC NULLS LAST, UserID@1 ASC, opensearch_extract(Utf8("minute"),.EventTime)@2 ASC, SearchPhrase@3 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@3 as count(Int64(1)), UserID@0 as UserID, opensearch_extract(Utf8("minute"),.EventTime)@1 as opensearch_extract(Utf8("minute"),.EventTime), SearchPhrase@2 as SearchPhrase] + AggregateExec: mode=FinalPartitioned, gby=[UserID@0 as UserID, opensearch_extract(Utf8("minute"),.EventTime)@1 as opensearch_extract(Utf8("minute"),.EventTime), SearchPhrase@2 as SearchPhrase], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([UserID@0, opensearch_extract(Utf8("minute"),.EventTime)@1, SearchPhrase@2], 4), input_partitions=2 + AggregateExec: mode=Partial, gby=[UserID@0 as UserID, opensearch_extract(Utf8("minute"),.EventTime)@1 as opensearch_extract(Utf8("minute"),.EventTime), SearchPhrase@2 as SearchPhrase], aggr=[count(Int64(1))] + DataSourceExec: file_groups={}, projection=[UserID, opensearch_extract(minute, CAST(EventTime@18 AS Timestamp(µs))) as opensearch_extract(Utf8("minute"),.EventTime), SearchPhrase], file_type=parquet diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q2.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q2.plan.yaml new file mode 100644 index 0000000000000..ecfcc9a73d2ff --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q2.plan.yaml @@ -0,0 +1,65 @@ +# != filter on numeric AdvEngineID: blocked from lucene delegation (production blocklist). +# Triple parquet pushdown: predicate, pruning_predicate (min/max stats), required_guarantees. +query: q2 +ppl_file: q2.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchProject(count()=[CAST($0):BIGINT NOT NULL], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{}], count()=[SUM($0)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchAggregate(group=[{}], count()=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($0, 0))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchAggregate(group=[{}], count()=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($0, 0))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchProject(count()=[CAST($0):BIGINT NOT NULL], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{}], count()=[SUM($0)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + AggregateExec: mode=Partial, gby=[], aggr=[count(1) as count()] + FilterExec: AdvEngineID@0 != 0, projection=[] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[AdvEngineID], file_type=parquet, predicate=AdvEngineID@20 != 0, pruning_predicate=AdvEngineID_null_count@2 != row_count@3 AND (AdvEngineID_min@0 != 0 OR 0 != AdvEngineID_max@1), required_guarantees=[AdvEngineID not in (0)] + shard_physical_nseg: | + AggregateExec: mode=Partial, gby=[], aggr=[count(1) as count()] + FilterExec: AdvEngineID@0 != 0, projection=[] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[AdvEngineID], file_type=parquet, predicate=AdvEngineID@20 != 0, pruning_predicate=AdvEngineID_null_count@2 != row_count@3 AND (AdvEngineID_min@0 != 0 OR 0 != AdvEngineID_max@1), required_guarantees=[AdvEngineID not in (0)] + coord_physical: | + ProjectionExec: expr=[sum(input-0.count())@0 as count()] + AggregateExec: mode=Final, gby=[], aggr=[sum(input-0.count())] + CoalescePartitionsExec + AggregateExec: mode=Partial, gby=[], aggr=[sum(input-0.count())] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + StreamingTableExec: partition_sizes=1, projection=[count()] + prod1s: + post_cbo: | + OpenSearchAggregate(group=[{}], count()=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($0, 0))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchAggregate(group=[{}], count()=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($0, 0))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + AggregateExec: mode=Final, gby=[], aggr=[count(1) as count()] + CoalescePartitionsExec + AggregateExec: mode=Partial, gby=[], aggr=[count(1) as count()] + FilterExec: AdvEngineID@0 != 0, projection=[] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[AdvEngineID], file_type=parquet, predicate=AdvEngineID@20 != 0, pruning_predicate=AdvEngineID_null_count@2 != row_count@3 AND (AdvEngineID_min@0 != 0 OR 0 != AdvEngineID_max@1), required_guarantees=[AdvEngineID not in (0)] + shard_physical_nseg: | + AggregateExec: mode=Final, gby=[], aggr=[count(1) as count()] + CoalescePartitionsExec + AggregateExec: mode=Partial, gby=[], aggr=[count(1) as count()] + FilterExec: AdvEngineID@0 != 0, projection=[] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[AdvEngineID], file_type=parquet, predicate=AdvEngineID@20 != 0, pruning_predicate=AdvEngineID_null_count@2 != row_count@3 AND (AdvEngineID_min@0 != 0 OR 0 != AdvEngineID_max@1), required_guarantees=[AdvEngineID not in (0)] diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q20.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q20.plan.yaml new file mode 100644 index 0000000000000..d583a6449106e --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q20.plan.yaml @@ -0,0 +1,62 @@ +# Point lookup: numeric UserID equality pushed into parquet DataSourceExec. +# predicate + pruning_predicate + required_guarantees=[UserID in (...)]; no aggregation. +query: q20 +ppl_file: q20.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchSort(fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchProject(UserID=[ANNOTATED_PROJECT_EXPR(id=1, backends=[datafusion], CAST(435090932899640449:BIGINT):BIGINT)], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($97, 435090932899640449))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchProject(UserID=[ANNOTATED_PROJECT_EXPR(id=1, backends=[datafusion], CAST(435090932899640449:BIGINT):BIGINT)], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($97, 435090932899640449))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[435090932899640449 as UserID] + CoalescePartitionsExec: fetch=10000 + FilterExec: UserID@0 = 435090932899640449, fetch=10000 + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[UserID], file_type=parquet, predicate=UserID@89 = 435090932899640449, pruning_predicate=UserID_null_count@2 != row_count@3 AND UserID_min@0 <= 435090932899640449 AND 435090932899640449 <= UserID_max@1, required_guarantees=[UserID in (435090932899640449)] + shard_physical_nseg: | + ProjectionExec: expr=[435090932899640449 as UserID] + CoalescePartitionsExec: fetch=10000 + FilterExec: UserID@0 = 435090932899640449, fetch=10000 + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[UserID], file_type=parquet, predicate=UserID@89 = 435090932899640449, pruning_predicate=UserID_null_count@2 != row_count@3 AND UserID_min@0 <= 435090932899640449 AND 435090932899640449 <= UserID_max@1, required_guarantees=[UserID in (435090932899640449)] + coord_physical: | + StreamingTableExec: partition_sizes=1, projection=[UserID], fetch=10000 + prod1s: + post_cbo: | + OpenSearchSort(fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchProject(UserID=[ANNOTATED_PROJECT_EXPR(id=1, backends=[datafusion], CAST(435090932899640449:BIGINT):BIGINT)], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($97, 435090932899640449))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchProject(UserID=[ANNOTATED_PROJECT_EXPR(id=1, backends=[datafusion], CAST(435090932899640449:BIGINT):BIGINT)], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($97, 435090932899640449))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[435090932899640449 as UserID] + CoalescePartitionsExec: fetch=10000 + FilterExec: UserID@0 = 435090932899640449, fetch=10000 + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[UserID], file_type=parquet, predicate=UserID@89 = 435090932899640449, pruning_predicate=UserID_null_count@2 != row_count@3 AND UserID_min@0 <= 435090932899640449 AND 435090932899640449 <= UserID_max@1, required_guarantees=[UserID in (435090932899640449)] + shard_physical_nseg: | + ProjectionExec: expr=[435090932899640449 as UserID] + CoalescePartitionsExec: fetch=10000 + FilterExec: UserID@0 = 435090932899640449, fetch=10000 + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[UserID], file_type=parquet, predicate=UserID@89 = 435090932899640449, pruning_predicate=UserID_null_count@2 != row_count@3 AND UserID_min@0 <= 435090932899640449 AND 435090932899640449 <= UserID_max@1, required_guarantees=[UserID in (435090932899640449)] diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q21.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q21.plan.yaml new file mode 100644 index 0000000000000..71b121f31a88b --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q21.plan.yaml @@ -0,0 +1,65 @@ +# LIKE wildcard on URL: blocked from lucene (production blocklist includes LIKE). +# Parquet DataSourceExec with ILIKE predicate pushdown; scalar count aggregation. +query: q21 +ppl_file: q21.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchProject(count()=[CAST($0):BIGINT NOT NULL], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{}], count()=[SUM($0)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchAggregate(group=[{}], count()=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], ILIKE($85, '%google%', '\'))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchAggregate(group=[{}], count()=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], ILIKE($85, '%google%', '\'))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchProject(count()=[CAST($0):BIGINT NOT NULL], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{}], count()=[SUM($0)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + AggregateExec: mode=Partial, gby=[], aggr=[count(1) as count()] + FilterExec: URL@0 ILIKE %google%, projection=[] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[URL], file_type=parquet, predicate=URL@27 ILIKE %google% + shard_physical_nseg: | + AggregateExec: mode=Partial, gby=[], aggr=[count(1) as count()] + FilterExec: URL@0 ILIKE %google%, projection=[] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[URL], file_type=parquet, predicate=URL@27 ILIKE %google% + coord_physical: | + ProjectionExec: expr=[sum(input-0.count())@0 as count()] + AggregateExec: mode=Final, gby=[], aggr=[sum(input-0.count())] + CoalescePartitionsExec + AggregateExec: mode=Partial, gby=[], aggr=[sum(input-0.count())] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + StreamingTableExec: partition_sizes=1, projection=[count()] + prod1s: + post_cbo: | + OpenSearchAggregate(group=[{}], count()=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], ILIKE($85, '%google%', '\'))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchAggregate(group=[{}], count()=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], ILIKE($85, '%google%', '\'))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + AggregateExec: mode=Final, gby=[], aggr=[count(1) as count()] + CoalescePartitionsExec + AggregateExec: mode=Partial, gby=[], aggr=[count(1) as count()] + FilterExec: URL@0 ILIKE %google%, projection=[] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[URL], file_type=parquet, predicate=URL@27 ILIKE %google% + shard_physical_nseg: | + AggregateExec: mode=Final, gby=[], aggr=[count(1) as count()] + CoalescePartitionsExec + AggregateExec: mode=Partial, gby=[], aggr=[count(1) as count()] + FilterExec: URL@0 ILIKE %google%, projection=[] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[URL], file_type=parquet, predicate=URL@27 ILIKE %google% diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q22.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q22.plan.yaml new file mode 100644 index 0000000000000..5f6df8d5e5e84 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q22.plan.yaml @@ -0,0 +1,102 @@ +# Multi-filter: LIKE(URL) + SearchPhrase != '' — both blocked from lucene. +# Parquet DataSourceExec with compound predicate pushdown; grouped count TopK. +query: q22 +ppl_file: q22.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$1], SearchPhrase=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], c=[SUM($1)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchSort(sort0=[$1], dir0=[DESC-nulls-last], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], c=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], ILIKE($85, '%google%', '\')), ANNOTATED_PREDICATE(id=1, backends=[datafusion], <>($74, '')))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$1], dir0=[DESC-nulls-last], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], c=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], ILIKE($85, '%google%', '\')), ANNOTATED_PREDICATE(id=1, backends=[datafusion], <>($74, '')))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$1], SearchPhrase=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], c=[SUM($1)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[SearchPhrase@0 as SearchPhrase, count(Int64(1))[count]@1 as c] + SortPreservingMergeExec: [count(Int64(1))@1 DESC NULLS LAST], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@1 DESC NULLS LAST], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[SearchPhrase@0 as SearchPhrase], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([SearchPhrase@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[SearchPhrase@0 as SearchPhrase], aggr=[count(Int64(1))] + FilterExec: URL@1 ILIKE %google% AND SearchPhrase@0 != , projection=[SearchPhrase@0] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[SearchPhrase, URL], file_type=parquet, predicate=URL@27 ILIKE %google% AND SearchPhrase@63 != , pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] + shard_physical_nseg: | + ProjectionExec: expr=[SearchPhrase@0 as SearchPhrase, count(Int64(1))[count]@1 as c] + SortPreservingMergeExec: [count(Int64(1))@1 DESC NULLS LAST], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@1 DESC NULLS LAST], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[SearchPhrase@0 as SearchPhrase], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([SearchPhrase@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[SearchPhrase@0 as SearchPhrase], aggr=[count(Int64(1))] + FilterExec: URL@1 ILIKE %google% AND SearchPhrase@0 != , projection=[SearchPhrase@0] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[SearchPhrase, URL], file_type=parquet, predicate=URL@27 ILIKE %google% AND SearchPhrase@63 != , pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] + coord_physical: | + ProjectionExec: expr=[sum(input-0.c)@0 as c, SearchPhrase@1 as SearchPhrase] + SortPreservingMergeExec: [sum(input-0.c)@0 DESC NULLS LAST], fetch=10 + SortExec: TopK(fetch=10), expr=[sum(input-0.c)@0 DESC NULLS LAST], preserve_partitioning=[true] + ProjectionExec: expr=[sum(input-0.c)@1 as sum(input-0.c), SearchPhrase@0 as SearchPhrase] + AggregateExec: mode=FinalPartitioned, gby=[SearchPhrase@0 as SearchPhrase], aggr=[sum(input-0.c)] + RepartitionExec: partitioning=Hash([SearchPhrase@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[SearchPhrase@0 as SearchPhrase], aggr=[sum(input-0.c)] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + StreamingTableExec: partition_sizes=1, projection=[SearchPhrase, c] + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$1], SearchPhrase=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], c=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], ILIKE($85, '%google%', '\')), ANNOTATED_PREDICATE(id=1, backends=[datafusion], <>($74, '')))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$1], SearchPhrase=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], c=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], ILIKE($85, '%google%', '\')), ANNOTATED_PREDICATE(id=1, backends=[datafusion], <>($74, '')))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[count(Int64(1))@0 as c, SearchPhrase@1 as SearchPhrase] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@0 DESC NULLS LAST], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@1 as count(Int64(1)), SearchPhrase@0 as SearchPhrase] + AggregateExec: mode=FinalPartitioned, gby=[SearchPhrase@0 as SearchPhrase], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([SearchPhrase@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[SearchPhrase@0 as SearchPhrase], aggr=[count(Int64(1))] + FilterExec: URL@1 ILIKE %google% AND SearchPhrase@0 != , projection=[SearchPhrase@0] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[SearchPhrase, URL], file_type=parquet, predicate=URL@27 ILIKE %google% AND SearchPhrase@63 != , pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] + shard_physical_nseg: | + ProjectionExec: expr=[count(Int64(1))@0 as c, SearchPhrase@1 as SearchPhrase] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@0 DESC NULLS LAST], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@1 as count(Int64(1)), SearchPhrase@0 as SearchPhrase] + AggregateExec: mode=FinalPartitioned, gby=[SearchPhrase@0 as SearchPhrase], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([SearchPhrase@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[SearchPhrase@0 as SearchPhrase], aggr=[count(Int64(1))] + FilterExec: URL@1 ILIKE %google% AND SearchPhrase@0 != , projection=[SearchPhrase@0] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[SearchPhrase, URL], file_type=parquet, predicate=URL@27 ILIKE %google% AND SearchPhrase@63 != , pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q23.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q23.plan.yaml new file mode 100644 index 0000000000000..a7a168c652a60 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q23.plan.yaml @@ -0,0 +1,94 @@ +# Triple filter (LIKE, NOT LIKE, !=) — all blocked from lucene. +# Compound predicate on parquet DataSourceExec with grouped count+dc(HLL) and TopK. +query: q23 +ppl_file: q23.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$1], dc(UserID)=[$2], SearchPhrase=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], c=[SUM($1)], dc(UserID)=[APPROX_COUNT_DISTINCT($2)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchSort(sort0=[$1], dir0=[DESC-nulls-last], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], c=[COUNT()], dc(UserID)=[APPROX_COUNT_DISTINCT($1)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(SearchPhrase=[$74], UserID=[$97], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], ILIKE($83, '%Google%', '\')), ANNOTATED_PREDICATE(id=1, backends=[datafusion], <>($74, '')), NOT(ANNOTATED_PREDICATE(id=2, backends=[datafusion], ILIKE($85, '%.google.%', '\'))))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$1], dir0=[DESC-nulls-last], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], c=[COUNT()], dc(UserID)=[APPROX_COUNT_DISTINCT($1)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(SearchPhrase=[$74], UserID=[$97], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], ILIKE($83, '%Google%', '\')), ANNOTATED_PREDICATE(id=1, backends=[datafusion], <>($74, '')), NOT(ANNOTATED_PREDICATE(id=2, backends=[datafusion], ILIKE($85, '%.google.%', '\'))))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$1], dc(UserID)=[$2], SearchPhrase=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], c=[SUM($1)], dc(UserID)=[APPROX_COUNT_DISTINCT($2)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[SearchPhrase@0 as SearchPhrase, count(Int64(1))[count]@1 as c, approx_distinct(.UserID)[hll_registers]@2 as dc(UserID)] + SortPreservingMergeExec: [count(Int64(1))@1 DESC NULLS LAST], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@1 DESC NULLS LAST], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[SearchPhrase@0 as SearchPhrase], aggr=[count(Int64(1)), approx_distinct(.UserID)] + RepartitionExec: partitioning=Hash([SearchPhrase@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[SearchPhrase@0 as SearchPhrase], aggr=[count(Int64(1)), approx_distinct(.UserID)] + FilterExec: Title@1 ILIKE %Google% AND SearchPhrase@0 != AND URL@2 NOT ILIKE %.google.%, projection=[SearchPhrase@0, UserID@3] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[SearchPhrase, Title, URL, UserID], file_type=parquet, predicate=Title@101 ILIKE %Google% AND SearchPhrase@63 != AND URL@27 NOT ILIKE %.google.%, pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] + shard_physical_nseg: | + ProjectionExec: expr=[SearchPhrase@0 as SearchPhrase, count(Int64(1))[count]@1 as c, approx_distinct(.UserID)[hll_registers]@2 as dc(UserID)] + SortPreservingMergeExec: [count(Int64(1))@1 DESC NULLS LAST], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@1 DESC NULLS LAST], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[SearchPhrase@0 as SearchPhrase], aggr=[count(Int64(1)), approx_distinct(.UserID)] + RepartitionExec: partitioning=Hash([SearchPhrase@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[SearchPhrase@0 as SearchPhrase], aggr=[count(Int64(1)), approx_distinct(.UserID)] + FilterExec: Title@1 ILIKE %Google% AND SearchPhrase@0 != AND URL@2 NOT ILIKE %.google.%, projection=[SearchPhrase@0, UserID@3] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[SearchPhrase, Title, URL, UserID], file_type=parquet, predicate=Title@101 ILIKE %Google% AND SearchPhrase@63 != AND URL@27 NOT ILIKE %.google.%, pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$1], dc(UserID)=[$2], SearchPhrase=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], c=[COUNT()], dc(UserID)=[APPROX_COUNT_DISTINCT($1)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(SearchPhrase=[$74], UserID=[$97], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], ILIKE($83, '%Google%', '\')), ANNOTATED_PREDICATE(id=1, backends=[datafusion], <>($74, '')), NOT(ANNOTATED_PREDICATE(id=2, backends=[datafusion], ILIKE($85, '%.google.%', '\'))))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$1], dc(UserID)=[$2], SearchPhrase=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], c=[COUNT()], dc(UserID)=[APPROX_COUNT_DISTINCT($1)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(SearchPhrase=[$74], UserID=[$97], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], ILIKE($83, '%Google%', '\')), ANNOTATED_PREDICATE(id=1, backends=[datafusion], <>($74, '')), NOT(ANNOTATED_PREDICATE(id=2, backends=[datafusion], ILIKE($85, '%.google.%', '\'))))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + RelabelExec: schema=Schema { fields: [Field { name: "c", data_type: Int64 }, Field { name: "dc(UserID)", data_type: Int64, nullable: true }, Field { name: "SearchPhrase", data_type: Utf8View, nullable: true }], metadata: {} } + ProjectionExec: expr=[count(Int64(1))@0 as c, approx_distinct(.UserID)@1 as dc(UserID), SearchPhrase@2 as SearchPhrase] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@0 DESC NULLS LAST], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@1 as count(Int64(1)), approx_distinct(.UserID)@2 as approx_distinct(.UserID), SearchPhrase@0 as SearchPhrase] + AggregateExec: mode=FinalPartitioned, gby=[SearchPhrase@0 as SearchPhrase], aggr=[count(Int64(1)), approx_distinct(.UserID)] + RepartitionExec: partitioning=Hash([SearchPhrase@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[SearchPhrase@0 as SearchPhrase], aggr=[count(Int64(1)), approx_distinct(.UserID)] + FilterExec: Title@1 ILIKE %Google% AND SearchPhrase@0 != AND URL@2 NOT ILIKE %.google.%, projection=[SearchPhrase@0, UserID@3] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[SearchPhrase, Title, URL, UserID], file_type=parquet, predicate=Title@101 ILIKE %Google% AND SearchPhrase@63 != AND URL@27 NOT ILIKE %.google.%, pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] + shard_physical_nseg: | + RelabelExec: schema=Schema { fields: [Field { name: "c", data_type: Int64 }, Field { name: "dc(UserID)", data_type: Int64, nullable: true }, Field { name: "SearchPhrase", data_type: Utf8View, nullable: true }], metadata: {} } + ProjectionExec: expr=[count(Int64(1))@0 as c, approx_distinct(.UserID)@1 as dc(UserID), SearchPhrase@2 as SearchPhrase] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@0 DESC NULLS LAST], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@1 as count(Int64(1)), approx_distinct(.UserID)@2 as approx_distinct(.UserID), SearchPhrase@0 as SearchPhrase] + AggregateExec: mode=FinalPartitioned, gby=[SearchPhrase@0 as SearchPhrase], aggr=[count(Int64(1)), approx_distinct(.UserID)] + RepartitionExec: partitioning=Hash([SearchPhrase@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[SearchPhrase@0 as SearchPhrase], aggr=[count(Int64(1)), approx_distinct(.UserID)] + FilterExec: Title@1 ILIKE %Google% AND SearchPhrase@0 != AND URL@2 NOT ILIKE %.google.%, projection=[SearchPhrase@0, UserID@3] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[SearchPhrase, Title, URL, UserID], file_type=parquet, predicate=Title@101 ILIKE %Google% AND SearchPhrase@63 != AND URL@27 NOT ILIKE %.google.%, pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q24.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q24.plan.yaml new file mode 100644 index 0000000000000..ada394f956b80 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q24.plan.yaml @@ -0,0 +1,65 @@ +# Sort without aggregation: LIKE filter (blocked) + sort EventTime + head 10. +# LATE_MATERIALIZATION stage for sort-key-only fetch; parquet DataSourceExec. +query: q24 +ppl_file: q24.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$16], dir0=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchLateMaterialization(aboveAnchorPhysicalFields=[[AdvEngineID, Age, BrowserCountry, BrowserLanguage, CLID, ClientEventTime, ClientIP, ClientTimeZone, CodeVersion, ConnectTiming, CookieEnable, CounterClass, CounterID, DNSTiming, DontCountHits, EventDate, EventTime, FUniqID, FetchTiming, FlashMajor, FlashMinor, FlashMinor2, FromTag, GoodEvent, HID, HTTPError, HasGCLID, HistoryLength, HitColor, IPNetworkID, Income, Interests, IsArtifical, IsDownload, IsEvent, IsLink, IsMobile, IsNotBounce, IsOldCounter, IsParameter, IsRefresh, JavaEnable, JavascriptEnable, LocalEventTime, MobilePhone, MobilePhoneModel, NetMajor, NetMinor, OS, OpenerName, OpenstatAdID, OpenstatCampaignID, OpenstatServiceName, OpenstatSourceID, OriginalURL, PageCharset, ParamCurrency, ParamCurrencyID, ParamOrderID, ParamPrice, Params, Referer, RefererCategoryID, RefererHash, RefererRegionID, RegionID, RemoteIP, ResolutionDepth, ResolutionHeight, ResolutionWidth, ResponseEndTiming, ResponseStartTiming, Robotness, SearchEngineID, SearchPhrase, SendTiming, Sex, SilverlightVersion1, SilverlightVersion2, SilverlightVersion3, SilverlightVersion4, SocialSourceNetworkID, SocialSourcePage, Title, TraficSourceID, URL, URLCategoryID, URLHash, URLRegionID, UTMCampaign, UTMContent, UTMMedium, UTMSource, UTMTerm, UserAgent, UserAgentMajor, UserAgentMinor, UserID, WatchID, WindowClientHeight, WindowClientWidth, WindowName, WithHash]], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchSort(sort0=[$0], dir0=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], ILIKE($1, '%google%', '\'))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], dir0=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], ILIKE($1, '%google%', '\'))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], dir0=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + [LATE_MATERIALIZATION chosen_backend=datafusion tree_shape=NONE] + OpenSearchLateMaterialization(aboveAnchorPhysicalFields=[[AdvEngineID, Age, BrowserCountry, BrowserLanguage, CLID, ClientEventTime, ClientIP, ClientTimeZone, CodeVersion, ConnectTiming, CookieEnable, CounterClass, CounterID, DNSTiming, DontCountHits, EventDate, EventTime, FUniqID, FetchTiming, FlashMajor, FlashMinor, FlashMinor2, FromTag, GoodEvent, HID, HTTPError, HasGCLID, HistoryLength, HitColor, IPNetworkID, Income, Interests, IsArtifical, IsDownload, IsEvent, IsLink, IsMobile, IsNotBounce, IsOldCounter, IsParameter, IsRefresh, JavaEnable, JavascriptEnable, LocalEventTime, MobilePhone, MobilePhoneModel, NetMajor, NetMinor, OS, OpenerName, OpenstatAdID, OpenstatCampaignID, OpenstatServiceName, OpenstatSourceID, OriginalURL, PageCharset, ParamCurrency, ParamCurrencyID, ParamOrderID, ParamPrice, Params, Referer, RefererCategoryID, RefererHash, RefererRegionID, RegionID, RemoteIP, ResolutionDepth, ResolutionHeight, ResolutionWidth, ResponseEndTiming, ResponseStartTiming, Robotness, SearchEngineID, SearchPhrase, SendTiming, Sex, SilverlightVersion1, SilverlightVersion2, SilverlightVersion3, SilverlightVersion4, SocialSourceNetworkID, SocialSourcePage, Title, TraficSourceID, URL, URLCategoryID, URLHash, URLRegionID, UTMCampaign, UTMContent, UTMMedium, UTMSource, UTMTerm, UserAgent, UserAgentMajor, UserAgentMinor, UserID, WatchID, WindowClientHeight, WindowClientWidth, WindowName, WithHash]], viableBackends=[[datafusion]]) + OpenSearchStageInputScan(childStageId=[1], viableBackends=[[datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$16], dir0=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchStageInputScan(childStageId=[2], viableBackends=[[datafusion]]) + shard_physical_1seg: | + SortExec: TopK(fetch=10), expr=[EventTime@0 ASC], preserve_partitioning=[false] + CooperativeExec + QueryShardExec: partitions=1, segments=1, ordering=unsorted + shard_physical_nseg: | + SortPreservingMergeExec: [EventTime@0 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[EventTime@0 ASC], preserve_partitioning=[true] + QueryShardExec: partitions=2, segments=2, ordering=unsorted + coord_physical: | + SortExec: TopK(fetch=10), expr=[EventTime@0 ASC], preserve_partitioning=[false] + StreamingTableExec: partition_sizes=1, projection=[EventTime, URL, __row_id__, ___ugsi] + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$16], dir0=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$16], dir0=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], ILIKE($85, '%google%', '\'))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$16], dir0=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$16], dir0=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], ILIKE($85, '%google%', '\'))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + SortPreservingMergeExec: [EventTime@16 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[EventTime@16 ASC], preserve_partitioning=[true] + FilterExec: URL@85 ILIKE %google% + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[AdvEngineID, Age, BrowserCountry, BrowserLanguage, CLID, ClientEventTime, ClientIP, ClientTimeZone, CodeVersion, ConnectTiming, CookieEnable, CounterClass, CounterID, DNSTiming, DontCountHits, EventDate, EventTime, FUniqID, FetchTiming, FlashMajor, FlashMinor, FlashMinor2, FromTag, GoodEvent, HID, HTTPError, HasGCLID, HistoryLength, HitColor, IPNetworkID, Income, Interests, IsArtifical, IsDownload, IsEvent, IsLink, IsMobile, IsNotBounce, IsOldCounter, IsParameter, IsRefresh, JavaEnable, JavascriptEnable, LocalEventTime, MobilePhone, MobilePhoneModel, NetMajor, NetMinor, OS, OpenerName, OpenstatAdID, OpenstatCampaignID, OpenstatServiceName, OpenstatSourceID, OriginalURL, PageCharset, ParamCurrency, ParamCurrencyID, ParamOrderID, ParamPrice, Params, Referer, RefererCategoryID, RefererHash, RefererRegionID, RegionID, RemoteIP, ResolutionDepth, ResolutionHeight, ResolutionWidth, ResponseEndTiming, ResponseStartTiming, Robotness, SearchEngineID, SearchPhrase, SendTiming, Sex, SilverlightVersion1, SilverlightVersion2, SilverlightVersion3, SilverlightVersion4, SocialSourceNetworkID, SocialSourcePage, Title, TraficSourceID, URL, URLCategoryID, URLHash, URLRegionID, UTMCampaign, UTMContent, UTMMedium, UTMSource, UTMTerm, UserAgent, UserAgentMajor, UserAgentMinor, UserID, WatchID, WindowClientHeight, WindowClientWidth, WindowName, WithHash], file_type=parquet, predicate=URL@27 ILIKE %google% AND DynamicFilter [ ] + shard_physical_nseg: | + SortPreservingMergeExec: [EventTime@16 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[EventTime@16 ASC], preserve_partitioning=[true] + FilterExec: URL@85 ILIKE %google% + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[AdvEngineID, Age, BrowserCountry, BrowserLanguage, CLID, ClientEventTime, ClientIP, ClientTimeZone, CodeVersion, ConnectTiming, CookieEnable, CounterClass, CounterID, DNSTiming, DontCountHits, EventDate, EventTime, FUniqID, FetchTiming, FlashMajor, FlashMinor, FlashMinor2, FromTag, GoodEvent, HID, HTTPError, HasGCLID, HistoryLength, HitColor, IPNetworkID, Income, Interests, IsArtifical, IsDownload, IsEvent, IsLink, IsMobile, IsNotBounce, IsOldCounter, IsParameter, IsRefresh, JavaEnable, JavascriptEnable, LocalEventTime, MobilePhone, MobilePhoneModel, NetMajor, NetMinor, OS, OpenerName, OpenstatAdID, OpenstatCampaignID, OpenstatServiceName, OpenstatSourceID, OriginalURL, PageCharset, ParamCurrency, ParamCurrencyID, ParamOrderID, ParamPrice, Params, Referer, RefererCategoryID, RefererHash, RefererRegionID, RegionID, RemoteIP, ResolutionDepth, ResolutionHeight, ResolutionWidth, ResponseEndTiming, ResponseStartTiming, Robotness, SearchEngineID, SearchPhrase, SendTiming, Sex, SilverlightVersion1, SilverlightVersion2, SilverlightVersion3, SilverlightVersion4, SocialSourceNetworkID, SocialSourcePage, Title, TraficSourceID, URL, URLCategoryID, URLHash, URLRegionID, UTMCampaign, UTMContent, UTMMedium, UTMSource, UTMTerm, UserAgent, UserAgentMajor, UserAgentMinor, UserID, WatchID, WindowClientHeight, WindowClientWidth, WindowName, WithHash], file_type=parquet, predicate=URL@27 ILIKE %google% AND DynamicFilter [ ] diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q25.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q25.plan.yaml new file mode 100644 index 0000000000000..340c7f1f38bbf --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q25.plan.yaml @@ -0,0 +1,66 @@ +# Sort-only with != filter (blocked): sort EventTime + project SearchPhrase + head 10. +# Pure sort pushdown with projection pruning on parquet DataSourceExec. +query: q25 +ppl_file: q25.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchProject(SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$16], dir0=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchSort(sort0=[$16], dir0=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$16], dir0=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchProject(SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$16], dir0=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + SortPreservingMergeExec: [EventTime@16 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[EventTime@16 ASC], preserve_partitioning=[true] + FilterExec: SearchPhrase@74 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[AdvEngineID, Age, BrowserCountry, BrowserLanguage, CLID, ClientEventTime, ClientIP, ClientTimeZone, CodeVersion, ConnectTiming, CookieEnable, CounterClass, CounterID, DNSTiming, DontCountHits, EventDate, EventTime, FUniqID, FetchTiming, FlashMajor, FlashMinor, FlashMinor2, FromTag, GoodEvent, HID, HTTPError, HasGCLID, HistoryLength, HitColor, IPNetworkID, Income, Interests, IsArtifical, IsDownload, IsEvent, IsLink, IsMobile, IsNotBounce, IsOldCounter, IsParameter, IsRefresh, JavaEnable, JavascriptEnable, LocalEventTime, MobilePhone, MobilePhoneModel, NetMajor, NetMinor, OS, OpenerName, OpenstatAdID, OpenstatCampaignID, OpenstatServiceName, OpenstatSourceID, OriginalURL, PageCharset, ParamCurrency, ParamCurrencyID, ParamOrderID, ParamPrice, Params, Referer, RefererCategoryID, RefererHash, RefererRegionID, RegionID, RemoteIP, ResolutionDepth, ResolutionHeight, ResolutionWidth, ResponseEndTiming, ResponseStartTiming, Robotness, SearchEngineID, SearchPhrase, SendTiming, Sex, SilverlightVersion1, SilverlightVersion2, SilverlightVersion3, SilverlightVersion4, SocialSourceNetworkID, SocialSourcePage, Title, TraficSourceID, URL, URLCategoryID, URLHash, URLRegionID, UTMCampaign, UTMContent, UTMMedium, UTMSource, UTMTerm, UserAgent, UserAgentMajor, UserAgentMinor, UserID, WatchID, WindowClientHeight, WindowClientWidth, WindowName, WithHash], file_type=parquet, predicate=SearchPhrase@63 != AND DynamicFilter [ ], pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] + shard_physical_nseg: | + SortPreservingMergeExec: [EventTime@16 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[EventTime@16 ASC], preserve_partitioning=[true] + FilterExec: SearchPhrase@74 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[AdvEngineID, Age, BrowserCountry, BrowserLanguage, CLID, ClientEventTime, ClientIP, ClientTimeZone, CodeVersion, ConnectTiming, CookieEnable, CounterClass, CounterID, DNSTiming, DontCountHits, EventDate, EventTime, FUniqID, FetchTiming, FlashMajor, FlashMinor, FlashMinor2, FromTag, GoodEvent, HID, HTTPError, HasGCLID, HistoryLength, HitColor, IPNetworkID, Income, Interests, IsArtifical, IsDownload, IsEvent, IsLink, IsMobile, IsNotBounce, IsOldCounter, IsParameter, IsRefresh, JavaEnable, JavascriptEnable, LocalEventTime, MobilePhone, MobilePhoneModel, NetMajor, NetMinor, OS, OpenerName, OpenstatAdID, OpenstatCampaignID, OpenstatServiceName, OpenstatSourceID, OriginalURL, PageCharset, ParamCurrency, ParamCurrencyID, ParamOrderID, ParamPrice, Params, Referer, RefererCategoryID, RefererHash, RefererRegionID, RegionID, RemoteIP, ResolutionDepth, ResolutionHeight, ResolutionWidth, ResponseEndTiming, ResponseStartTiming, Robotness, SearchEngineID, SearchPhrase, SendTiming, Sex, SilverlightVersion1, SilverlightVersion2, SilverlightVersion3, SilverlightVersion4, SocialSourceNetworkID, SocialSourcePage, Title, TraficSourceID, URL, URLCategoryID, URLHash, URLRegionID, UTMCampaign, UTMContent, UTMMedium, UTMSource, UTMTerm, UserAgent, UserAgentMajor, UserAgentMinor, UserID, WatchID, WindowClientHeight, WindowClientWidth, WindowName, WithHash], file_type=parquet, predicate=SearchPhrase@63 != AND DynamicFilter [ ], pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] + coord_physical: | + ProjectionExec: expr=[SearchPhrase@1 as SearchPhrase] + SortExec: TopK(fetch=10), expr=[EventTime@0 ASC], preserve_partitioning=[false] + StreamingTableExec: partition_sizes=1, projection=[EventTime, SearchPhrase] + prod1s: + post_cbo: | + OpenSearchProject(SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$16], dir0=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchProject(SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$16], dir0=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[SearchPhrase@1 as SearchPhrase] + SortPreservingMergeExec: [EventTime@0 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[EventTime@0 ASC], preserve_partitioning=[true] + FilterExec: SearchPhrase@1 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[EventTime, SearchPhrase], file_type=parquet, predicate=SearchPhrase@63 != AND DynamicFilter [ ], pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] + shard_physical_nseg: | + ProjectionExec: expr=[SearchPhrase@1 as SearchPhrase] + SortPreservingMergeExec: [EventTime@0 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[EventTime@0 ASC], preserve_partitioning=[true] + FilterExec: SearchPhrase@1 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[EventTime, SearchPhrase], file_type=parquet, predicate=SearchPhrase@63 != AND DynamicFilter [ ], pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q26.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q26.plan.yaml new file mode 100644 index 0000000000000..7ed0d7c0ef79e --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q26.plan.yaml @@ -0,0 +1,67 @@ +# Sort on keyword column: SearchPhrase sorted alphabetically after != filter (blocked). +# Tests sort pushdown on string field via parquet DataSourceExec. +query: q26 +ppl_file: q26.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$0], dir0=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchSort(sort0=[$0], dir0=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], dir0=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], dir0=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + SortPreservingMergeExec: [SearchPhrase@0 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[SearchPhrase@0 ASC], preserve_partitioning=[true] + FilterExec: SearchPhrase@0 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[SearchPhrase], file_type=parquet, predicate=SearchPhrase@63 != AND DynamicFilter [ ], pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] + shard_physical_nseg: | + SortPreservingMergeExec: [SearchPhrase@0 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[SearchPhrase@0 ASC], preserve_partitioning=[true] + FilterExec: SearchPhrase@0 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[SearchPhrase], file_type=parquet, predicate=SearchPhrase@63 != AND DynamicFilter [ ], pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] + coord_physical: | + SortExec: TopK(fetch=10), expr=[SearchPhrase@0 ASC], preserve_partitioning=[false] + StreamingTableExec: partition_sizes=1, projection=[SearchPhrase] + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$0], dir0=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], dir0=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + SortPreservingMergeExec: [SearchPhrase@0 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[SearchPhrase@0 ASC], preserve_partitioning=[true] + FilterExec: SearchPhrase@0 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[SearchPhrase], file_type=parquet, predicate=SearchPhrase@63 != AND DynamicFilter [ ], pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] + shard_physical_nseg: | + SortPreservingMergeExec: [SearchPhrase@0 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[SearchPhrase@0 ASC], preserve_partitioning=[true] + FilterExec: SearchPhrase@0 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[SearchPhrase], file_type=parquet, predicate=SearchPhrase@63 != AND DynamicFilter [ ], pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q27.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q27.plan.yaml new file mode 100644 index 0000000000000..10956252494a2 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q27.plan.yaml @@ -0,0 +1,66 @@ +# Multi-column sort (EventTime, SearchPhrase) with != filter (blocked from lucene). +# Composite sort key pushdown without aggregation on parquet DataSourceExec. +query: q27 +ppl_file: q27.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchProject(SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$16], sort1=[$74], dir0=[ASC-nulls-first], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchSort(sort0=[$16], sort1=[$74], dir0=[ASC-nulls-first], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$16], sort1=[$74], dir0=[ASC-nulls-first], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchProject(SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$16], sort1=[$74], dir0=[ASC-nulls-first], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + SortPreservingMergeExec: [EventTime@16 ASC, SearchPhrase@74 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[EventTime@16 ASC, SearchPhrase@74 ASC], preserve_partitioning=[true] + FilterExec: SearchPhrase@74 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[AdvEngineID, Age, BrowserCountry, BrowserLanguage, CLID, ClientEventTime, ClientIP, ClientTimeZone, CodeVersion, ConnectTiming, CookieEnable, CounterClass, CounterID, DNSTiming, DontCountHits, EventDate, EventTime, FUniqID, FetchTiming, FlashMajor, FlashMinor, FlashMinor2, FromTag, GoodEvent, HID, HTTPError, HasGCLID, HistoryLength, HitColor, IPNetworkID, Income, Interests, IsArtifical, IsDownload, IsEvent, IsLink, IsMobile, IsNotBounce, IsOldCounter, IsParameter, IsRefresh, JavaEnable, JavascriptEnable, LocalEventTime, MobilePhone, MobilePhoneModel, NetMajor, NetMinor, OS, OpenerName, OpenstatAdID, OpenstatCampaignID, OpenstatServiceName, OpenstatSourceID, OriginalURL, PageCharset, ParamCurrency, ParamCurrencyID, ParamOrderID, ParamPrice, Params, Referer, RefererCategoryID, RefererHash, RefererRegionID, RegionID, RemoteIP, ResolutionDepth, ResolutionHeight, ResolutionWidth, ResponseEndTiming, ResponseStartTiming, Robotness, SearchEngineID, SearchPhrase, SendTiming, Sex, SilverlightVersion1, SilverlightVersion2, SilverlightVersion3, SilverlightVersion4, SocialSourceNetworkID, SocialSourcePage, Title, TraficSourceID, URL, URLCategoryID, URLHash, URLRegionID, UTMCampaign, UTMContent, UTMMedium, UTMSource, UTMTerm, UserAgent, UserAgentMajor, UserAgentMinor, UserID, WatchID, WindowClientHeight, WindowClientWidth, WindowName, WithHash], file_type=parquet, predicate=SearchPhrase@63 != AND DynamicFilter [ ], pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] + shard_physical_nseg: | + SortPreservingMergeExec: [EventTime@16 ASC, SearchPhrase@74 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[EventTime@16 ASC, SearchPhrase@74 ASC], preserve_partitioning=[true] + FilterExec: SearchPhrase@74 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[AdvEngineID, Age, BrowserCountry, BrowserLanguage, CLID, ClientEventTime, ClientIP, ClientTimeZone, CodeVersion, ConnectTiming, CookieEnable, CounterClass, CounterID, DNSTiming, DontCountHits, EventDate, EventTime, FUniqID, FetchTiming, FlashMajor, FlashMinor, FlashMinor2, FromTag, GoodEvent, HID, HTTPError, HasGCLID, HistoryLength, HitColor, IPNetworkID, Income, Interests, IsArtifical, IsDownload, IsEvent, IsLink, IsMobile, IsNotBounce, IsOldCounter, IsParameter, IsRefresh, JavaEnable, JavascriptEnable, LocalEventTime, MobilePhone, MobilePhoneModel, NetMajor, NetMinor, OS, OpenerName, OpenstatAdID, OpenstatCampaignID, OpenstatServiceName, OpenstatSourceID, OriginalURL, PageCharset, ParamCurrency, ParamCurrencyID, ParamOrderID, ParamPrice, Params, Referer, RefererCategoryID, RefererHash, RefererRegionID, RegionID, RemoteIP, ResolutionDepth, ResolutionHeight, ResolutionWidth, ResponseEndTiming, ResponseStartTiming, Robotness, SearchEngineID, SearchPhrase, SendTiming, Sex, SilverlightVersion1, SilverlightVersion2, SilverlightVersion3, SilverlightVersion4, SocialSourceNetworkID, SocialSourcePage, Title, TraficSourceID, URL, URLCategoryID, URLHash, URLRegionID, UTMCampaign, UTMContent, UTMMedium, UTMSource, UTMTerm, UserAgent, UserAgentMajor, UserAgentMinor, UserID, WatchID, WindowClientHeight, WindowClientWidth, WindowName, WithHash], file_type=parquet, predicate=SearchPhrase@63 != AND DynamicFilter [ ], pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] + coord_physical: | + ProjectionExec: expr=[SearchPhrase@1 as SearchPhrase] + SortExec: TopK(fetch=10), expr=[EventTime@0 ASC, SearchPhrase@1 ASC], preserve_partitioning=[false] + StreamingTableExec: partition_sizes=1, projection=[EventTime, SearchPhrase] + prod1s: + post_cbo: | + OpenSearchProject(SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$16], sort1=[$74], dir0=[ASC-nulls-first], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchProject(SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$16], sort1=[$74], dir0=[ASC-nulls-first], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[SearchPhrase@1 as SearchPhrase] + SortPreservingMergeExec: [EventTime@0 ASC, SearchPhrase@1 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[EventTime@0 ASC, SearchPhrase@1 ASC], preserve_partitioning=[true] + FilterExec: SearchPhrase@1 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[EventTime, SearchPhrase], file_type=parquet, predicate=SearchPhrase@63 != AND DynamicFilter [ ], pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] + shard_physical_nseg: | + ProjectionExec: expr=[SearchPhrase@1 as SearchPhrase] + SortPreservingMergeExec: [EventTime@0 ASC, SearchPhrase@1 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[EventTime@0 ASC, SearchPhrase@1 ASC], preserve_partitioning=[true] + FilterExec: SearchPhrase@1 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[EventTime, SearchPhrase], file_type=parquet, predicate=SearchPhrase@63 != AND DynamicFilter [ ], pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q28.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q28.plan.yaml new file mode 100644 index 0000000000000..6a0325faf4c97 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q28.plan.yaml @@ -0,0 +1,117 @@ +# HAVING clause: grouped aggregate + post-agg filter (c > 5) + avg(length(URL)). +# != filter blocked; two-phase: aggregate, then filter on result, then TopK sort. +query: q28 +ppl_file: q28.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[25], viableBackends=[[datafusion]]) + OpenSearchProject(l=[$1], c=[$2], CounterID=[$0], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=7, backends=[datafusion], >($2, 5))], viableBackends=[[datafusion]]) + OpenSearchProject(CounterID=[$0], l=[ANNOTATED_PROJECT_EXPR(id=6, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], CAST($1):DOUBLE), $2))], c=[$3], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], $f1=[SUM($1)], $f2=[SUM($2)], c=[SUM($3)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchSort(sort0=[$1], dir0=[DESC-nulls-last], fetch=[75], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], agg#0=[SUM($1)], agg#1=[COUNT($1)], c=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(CounterID=[$12], $f2=[ANNOTATED_PROJECT_EXPR(id=1, backends=[datafusion], CHAR_LENGTH($85))], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($85, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$1], dir0=[DESC-nulls-last], fetch=[75], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], agg#0=[SUM($1)], agg#1=[COUNT($1)], c=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(CounterID=[$12], $f2=[ANNOTATED_PROJECT_EXPR(id=1, backends=[datafusion], CHAR_LENGTH($85))], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($85, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[25], viableBackends=[[datafusion]]) + OpenSearchProject(l=[$1], c=[$2], CounterID=[$0], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=7, backends=[datafusion], >($2, 5))], viableBackends=[[datafusion]]) + OpenSearchProject(CounterID=[$0], l=[ANNOTATED_PROJECT_EXPR(id=6, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], CAST($1):DOUBLE), $2))], c=[$3], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], $f1=[SUM($1)], $f2=[SUM($2)], c=[SUM($3)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[CounterID@0 as CounterID, sum(character_length(.URL))[sum]@1 as $f1, count(character_length(.URL))[count]@2 as $f2, count(Int64(1))[count]@3 as c] + SortPreservingMergeExec: [sum(character_length(.URL))@1 DESC NULLS LAST], fetch=75 + SortExec: TopK(fetch=75), expr=[sum(character_length(.URL))@1 DESC NULLS LAST], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[CounterID@0 as CounterID], aggr=[sum(character_length(.URL)), count(character_length(.URL)), count(Int64(1))] + RepartitionExec: partitioning=Hash([CounterID@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[CounterID@0 as CounterID], aggr=[sum(character_length(.URL)), count(character_length(.URL)), count(Int64(1))] + ProjectionExec: expr=[CounterID@0 as CounterID, character_length(URL@1) as character_length(.URL)] + FilterExec: URL@1 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[CounterID, URL], file_type=parquet, predicate=URL@27 != , pruning_predicate=URL_null_count@2 != row_count@3 AND (URL_min@0 != OR != URL_max@1), required_guarantees=[URL not in ()] + shard_physical_nseg: | + ProjectionExec: expr=[CounterID@0 as CounterID, sum(character_length(.URL))[sum]@1 as $f1, count(character_length(.URL))[count]@2 as $f2, count(Int64(1))[count]@3 as c] + SortPreservingMergeExec: [sum(character_length(.URL))@1 DESC NULLS LAST], fetch=75 + SortExec: TopK(fetch=75), expr=[sum(character_length(.URL))@1 DESC NULLS LAST], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[CounterID@0 as CounterID], aggr=[sum(character_length(.URL)), count(character_length(.URL)), count(Int64(1))] + RepartitionExec: partitioning=Hash([CounterID@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[CounterID@0 as CounterID], aggr=[sum(character_length(.URL)), count(character_length(.URL)), count(Int64(1))] + ProjectionExec: expr=[CounterID@0 as CounterID, character_length(URL@1) as character_length(.URL)] + FilterExec: URL@1 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[CounterID, URL], file_type=parquet, predicate=URL@27 != , pruning_predicate=URL_null_count@2 != row_count@3 AND (URL_min@0 != OR != URL_max@1), required_guarantees=[URL not in ()] + coord_physical: | + ProjectionExec: expr=[CASE WHEN sum(input-0.$f2) = Int64(0) THEN Float64(NULL) ELSE sum(input-0.$f1) / sum(input-0.$f2) END@0 as l, sum(input-0.c)@1 as c, CounterID@2 as CounterID] + SortPreservingMergeExec: [CASE WHEN sum(input-0.$f2) = Int64(0) THEN Float64(NULL) ELSE sum(input-0.$f1) / sum(input-0.$f2) END@0 DESC NULLS LAST], fetch=25 + SortExec: TopK(fetch=25), expr=[CASE WHEN sum(input-0.$f2) = Int64(0) THEN Float64(NULL) ELSE sum(input-0.$f1) / sum(input-0.$f2) END@0 DESC NULLS LAST], preserve_partitioning=[true] + FilterExec: sum(input-0.c)@1 > 5 + ProjectionExec: expr=[CASE WHEN sum(input-0.$f2)@2 = 0 THEN NULL ELSE CAST(sum(input-0.$f1)@1 AS Float64) / CAST(sum(input-0.$f2)@2 AS Float64) END as CASE WHEN sum(input-0.$f2) = Int64(0) THEN Float64(NULL) ELSE sum(input-0.$f1) / sum(input-0.$f2) END, sum(input-0.c)@3 as sum(input-0.c), CounterID@0 as CounterID] + AggregateExec: mode=FinalPartitioned, gby=[CounterID@0 as CounterID], aggr=[sum(input-0.$f1), sum(input-0.$f2), sum(input-0.c)] + RepartitionExec: partitioning=Hash([CounterID@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[CounterID@0 as CounterID], aggr=[sum(input-0.$f1), sum(input-0.$f2), sum(input-0.c)] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + StreamingTableExec: partition_sizes=1, projection=[CounterID, $f1, $f2, c] + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[25], viableBackends=[[datafusion]]) + OpenSearchProject(l=[$1], c=[$2], CounterID=[$0], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=7, backends=[datafusion], >($2, 5))], viableBackends=[[datafusion]]) + OpenSearchProject(CounterID=[$0], l=[ANNOTATED_PROJECT_EXPR(id=6, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], CAST($1):DOUBLE), $2))], c=[$3], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], agg#0=[SUM($1)], agg#1=[COUNT($1)], c=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(CounterID=[$12], $f2=[ANNOTATED_PROJECT_EXPR(id=1, backends=[datafusion], CHAR_LENGTH($85))], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($85, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[25], viableBackends=[[datafusion]]) + OpenSearchProject(l=[$1], c=[$2], CounterID=[$0], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=7, backends=[datafusion], >($2, 5))], viableBackends=[[datafusion]]) + OpenSearchProject(CounterID=[$0], l=[ANNOTATED_PROJECT_EXPR(id=6, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], CAST($1):DOUBLE), $2))], c=[$3], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], agg#0=[SUM($1)], agg#1=[COUNT($1)], c=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(CounterID=[$12], $f2=[ANNOTATED_PROJECT_EXPR(id=1, backends=[datafusion], CHAR_LENGTH($85))], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($85, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[CASE WHEN count(character_length(.URL)) = Int64(0) THEN Float64(NULL) ELSE sum(character_length(.URL)) / count(character_length(.URL)) END@0 as l, count(Int64(1))@1 as c, CounterID@2 as CounterID] + SortPreservingMergeExec: [CASE WHEN count(character_length(.URL)) = Int64(0) THEN Float64(NULL) ELSE sum(character_length(.URL)) / count(character_length(.URL)) END@0 DESC NULLS LAST], fetch=25 + SortExec: TopK(fetch=25), expr=[CASE WHEN count(character_length(.URL)) = Int64(0) THEN Float64(NULL) ELSE sum(character_length(.URL)) / count(character_length(.URL)) END@0 DESC NULLS LAST], preserve_partitioning=[true] + FilterExec: count(Int64(1))@1 > 5 + ProjectionExec: expr=[CASE WHEN count(character_length(.URL))@2 = 0 THEN NULL ELSE CAST(sum(character_length(.URL))@1 AS Float64) / CAST(count(character_length(.URL))@2 AS Float64) END as CASE WHEN count(character_length(.URL)) = Int64(0) THEN Float64(NULL) ELSE sum(character_length(.URL)) / count(character_length(.URL)) END, count(Int64(1))@3 as count(Int64(1)), CounterID@0 as CounterID] + AggregateExec: mode=FinalPartitioned, gby=[CounterID@0 as CounterID], aggr=[sum(character_length(.URL)), count(character_length(.URL)), count(Int64(1))] + RepartitionExec: partitioning=Hash([CounterID@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[CounterID@0 as CounterID], aggr=[sum(character_length(.URL)), count(character_length(.URL)), count(Int64(1))] + ProjectionExec: expr=[CounterID@0 as CounterID, character_length(URL@1) as character_length(.URL)] + FilterExec: URL@1 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[CounterID, URL], file_type=parquet, predicate=URL@27 != , pruning_predicate=URL_null_count@2 != row_count@3 AND (URL_min@0 != OR != URL_max@1), required_guarantees=[URL not in ()] + shard_physical_nseg: | + ProjectionExec: expr=[CASE WHEN count(character_length(.URL)) = Int64(0) THEN Float64(NULL) ELSE sum(character_length(.URL)) / count(character_length(.URL)) END@0 as l, count(Int64(1))@1 as c, CounterID@2 as CounterID] + SortPreservingMergeExec: [CASE WHEN count(character_length(.URL)) = Int64(0) THEN Float64(NULL) ELSE sum(character_length(.URL)) / count(character_length(.URL)) END@0 DESC NULLS LAST], fetch=25 + SortExec: TopK(fetch=25), expr=[CASE WHEN count(character_length(.URL)) = Int64(0) THEN Float64(NULL) ELSE sum(character_length(.URL)) / count(character_length(.URL)) END@0 DESC NULLS LAST], preserve_partitioning=[true] + FilterExec: count(Int64(1))@1 > 5 + ProjectionExec: expr=[CASE WHEN count(character_length(.URL))@2 = 0 THEN NULL ELSE CAST(sum(character_length(.URL))@1 AS Float64) / CAST(count(character_length(.URL))@2 AS Float64) END as CASE WHEN count(character_length(.URL)) = Int64(0) THEN Float64(NULL) ELSE sum(character_length(.URL)) / count(character_length(.URL)) END, count(Int64(1))@3 as count(Int64(1)), CounterID@0 as CounterID] + AggregateExec: mode=FinalPartitioned, gby=[CounterID@0 as CounterID], aggr=[sum(character_length(.URL)), count(character_length(.URL)), count(Int64(1))] + RepartitionExec: partitioning=Hash([CounterID@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[CounterID@0 as CounterID], aggr=[sum(character_length(.URL)), count(character_length(.URL)), count(Int64(1))] + ProjectionExec: expr=[CounterID@0 as CounterID, character_length(URL@1) as character_length(.URL)] + FilterExec: URL@1 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[CounterID, URL], file_type=parquet, predicate=URL@27 != , pruning_predicate=URL_null_count@2 != row_count@3 AND (URL_min@0 != OR != URL_max@1), required_guarantees=[URL not in ()] diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q29.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q29.plan.yaml new file mode 100644 index 0000000000000..1a6d7c0b81c89 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q29.plan.yaml @@ -0,0 +1,117 @@ +# regexp_replace eval + grouped avg/count/min + HAVING + TopK. +# Most complex eval+agg+filter+sort pipeline; != blocked, parquet DataSourceExec. +query: q29 +ppl_file: q29.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[25], viableBackends=[[datafusion]]) + OpenSearchProject(l=[$1], c=[$2], min(Referer)=[$3], k=[$0], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=9, backends=[datafusion], >($2, 5))], viableBackends=[[datafusion]]) + OpenSearchProject(k=[$0], l=[ANNOTATED_PROJECT_EXPR(id=8, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=7, backends=[datafusion], CAST($1):DOUBLE), $2))], c=[$3], min(Referer)=[$4], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], $f1=[SUM($1)], $f2=[SUM($2)], c=[SUM($3)], min(Referer)=[MIN($4)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchSort(sort0=[$1], dir0=[DESC-nulls-last], fetch=[75], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], agg#0=[SUM($2)], agg#1=[COUNT($2)], c=[COUNT()], min(Referer)=[MIN($1)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(k=[ANNOTATED_PROJECT_EXPR(id=1, backends=[datafusion], REGEXP_REPLACE($61, '^https?://(?:www\.)?([^/]+)/.*$':VARCHAR, '$1'))], Referer=[$61], $f3=[ANNOTATED_PROJECT_EXPR(id=2, backends=[datafusion], CHAR_LENGTH($61))], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($61, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$1], dir0=[DESC-nulls-last], fetch=[75], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], agg#0=[SUM($2)], agg#1=[COUNT($2)], c=[COUNT()], min(Referer)=[MIN($1)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(k=[ANNOTATED_PROJECT_EXPR(id=1, backends=[datafusion], REGEXP_REPLACE($61, '^https?://(?:www\.)?([^/]+)/.*$':VARCHAR, '$1'))], Referer=[$61], $f3=[ANNOTATED_PROJECT_EXPR(id=2, backends=[datafusion], CHAR_LENGTH($61))], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($61, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[25], viableBackends=[[datafusion]]) + OpenSearchProject(l=[$1], c=[$2], min(Referer)=[$3], k=[$0], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=9, backends=[datafusion], >($2, 5))], viableBackends=[[datafusion]]) + OpenSearchProject(k=[$0], l=[ANNOTATED_PROJECT_EXPR(id=8, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=7, backends=[datafusion], CAST($1):DOUBLE), $2))], c=[$3], min(Referer)=[$4], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], $f1=[SUM($1)], $f2=[SUM($2)], c=[SUM($3)], min(Referer)=[MIN($4)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g"))@0 as k, sum(character_length(.Referer))[sum]@1 as $f1, count(character_length(.Referer))[count]@2 as $f2, count(Int64(1))[count]@3 as c, min(.Referer)[value]@4 as min(Referer)] + SortPreservingMergeExec: [sum(character_length(.Referer))@1 DESC NULLS LAST], fetch=75 + SortExec: TopK(fetch=75), expr=[sum(character_length(.Referer))@1 DESC NULLS LAST], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g"))@0 as regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g"))], aggr=[sum(character_length(.Referer)), count(character_length(.Referer)), count(Int64(1)), min(.Referer)] + RepartitionExec: partitioning=Hash([regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g"))@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g"))@0 as regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g"))], aggr=[sum(character_length(.Referer)), count(character_length(.Referer)), count(Int64(1)), min(.Referer)] + ProjectionExec: expr=[regexp_replace(Referer@0, ^https?://(?:www\.)?([^/]+)/.*$, ${1}, g) as regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g")), Referer@0 as Referer, character_length(Referer@0) as character_length(.Referer)] + FilterExec: Referer@0 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[Referer], file_type=parquet, predicate=Referer@100 != , pruning_predicate=Referer_null_count@2 != row_count@3 AND (Referer_min@0 != OR != Referer_max@1), required_guarantees=[Referer not in ()] + shard_physical_nseg: | + ProjectionExec: expr=[regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g"))@0 as k, sum(character_length(.Referer))[sum]@1 as $f1, count(character_length(.Referer))[count]@2 as $f2, count(Int64(1))[count]@3 as c, min(.Referer)[value]@4 as min(Referer)] + SortPreservingMergeExec: [sum(character_length(.Referer))@1 DESC NULLS LAST], fetch=75 + SortExec: TopK(fetch=75), expr=[sum(character_length(.Referer))@1 DESC NULLS LAST], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g"))@0 as regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g"))], aggr=[sum(character_length(.Referer)), count(character_length(.Referer)), count(Int64(1)), min(.Referer)] + RepartitionExec: partitioning=Hash([regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g"))@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g"))@0 as regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g"))], aggr=[sum(character_length(.Referer)), count(character_length(.Referer)), count(Int64(1)), min(.Referer)] + ProjectionExec: expr=[regexp_replace(Referer@0, ^https?://(?:www\.)?([^/]+)/.*$, ${1}, g) as regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g")), Referer@0 as Referer, character_length(Referer@0) as character_length(.Referer)] + FilterExec: Referer@0 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[Referer], file_type=parquet, predicate=Referer@100 != , pruning_predicate=Referer_null_count@2 != row_count@3 AND (Referer_min@0 != OR != Referer_max@1), required_guarantees=[Referer not in ()] + coord_physical: | + ProjectionExec: expr=[CASE WHEN sum(input-0.$f2) = Int64(0) THEN Float64(NULL) ELSE sum(input-0.$f1) / sum(input-0.$f2) END@0 as l, sum(input-0.c)@1 as c, min(input-0.min(Referer))@2 as min(Referer), k@3 as k] + SortPreservingMergeExec: [CASE WHEN sum(input-0.$f2) = Int64(0) THEN Float64(NULL) ELSE sum(input-0.$f1) / sum(input-0.$f2) END@0 DESC NULLS LAST], fetch=25 + SortExec: TopK(fetch=25), expr=[CASE WHEN sum(input-0.$f2) = Int64(0) THEN Float64(NULL) ELSE sum(input-0.$f1) / sum(input-0.$f2) END@0 DESC NULLS LAST], preserve_partitioning=[true] + FilterExec: sum(input-0.c)@1 > 5 + ProjectionExec: expr=[CASE WHEN sum(input-0.$f2)@2 = 0 THEN NULL ELSE CAST(sum(input-0.$f1)@1 AS Float64) / CAST(sum(input-0.$f2)@2 AS Float64) END as CASE WHEN sum(input-0.$f2) = Int64(0) THEN Float64(NULL) ELSE sum(input-0.$f1) / sum(input-0.$f2) END, sum(input-0.c)@3 as sum(input-0.c), min(input-0.min(Referer))@4 as min(input-0.min(Referer)), k@0 as k] + AggregateExec: mode=FinalPartitioned, gby=[k@0 as k], aggr=[sum(input-0.$f1), sum(input-0.$f2), sum(input-0.c), min(input-0.min(Referer))] + RepartitionExec: partitioning=Hash([k@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[k@0 as k], aggr=[sum(input-0.$f1), sum(input-0.$f2), sum(input-0.c), min(input-0.min(Referer))] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + StreamingTableExec: partition_sizes=1, projection=[k, $f1, $f2, c, min(Referer)] + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[25], viableBackends=[[datafusion]]) + OpenSearchProject(l=[$1], c=[$2], min(Referer)=[$3], k=[$0], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=9, backends=[datafusion], >($2, 5))], viableBackends=[[datafusion]]) + OpenSearchProject(k=[$0], l=[ANNOTATED_PROJECT_EXPR(id=8, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=7, backends=[datafusion], CAST($1):DOUBLE), $2))], c=[$3], min(Referer)=[$4], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], agg#0=[SUM($2)], agg#1=[COUNT($2)], c=[COUNT()], min(Referer)=[MIN($1)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(k=[ANNOTATED_PROJECT_EXPR(id=1, backends=[datafusion], REGEXP_REPLACE($61, '^https?://(?:www\.)?([^/]+)/.*$':VARCHAR, '$1'))], Referer=[$61], $f3=[ANNOTATED_PROJECT_EXPR(id=2, backends=[datafusion], CHAR_LENGTH($61))], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($61, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[25], viableBackends=[[datafusion]]) + OpenSearchProject(l=[$1], c=[$2], min(Referer)=[$3], k=[$0], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=9, backends=[datafusion], >($2, 5))], viableBackends=[[datafusion]]) + OpenSearchProject(k=[$0], l=[ANNOTATED_PROJECT_EXPR(id=8, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=7, backends=[datafusion], CAST($1):DOUBLE), $2))], c=[$3], min(Referer)=[$4], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], agg#0=[SUM($2)], agg#1=[COUNT($2)], c=[COUNT()], min(Referer)=[MIN($1)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(k=[ANNOTATED_PROJECT_EXPR(id=1, backends=[datafusion], REGEXP_REPLACE($61, '^https?://(?:www\.)?([^/]+)/.*$':VARCHAR, '$1'))], Referer=[$61], $f3=[ANNOTATED_PROJECT_EXPR(id=2, backends=[datafusion], CHAR_LENGTH($61))], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($61, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[CASE WHEN count(character_length(.Referer)) = Int64(0) THEN Float64(NULL) ELSE sum(character_length(.Referer)) / count(character_length(.Referer)) END@0 as l, count(Int64(1))@1 as c, min(.Referer)@2 as min(Referer), regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g"))@3 as k] + SortPreservingMergeExec: [CASE WHEN count(character_length(.Referer)) = Int64(0) THEN Float64(NULL) ELSE sum(character_length(.Referer)) / count(character_length(.Referer)) END@0 DESC NULLS LAST], fetch=25 + SortExec: TopK(fetch=25), expr=[CASE WHEN count(character_length(.Referer)) = Int64(0) THEN Float64(NULL) ELSE sum(character_length(.Referer)) / count(character_length(.Referer)) END@0 DESC NULLS LAST], preserve_partitioning=[true] + FilterExec: count(Int64(1))@1 > 5 + ProjectionExec: expr=[CASE WHEN count(character_length(.Referer))@2 = 0 THEN NULL ELSE CAST(sum(character_length(.Referer))@1 AS Float64) / CAST(count(character_length(.Referer))@2 AS Float64) END as CASE WHEN count(character_length(.Referer)) = Int64(0) THEN Float64(NULL) ELSE sum(character_length(.Referer)) / count(character_length(.Referer)) END, count(Int64(1))@3 as count(Int64(1)), min(.Referer)@4 as min(.Referer), regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g"))@0 as regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g"))] + AggregateExec: mode=FinalPartitioned, gby=[regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g"))@0 as regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g"))], aggr=[sum(character_length(.Referer)), count(character_length(.Referer)), count(Int64(1)), min(.Referer)] + RepartitionExec: partitioning=Hash([regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g"))@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g"))@0 as regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g"))], aggr=[sum(character_length(.Referer)), count(character_length(.Referer)), count(Int64(1)), min(.Referer)] + ProjectionExec: expr=[regexp_replace(Referer@0, ^https?://(?:www\.)?([^/]+)/.*$, ${1}, g) as regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g")), Referer@0 as Referer, character_length(Referer@0) as character_length(.Referer)] + FilterExec: Referer@0 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[Referer], file_type=parquet, predicate=Referer@100 != , pruning_predicate=Referer_null_count@2 != row_count@3 AND (Referer_min@0 != OR != Referer_max@1), required_guarantees=[Referer not in ()] + shard_physical_nseg: | + ProjectionExec: expr=[CASE WHEN count(character_length(.Referer)) = Int64(0) THEN Float64(NULL) ELSE sum(character_length(.Referer)) / count(character_length(.Referer)) END@0 as l, count(Int64(1))@1 as c, min(.Referer)@2 as min(Referer), regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g"))@3 as k] + SortPreservingMergeExec: [CASE WHEN count(character_length(.Referer)) = Int64(0) THEN Float64(NULL) ELSE sum(character_length(.Referer)) / count(character_length(.Referer)) END@0 DESC NULLS LAST], fetch=25 + SortExec: TopK(fetch=25), expr=[CASE WHEN count(character_length(.Referer)) = Int64(0) THEN Float64(NULL) ELSE sum(character_length(.Referer)) / count(character_length(.Referer)) END@0 DESC NULLS LAST], preserve_partitioning=[true] + FilterExec: count(Int64(1))@1 > 5 + ProjectionExec: expr=[CASE WHEN count(character_length(.Referer))@2 = 0 THEN NULL ELSE CAST(sum(character_length(.Referer))@1 AS Float64) / CAST(count(character_length(.Referer))@2 AS Float64) END as CASE WHEN count(character_length(.Referer)) = Int64(0) THEN Float64(NULL) ELSE sum(character_length(.Referer)) / count(character_length(.Referer)) END, count(Int64(1))@3 as count(Int64(1)), min(.Referer)@4 as min(.Referer), regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g"))@0 as regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g"))] + AggregateExec: mode=FinalPartitioned, gby=[regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g"))@0 as regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g"))], aggr=[sum(character_length(.Referer)), count(character_length(.Referer)), count(Int64(1)), min(.Referer)] + RepartitionExec: partitioning=Hash([regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g"))@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g"))@0 as regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g"))], aggr=[sum(character_length(.Referer)), count(character_length(.Referer)), count(Int64(1)), min(.Referer)] + ProjectionExec: expr=[regexp_replace(Referer@0, ^https?://(?:www\.)?([^/]+)/.*$, ${1}, g) as regexp_replace(.Referer,Utf8("^https?://(?:www\.)?([^/]+)/.*$"),Utf8("${1}"),Utf8("g")), Referer@0 as Referer, character_length(Referer@0) as character_length(.Referer)] + FilterExec: Referer@0 != + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[Referer], file_type=parquet, predicate=Referer@100 != , pruning_predicate=Referer_null_count@2 != row_count@3 AND (Referer_min@0 != OR != Referer_max@1), required_guarantees=[Referer not in ()] diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q3.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q3.plan.yaml new file mode 100644 index 0000000000000..26a098d1dd63a --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q3.plan.yaml @@ -0,0 +1,58 @@ +# AVG decomposed to SUM+COUNT at PARTIAL, recombined with CASE WHEN null-guard at coordinator. +# 4 partial aggregates shipped for 3 user-visible aggregates (avg expansion). +query: q3 +ppl_file: q3.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchProject(sum(AdvEngineID)=[$0], count()=[$1], avg(ResolutionWidth)=[ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=4, backends=[datafusion], CAST($2):DOUBLE), $3))], viableBackends=[[datafusion]]) + OpenSearchProject(sum(AdvEngineID)=[$0], count()=[CAST($1):BIGINT NOT NULL], $f2=[$2], $f3=[CAST($3):BIGINT NOT NULL], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{}], sum(AdvEngineID)=[SUM($0)], count()=[SUM($1)], $f2=[SUM($2)], $f3=[SUM($3)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchAggregate(group=[{}], sum(AdvEngineID)=[SUM($0)], count()=[COUNT()], agg#2=[SUM($1)], agg#3=[COUNT($1)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(AdvEngineID=[$0], ResolutionWidth=[$69], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchAggregate(group=[{}], sum(AdvEngineID)=[SUM($0)], count()=[COUNT()], agg#2=[SUM($1)], agg#3=[COUNT($1)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(AdvEngineID=[$0], ResolutionWidth=[$69], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchProject(sum(AdvEngineID)=[$0], count()=[$1], avg(ResolutionWidth)=[ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=4, backends=[datafusion], CAST($2):DOUBLE), $3))], viableBackends=[[datafusion]]) + OpenSearchProject(sum(AdvEngineID)=[$0], count()=[CAST($1):BIGINT NOT NULL], $f2=[$2], $f3=[CAST($3):BIGINT NOT NULL], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{}], sum(AdvEngineID)=[SUM($0)], count()=[SUM($1)], $f2=[SUM($2)], $f3=[SUM($3)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical: | + AggregateExec: mode=Partial, gby=[], aggr=[sum(.AdvEngineID) as sum(AdvEngineID), count(1) as count(), sum(.ResolutionWidth) as $f2, count(.ResolutionWidth) as $f3] + DataSourceExec: file_groups={}, projection=[AdvEngineID, ResolutionWidth], file_type=parquet + coord_physical: | + ProjectionExec: expr=[sum(input-0.sum(AdvEngineID))@0 as sum(AdvEngineID), sum(input-0.count())@1 as count(), CASE WHEN sum(input-0.$f3)@3 = 0 THEN NULL ELSE CAST(sum(input-0.$f2)@2 AS Float64) / CAST(sum(input-0.$f3)@3 AS Float64) END as avg(ResolutionWidth)] + AggregateExec: mode=Final, gby=[], aggr=[sum(input-0.sum(AdvEngineID)), sum(input-0.count()), sum(input-0.$f2), sum(input-0.$f3)] + CoalescePartitionsExec + AggregateExec: mode=Partial, gby=[], aggr=[sum(input-0.sum(AdvEngineID)), sum(input-0.count()), sum(input-0.$f2), sum(input-0.$f3)] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + StreamingTableExec: partition_sizes=1, projection=[sum(AdvEngineID), count(), $f2, $f3] + prod1s: + post_cbo: | + OpenSearchProject(sum(AdvEngineID)=[$0], count()=[$1], avg(ResolutionWidth)=[ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=4, backends=[datafusion], CAST($2):DOUBLE), $3))], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{}], sum(AdvEngineID)=[SUM($0)], count()=[COUNT()], agg#2=[SUM($1)], agg#3=[COUNT($1)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(AdvEngineID=[$0], ResolutionWidth=[$69], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchProject(sum(AdvEngineID)=[$0], count()=[$1], avg(ResolutionWidth)=[ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=4, backends=[datafusion], CAST($2):DOUBLE), $3))], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{}], sum(AdvEngineID)=[SUM($0)], count()=[COUNT()], agg#2=[SUM($1)], agg#3=[COUNT($1)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(AdvEngineID=[$0], ResolutionWidth=[$69], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[sum(.AdvEngineID)@0 as sum(AdvEngineID), count(Int64(1))@1 as count(), CASE WHEN count(.ResolutionWidth)@3 = 0 THEN NULL ELSE CAST(sum(.ResolutionWidth)@2 AS Float64) / CAST(count(.ResolutionWidth)@3 AS Float64) END as avg(ResolutionWidth)] + AggregateExec: mode=Single, gby=[], aggr=[sum(.AdvEngineID), count(Int64(1)), sum(.ResolutionWidth), count(.ResolutionWidth)] + DataSourceExec: file_groups={}, projection=[AdvEngineID, ResolutionWidth], file_type=parquet + shard_physical_nseg: | + ProjectionExec: expr=[sum(.AdvEngineID)@0 as sum(AdvEngineID), count(Int64(1))@1 as count(), CASE WHEN count(.ResolutionWidth)@3 = 0 THEN NULL ELSE CAST(sum(.ResolutionWidth)@2 AS Float64) / CAST(count(.ResolutionWidth)@3 AS Float64) END as avg(ResolutionWidth)] + AggregateExec: mode=Final, gby=[], aggr=[sum(.AdvEngineID), count(Int64(1)), sum(.ResolutionWidth), count(.ResolutionWidth)] + CoalescePartitionsExec + AggregateExec: mode=Partial, gby=[], aggr=[sum(.AdvEngineID), count(Int64(1)), sum(.ResolutionWidth), count(.ResolutionWidth)] + DataSourceExec: file_groups={}, projection=[AdvEngineID, ResolutionWidth], file_type=parquet diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q30.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q30.plan.yaml new file mode 100644 index 0000000000000..5b06541a7822f --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q30.plan.yaml @@ -0,0 +1,49 @@ +# 10 parallel scalar SUMs with arithmetic expressions pushed into DataSourceExec. +# No sort, no group-by, no filter — pure wide aggregation stress-test. +query: q30 +ppl_file: q30.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchAggregate(group=[{}], sum(ResolutionWidth)=[SUM($0)], sum(ResolutionWidth+1)=[SUM($1)], sum(ResolutionWidth+2)=[SUM($2)], sum(ResolutionWidth+3)=[SUM($3)], sum(ResolutionWidth+4)=[SUM($4)], sum(ResolutionWidth+5)=[SUM($5)], sum(ResolutionWidth+6)=[SUM($6)], sum(ResolutionWidth+7)=[SUM($7)], sum(ResolutionWidth+8)=[SUM($8)], sum(ResolutionWidth+9)=[SUM($9)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchAggregate(group=[{}], sum(ResolutionWidth)=[SUM($0)], sum(ResolutionWidth+1)=[SUM($1)], sum(ResolutionWidth+2)=[SUM($2)], sum(ResolutionWidth+3)=[SUM($3)], sum(ResolutionWidth+4)=[SUM($4)], sum(ResolutionWidth+5)=[SUM($5)], sum(ResolutionWidth+6)=[SUM($6)], sum(ResolutionWidth+7)=[SUM($7)], sum(ResolutionWidth+8)=[SUM($8)], sum(ResolutionWidth+9)=[SUM($9)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(ResolutionWidth=[$69], $f10=[ANNOTATED_PROJECT_EXPR(id=0, backends=[datafusion], +($69, 1))], $f11=[ANNOTATED_PROJECT_EXPR(id=1, backends=[datafusion], +($69, 2))], $f12=[ANNOTATED_PROJECT_EXPR(id=2, backends=[datafusion], +($69, 3))], $f13=[ANNOTATED_PROJECT_EXPR(id=3, backends=[datafusion], +($69, 4))], $f14=[ANNOTATED_PROJECT_EXPR(id=4, backends=[datafusion], +($69, 5))], $f15=[ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], +($69, 6))], $f16=[ANNOTATED_PROJECT_EXPR(id=6, backends=[datafusion], +($69, 7))], $f17=[ANNOTATED_PROJECT_EXPR(id=7, backends=[datafusion], +($69, 8))], $f18=[ANNOTATED_PROJECT_EXPR(id=8, backends=[datafusion], +($69, 9))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchAggregate(group=[{}], sum(ResolutionWidth)=[SUM($0)], sum(ResolutionWidth+1)=[SUM($1)], sum(ResolutionWidth+2)=[SUM($2)], sum(ResolutionWidth+3)=[SUM($3)], sum(ResolutionWidth+4)=[SUM($4)], sum(ResolutionWidth+5)=[SUM($5)], sum(ResolutionWidth+6)=[SUM($6)], sum(ResolutionWidth+7)=[SUM($7)], sum(ResolutionWidth+8)=[SUM($8)], sum(ResolutionWidth+9)=[SUM($9)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(ResolutionWidth=[$69], $f10=[ANNOTATED_PROJECT_EXPR(id=0, backends=[datafusion], +($69, 1))], $f11=[ANNOTATED_PROJECT_EXPR(id=1, backends=[datafusion], +($69, 2))], $f12=[ANNOTATED_PROJECT_EXPR(id=2, backends=[datafusion], +($69, 3))], $f13=[ANNOTATED_PROJECT_EXPR(id=3, backends=[datafusion], +($69, 4))], $f14=[ANNOTATED_PROJECT_EXPR(id=4, backends=[datafusion], +($69, 5))], $f15=[ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], +($69, 6))], $f16=[ANNOTATED_PROJECT_EXPR(id=6, backends=[datafusion], +($69, 7))], $f17=[ANNOTATED_PROJECT_EXPR(id=7, backends=[datafusion], +($69, 8))], $f18=[ANNOTATED_PROJECT_EXPR(id=8, backends=[datafusion], +($69, 9))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchAggregate(group=[{}], sum(ResolutionWidth)=[SUM($0)], sum(ResolutionWidth+1)=[SUM($1)], sum(ResolutionWidth+2)=[SUM($2)], sum(ResolutionWidth+3)=[SUM($3)], sum(ResolutionWidth+4)=[SUM($4)], sum(ResolutionWidth+5)=[SUM($5)], sum(ResolutionWidth+6)=[SUM($6)], sum(ResolutionWidth+7)=[SUM($7)], sum(ResolutionWidth+8)=[SUM($8)], sum(ResolutionWidth+9)=[SUM($9)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical: | + AggregateExec: mode=Partial, gby=[], aggr=[sum(.ResolutionWidth) as sum(ResolutionWidth), sum(.ResolutionWidth + Int32(1)) as sum(ResolutionWidth+1), sum(.ResolutionWidth + Int32(2)) as sum(ResolutionWidth+2), sum(.ResolutionWidth + Int32(3)) as sum(ResolutionWidth+3), sum(.ResolutionWidth + Int32(4)) as sum(ResolutionWidth+4), sum(.ResolutionWidth + Int32(5)) as sum(ResolutionWidth+5), sum(.ResolutionWidth + Int32(6)) as sum(ResolutionWidth+6), sum(.ResolutionWidth + Int32(7)) as sum(ResolutionWidth+7), sum(.ResolutionWidth + Int32(8)) as sum(ResolutionWidth+8), sum(.ResolutionWidth + Int32(9)) as sum(ResolutionWidth+9)] + DataSourceExec: file_groups={}, projection=[ResolutionWidth, CAST(ResolutionWidth@86 AS Int32) + 1 as .ResolutionWidth + Int32(1), CAST(ResolutionWidth@86 AS Int32) + 2 as .ResolutionWidth + Int32(2), CAST(ResolutionWidth@86 AS Int32) + 3 as .ResolutionWidth + Int32(3), CAST(ResolutionWidth@86 AS Int32) + 4 as .ResolutionWidth + Int32(4), CAST(ResolutionWidth@86 AS Int32) + 5 as .ResolutionWidth + Int32(5), CAST(ResolutionWidth@86 AS Int32) + 6 as .ResolutionWidth + Int32(6), CAST(ResolutionWidth@86 AS Int32) + 7 as .ResolutionWidth + Int32(7), CAST(ResolutionWidth@86 AS Int32) + 8 as .ResolutionWidth + Int32(8), CAST(ResolutionWidth@86 AS Int32) + 9 as .ResolutionWidth + Int32(9)], file_type=parquet + coord_physical: | + AggregateExec: mode=Final, gby=[], aggr=[sum(input-0.sum(ResolutionWidth)) as sum(ResolutionWidth), sum(input-0.sum(ResolutionWidth+1)) as sum(ResolutionWidth+1), sum(input-0.sum(ResolutionWidth+2)) as sum(ResolutionWidth+2), sum(input-0.sum(ResolutionWidth+3)) as sum(ResolutionWidth+3), sum(input-0.sum(ResolutionWidth+4)) as sum(ResolutionWidth+4), sum(input-0.sum(ResolutionWidth+5)) as sum(ResolutionWidth+5), sum(input-0.sum(ResolutionWidth+6)) as sum(ResolutionWidth+6), sum(input-0.sum(ResolutionWidth+7)) as sum(ResolutionWidth+7), sum(input-0.sum(ResolutionWidth+8)) as sum(ResolutionWidth+8), sum(input-0.sum(ResolutionWidth+9)) as sum(ResolutionWidth+9)] + CoalescePartitionsExec + AggregateExec: mode=Partial, gby=[], aggr=[sum(input-0.sum(ResolutionWidth)) as sum(ResolutionWidth), sum(input-0.sum(ResolutionWidth+1)) as sum(ResolutionWidth+1), sum(input-0.sum(ResolutionWidth+2)) as sum(ResolutionWidth+2), sum(input-0.sum(ResolutionWidth+3)) as sum(ResolutionWidth+3), sum(input-0.sum(ResolutionWidth+4)) as sum(ResolutionWidth+4), sum(input-0.sum(ResolutionWidth+5)) as sum(ResolutionWidth+5), sum(input-0.sum(ResolutionWidth+6)) as sum(ResolutionWidth+6), sum(input-0.sum(ResolutionWidth+7)) as sum(ResolutionWidth+7), sum(input-0.sum(ResolutionWidth+8)) as sum(ResolutionWidth+8), sum(input-0.sum(ResolutionWidth+9)) as sum(ResolutionWidth+9)] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + StreamingTableExec: partition_sizes=1, projection=[sum(ResolutionWidth), sum(ResolutionWidth+1), sum(ResolutionWidth+2), sum(ResolutionWidth+3), sum(ResolutionWidth+4), sum(ResolutionWidth+5), sum(ResolutionWidth+6), sum(ResolutionWidth+7), sum(ResolutionWidth+8), sum(ResolutionWidth+9)] + prod1s: + post_cbo: | + OpenSearchAggregate(group=[{}], sum(ResolutionWidth)=[SUM($0)], sum(ResolutionWidth+1)=[SUM($1)], sum(ResolutionWidth+2)=[SUM($2)], sum(ResolutionWidth+3)=[SUM($3)], sum(ResolutionWidth+4)=[SUM($4)], sum(ResolutionWidth+5)=[SUM($5)], sum(ResolutionWidth+6)=[SUM($6)], sum(ResolutionWidth+7)=[SUM($7)], sum(ResolutionWidth+8)=[SUM($8)], sum(ResolutionWidth+9)=[SUM($9)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(ResolutionWidth=[$69], $f10=[ANNOTATED_PROJECT_EXPR(id=0, backends=[datafusion], +($69, 1))], $f11=[ANNOTATED_PROJECT_EXPR(id=1, backends=[datafusion], +($69, 2))], $f12=[ANNOTATED_PROJECT_EXPR(id=2, backends=[datafusion], +($69, 3))], $f13=[ANNOTATED_PROJECT_EXPR(id=3, backends=[datafusion], +($69, 4))], $f14=[ANNOTATED_PROJECT_EXPR(id=4, backends=[datafusion], +($69, 5))], $f15=[ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], +($69, 6))], $f16=[ANNOTATED_PROJECT_EXPR(id=6, backends=[datafusion], +($69, 7))], $f17=[ANNOTATED_PROJECT_EXPR(id=7, backends=[datafusion], +($69, 8))], $f18=[ANNOTATED_PROJECT_EXPR(id=8, backends=[datafusion], +($69, 9))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchAggregate(group=[{}], sum(ResolutionWidth)=[SUM($0)], sum(ResolutionWidth+1)=[SUM($1)], sum(ResolutionWidth+2)=[SUM($2)], sum(ResolutionWidth+3)=[SUM($3)], sum(ResolutionWidth+4)=[SUM($4)], sum(ResolutionWidth+5)=[SUM($5)], sum(ResolutionWidth+6)=[SUM($6)], sum(ResolutionWidth+7)=[SUM($7)], sum(ResolutionWidth+8)=[SUM($8)], sum(ResolutionWidth+9)=[SUM($9)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(ResolutionWidth=[$69], $f10=[ANNOTATED_PROJECT_EXPR(id=0, backends=[datafusion], +($69, 1))], $f11=[ANNOTATED_PROJECT_EXPR(id=1, backends=[datafusion], +($69, 2))], $f12=[ANNOTATED_PROJECT_EXPR(id=2, backends=[datafusion], +($69, 3))], $f13=[ANNOTATED_PROJECT_EXPR(id=3, backends=[datafusion], +($69, 4))], $f14=[ANNOTATED_PROJECT_EXPR(id=4, backends=[datafusion], +($69, 5))], $f15=[ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], +($69, 6))], $f16=[ANNOTATED_PROJECT_EXPR(id=6, backends=[datafusion], +($69, 7))], $f17=[ANNOTATED_PROJECT_EXPR(id=7, backends=[datafusion], +($69, 8))], $f18=[ANNOTATED_PROJECT_EXPR(id=8, backends=[datafusion], +($69, 9))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + AggregateExec: mode=Single, gby=[], aggr=[sum(.ResolutionWidth) as sum(ResolutionWidth), sum(.ResolutionWidth + Int32(1)) as sum(ResolutionWidth+1), sum(.ResolutionWidth + Int32(2)) as sum(ResolutionWidth+2), sum(.ResolutionWidth + Int32(3)) as sum(ResolutionWidth+3), sum(.ResolutionWidth + Int32(4)) as sum(ResolutionWidth+4), sum(.ResolutionWidth + Int32(5)) as sum(ResolutionWidth+5), sum(.ResolutionWidth + Int32(6)) as sum(ResolutionWidth+6), sum(.ResolutionWidth + Int32(7)) as sum(ResolutionWidth+7), sum(.ResolutionWidth + Int32(8)) as sum(ResolutionWidth+8), sum(.ResolutionWidth + Int32(9)) as sum(ResolutionWidth+9)] + DataSourceExec: file_groups={}, projection=[ResolutionWidth, CAST(ResolutionWidth@86 AS Int32) + 1 as .ResolutionWidth + Int32(1), CAST(ResolutionWidth@86 AS Int32) + 2 as .ResolutionWidth + Int32(2), CAST(ResolutionWidth@86 AS Int32) + 3 as .ResolutionWidth + Int32(3), CAST(ResolutionWidth@86 AS Int32) + 4 as .ResolutionWidth + Int32(4), CAST(ResolutionWidth@86 AS Int32) + 5 as .ResolutionWidth + Int32(5), CAST(ResolutionWidth@86 AS Int32) + 6 as .ResolutionWidth + Int32(6), CAST(ResolutionWidth@86 AS Int32) + 7 as .ResolutionWidth + Int32(7), CAST(ResolutionWidth@86 AS Int32) + 8 as .ResolutionWidth + Int32(8), CAST(ResolutionWidth@86 AS Int32) + 9 as .ResolutionWidth + Int32(9)], file_type=parquet + shard_physical_nseg: | + AggregateExec: mode=Final, gby=[], aggr=[sum(.ResolutionWidth) as sum(ResolutionWidth), sum(.ResolutionWidth + Int32(1)) as sum(ResolutionWidth+1), sum(.ResolutionWidth + Int32(2)) as sum(ResolutionWidth+2), sum(.ResolutionWidth + Int32(3)) as sum(ResolutionWidth+3), sum(.ResolutionWidth + Int32(4)) as sum(ResolutionWidth+4), sum(.ResolutionWidth + Int32(5)) as sum(ResolutionWidth+5), sum(.ResolutionWidth + Int32(6)) as sum(ResolutionWidth+6), sum(.ResolutionWidth + Int32(7)) as sum(ResolutionWidth+7), sum(.ResolutionWidth + Int32(8)) as sum(ResolutionWidth+8), sum(.ResolutionWidth + Int32(9)) as sum(ResolutionWidth+9)] + CoalescePartitionsExec + AggregateExec: mode=Partial, gby=[], aggr=[sum(.ResolutionWidth) as sum(ResolutionWidth), sum(.ResolutionWidth + Int32(1)) as sum(ResolutionWidth+1), sum(.ResolutionWidth + Int32(2)) as sum(ResolutionWidth+2), sum(.ResolutionWidth + Int32(3)) as sum(ResolutionWidth+3), sum(.ResolutionWidth + Int32(4)) as sum(ResolutionWidth+4), sum(.ResolutionWidth + Int32(5)) as sum(ResolutionWidth+5), sum(.ResolutionWidth + Int32(6)) as sum(ResolutionWidth+6), sum(.ResolutionWidth + Int32(7)) as sum(ResolutionWidth+7), sum(.ResolutionWidth + Int32(8)) as sum(ResolutionWidth+8), sum(.ResolutionWidth + Int32(9)) as sum(ResolutionWidth+9)] + DataSourceExec: file_groups={}, projection=[ResolutionWidth, CAST(ResolutionWidth@86 AS Int32) + 1 as .ResolutionWidth + Int32(1), CAST(ResolutionWidth@86 AS Int32) + 2 as .ResolutionWidth + Int32(2), CAST(ResolutionWidth@86 AS Int32) + 3 as .ResolutionWidth + Int32(3), CAST(ResolutionWidth@86 AS Int32) + 4 as .ResolutionWidth + Int32(4), CAST(ResolutionWidth@86 AS Int32) + 5 as .ResolutionWidth + Int32(5), CAST(ResolutionWidth@86 AS Int32) + 6 as .ResolutionWidth + Int32(6), CAST(ResolutionWidth@86 AS Int32) + 7 as .ResolutionWidth + Int32(7), CAST(ResolutionWidth@86 AS Int32) + 8 as .ResolutionWidth + Int32(8), CAST(ResolutionWidth@86 AS Int32) + 9 as .ResolutionWidth + Int32(9)], file_type=parquet diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q31.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q31.plan.yaml new file mode 100644 index 0000000000000..bf513cd933359 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q31.plan.yaml @@ -0,0 +1,106 @@ +# Multi-stat + != filter (blocked) + multi-column sort with grouped TopK. +# AVG decomposition with composite sort; parquet DataSourceExec. +query: q31 +ppl_file: q31.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$3], sort2=[$4], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$3], sort2=[$4], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$2], sum(IsRefresh)=[$3], avg(ResolutionWidth)=[$4], SearchEngineID=[$0], ClientIP=[$1], viableBackends=[[datafusion]]) + OpenSearchProject(SearchEngineID=[$0], ClientIP=[$1], c=[$2], sum(IsRefresh)=[$3], avg(ResolutionWidth)=[ANNOTATED_PROJECT_EXPR(id=6, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], CAST($4):DOUBLE), $5))], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], c=[SUM($2)], sum(IsRefresh)=[SUM($3)], $f4=[SUM($4)], $f5=[SUM($5)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchSort(sort0=[$2], sort1=[$0], sort2=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], c=[COUNT()], sum(IsRefresh)=[SUM($2)], agg#2=[SUM($3)], agg#3=[COUNT($3)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(SearchEngineID=[$73], ClientIP=[$6], IsRefresh=[$40], ResolutionWidth=[$69], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$2], sort1=[$0], sort2=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], c=[COUNT()], sum(IsRefresh)=[SUM($2)], agg#2=[SUM($3)], agg#3=[COUNT($3)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(SearchEngineID=[$73], ClientIP=[$6], IsRefresh=[$40], ResolutionWidth=[$69], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$3], sort2=[$4], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$3], sort2=[$4], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$2], sum(IsRefresh)=[$3], avg(ResolutionWidth)=[$4], SearchEngineID=[$0], ClientIP=[$1], viableBackends=[[datafusion]]) + OpenSearchProject(SearchEngineID=[$0], ClientIP=[$1], c=[$2], sum(IsRefresh)=[$3], avg(ResolutionWidth)=[ANNOTATED_PROJECT_EXPR(id=6, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], CAST($4):DOUBLE), $5))], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], c=[SUM($2)], sum(IsRefresh)=[SUM($3)], $f4=[SUM($4)], $f5=[SUM($5)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[SearchEngineID@0 as SearchEngineID, ClientIP@1 as ClientIP, count(Int64(1))[count]@2 as c, sum(.IsRefresh)[sum]@3 as sum(IsRefresh), sum(.ResolutionWidth)[sum]@4 as $f4, count(.ResolutionWidth)[count]@5 as $f5] + SortPreservingMergeExec: [count(Int64(1))@2 DESC NULLS LAST, SearchEngineID@0 ASC, ClientIP@1 ASC], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@2 DESC NULLS LAST, SearchEngineID@0 ASC, ClientIP@1 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[SearchEngineID@0 as SearchEngineID, ClientIP@1 as ClientIP], aggr=[count(Int64(1)), sum(.IsRefresh), sum(.ResolutionWidth), count(.ResolutionWidth)] + RepartitionExec: partitioning=Hash([SearchEngineID@0, ClientIP@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[SearchEngineID@0 as SearchEngineID, ClientIP@1 as ClientIP], aggr=[count(Int64(1)), sum(.IsRefresh), sum(.ResolutionWidth), count(.ResolutionWidth)] + FilterExec: SearchPhrase@4 != , projection=[SearchEngineID@3, ClientIP@0, IsRefresh@1, ResolutionWidth@2] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[ClientIP, IsRefresh, ResolutionWidth, SearchEngineID, SearchPhrase], file_type=parquet, predicate=SearchPhrase@63 != , pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] + shard_physical_nseg: | + ProjectionExec: expr=[SearchEngineID@0 as SearchEngineID, ClientIP@1 as ClientIP, count(Int64(1))[count]@2 as c, sum(.IsRefresh)[sum]@3 as sum(IsRefresh), sum(.ResolutionWidth)[sum]@4 as $f4, count(.ResolutionWidth)[count]@5 as $f5] + SortPreservingMergeExec: [count(Int64(1))@2 DESC NULLS LAST, SearchEngineID@0 ASC, ClientIP@1 ASC], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@2 DESC NULLS LAST, SearchEngineID@0 ASC, ClientIP@1 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[SearchEngineID@0 as SearchEngineID, ClientIP@1 as ClientIP], aggr=[count(Int64(1)), sum(.IsRefresh), sum(.ResolutionWidth), count(.ResolutionWidth)] + RepartitionExec: partitioning=Hash([SearchEngineID@0, ClientIP@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[SearchEngineID@0 as SearchEngineID, ClientIP@1 as ClientIP], aggr=[count(Int64(1)), sum(.IsRefresh), sum(.ResolutionWidth), count(.ResolutionWidth)] + FilterExec: SearchPhrase@4 != , projection=[SearchEngineID@3, ClientIP@0, IsRefresh@1, ResolutionWidth@2] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[ClientIP, IsRefresh, ResolutionWidth, SearchEngineID, SearchPhrase], file_type=parquet, predicate=SearchPhrase@63 != , pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] + coord_physical: | + ProjectionExec: expr=[sum(input-0.c)@0 as c, sum(input-0.sum(IsRefresh))@1 as sum(IsRefresh), CASE WHEN sum(input-0.$f5) = Int64(0) THEN Float64(NULL) ELSE sum(input-0.$f4) / sum(input-0.$f5) END@2 as avg(ResolutionWidth), SearchEngineID@3 as SearchEngineID, ClientIP@4 as ClientIP] + SortPreservingMergeExec: [sum(input-0.c)@0 DESC NULLS LAST, SearchEngineID@3 ASC, ClientIP@4 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[sum(input-0.c)@0 DESC NULLS LAST, SearchEngineID@3 ASC, ClientIP@4 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[sum(input-0.c)@2 as sum(input-0.c), sum(input-0.sum(IsRefresh))@3 as sum(input-0.sum(IsRefresh)), CASE WHEN sum(input-0.$f5)@5 = 0 THEN NULL ELSE CAST(sum(input-0.$f4)@4 AS Float64) / CAST(sum(input-0.$f5)@5 AS Float64) END as CASE WHEN sum(input-0.$f5) = Int64(0) THEN Float64(NULL) ELSE sum(input-0.$f4) / sum(input-0.$f5) END, SearchEngineID@0 as SearchEngineID, ClientIP@1 as ClientIP] + AggregateExec: mode=FinalPartitioned, gby=[SearchEngineID@0 as SearchEngineID, ClientIP@1 as ClientIP], aggr=[sum(input-0.c), sum(input-0.sum(IsRefresh)), sum(input-0.$f4), sum(input-0.$f5)] + RepartitionExec: partitioning=Hash([SearchEngineID@0, ClientIP@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[SearchEngineID@0 as SearchEngineID, ClientIP@1 as ClientIP], aggr=[sum(input-0.c), sum(input-0.sum(IsRefresh)), sum(input-0.$f4), sum(input-0.$f5)] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + StreamingTableExec: partition_sizes=1, projection=[SearchEngineID, ClientIP, c, sum(IsRefresh), $f4, $f5] + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$3], sort2=[$4], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$3], sort2=[$4], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$2], sum(IsRefresh)=[$3], avg(ResolutionWidth)=[$4], SearchEngineID=[$0], ClientIP=[$1], viableBackends=[[datafusion]]) + OpenSearchProject(SearchEngineID=[$0], ClientIP=[$1], c=[$2], sum(IsRefresh)=[$3], avg(ResolutionWidth)=[ANNOTATED_PROJECT_EXPR(id=6, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], CAST($4):DOUBLE), $5))], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], c=[COUNT()], sum(IsRefresh)=[SUM($2)], agg#2=[SUM($3)], agg#3=[COUNT($3)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(SearchEngineID=[$73], ClientIP=[$6], IsRefresh=[$40], ResolutionWidth=[$69], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$3], sort2=[$4], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$3], sort2=[$4], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$2], sum(IsRefresh)=[$3], avg(ResolutionWidth)=[$4], SearchEngineID=[$0], ClientIP=[$1], viableBackends=[[datafusion]]) + OpenSearchProject(SearchEngineID=[$0], ClientIP=[$1], c=[$2], sum(IsRefresh)=[$3], avg(ResolutionWidth)=[ANNOTATED_PROJECT_EXPR(id=6, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], CAST($4):DOUBLE), $5))], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], c=[COUNT()], sum(IsRefresh)=[SUM($2)], agg#2=[SUM($3)], agg#3=[COUNT($3)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(SearchEngineID=[$73], ClientIP=[$6], IsRefresh=[$40], ResolutionWidth=[$69], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[count(Int64(1))@0 as c, sum(.IsRefresh)@1 as sum(IsRefresh), CASE WHEN count(.ResolutionWidth) = Int64(0) THEN Float64(NULL) ELSE sum(.ResolutionWidth) / count(.ResolutionWidth) END@2 as avg(ResolutionWidth), SearchEngineID@3 as SearchEngineID, ClientIP@4 as ClientIP] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST, SearchEngineID@3 ASC, ClientIP@4 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@0 DESC NULLS LAST, SearchEngineID@3 ASC, ClientIP@4 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@2 as count(Int64(1)), sum(.IsRefresh)@3 as sum(.IsRefresh), CASE WHEN count(.ResolutionWidth)@5 = 0 THEN NULL ELSE CAST(sum(.ResolutionWidth)@4 AS Float64) / CAST(count(.ResolutionWidth)@5 AS Float64) END as CASE WHEN count(.ResolutionWidth) = Int64(0) THEN Float64(NULL) ELSE sum(.ResolutionWidth) / count(.ResolutionWidth) END, SearchEngineID@0 as SearchEngineID, ClientIP@1 as ClientIP] + AggregateExec: mode=FinalPartitioned, gby=[SearchEngineID@0 as SearchEngineID, ClientIP@1 as ClientIP], aggr=[count(Int64(1)), sum(.IsRefresh), sum(.ResolutionWidth), count(.ResolutionWidth)] + RepartitionExec: partitioning=Hash([SearchEngineID@0, ClientIP@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[SearchEngineID@0 as SearchEngineID, ClientIP@1 as ClientIP], aggr=[count(Int64(1)), sum(.IsRefresh), sum(.ResolutionWidth), count(.ResolutionWidth)] + FilterExec: SearchPhrase@4 != , projection=[SearchEngineID@3, ClientIP@0, IsRefresh@1, ResolutionWidth@2] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[ClientIP, IsRefresh, ResolutionWidth, SearchEngineID, SearchPhrase], file_type=parquet, predicate=SearchPhrase@63 != , pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] + shard_physical_nseg: | + ProjectionExec: expr=[count(Int64(1))@0 as c, sum(.IsRefresh)@1 as sum(IsRefresh), CASE WHEN count(.ResolutionWidth) = Int64(0) THEN Float64(NULL) ELSE sum(.ResolutionWidth) / count(.ResolutionWidth) END@2 as avg(ResolutionWidth), SearchEngineID@3 as SearchEngineID, ClientIP@4 as ClientIP] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST, SearchEngineID@3 ASC, ClientIP@4 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@0 DESC NULLS LAST, SearchEngineID@3 ASC, ClientIP@4 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@2 as count(Int64(1)), sum(.IsRefresh)@3 as sum(.IsRefresh), CASE WHEN count(.ResolutionWidth)@5 = 0 THEN NULL ELSE CAST(sum(.ResolutionWidth)@4 AS Float64) / CAST(count(.ResolutionWidth)@5 AS Float64) END as CASE WHEN count(.ResolutionWidth) = Int64(0) THEN Float64(NULL) ELSE sum(.ResolutionWidth) / count(.ResolutionWidth) END, SearchEngineID@0 as SearchEngineID, ClientIP@1 as ClientIP] + AggregateExec: mode=FinalPartitioned, gby=[SearchEngineID@0 as SearchEngineID, ClientIP@1 as ClientIP], aggr=[count(Int64(1)), sum(.IsRefresh), sum(.ResolutionWidth), count(.ResolutionWidth)] + RepartitionExec: partitioning=Hash([SearchEngineID@0, ClientIP@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[SearchEngineID@0 as SearchEngineID, ClientIP@1 as ClientIP], aggr=[count(Int64(1)), sum(.IsRefresh), sum(.ResolutionWidth), count(.ResolutionWidth)] + FilterExec: SearchPhrase@4 != , projection=[SearchEngineID@3, ClientIP@0, IsRefresh@1, ResolutionWidth@2] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[ClientIP, IsRefresh, ResolutionWidth, SearchEngineID, SearchPhrase], file_type=parquet, predicate=SearchPhrase@63 != , pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q32.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q32.plan.yaml new file mode 100644 index 0000000000000..c22ecb2044843 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q32.plan.yaml @@ -0,0 +1,106 @@ +# Same as q31 but grouped by (WatchID, ClientIP) — higher cardinality group keys. +# Mixed aggregates (count + sum + avg) with TopK on parquet DataSourceExec. +query: q32 +ppl_file: q32.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$2], sum(IsRefresh)=[$3], avg(ResolutionWidth)=[$4], WatchID=[$0], ClientIP=[$1], viableBackends=[[datafusion]]) + OpenSearchProject(WatchID=[$0], ClientIP=[$1], c=[$2], sum(IsRefresh)=[$3], avg(ResolutionWidth)=[ANNOTATED_PROJECT_EXPR(id=6, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], CAST($4):DOUBLE), $5))], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], c=[SUM($2)], sum(IsRefresh)=[SUM($3)], $f4=[SUM($4)], $f5=[SUM($5)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchSort(sort0=[$2], dir0=[DESC-nulls-last], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], c=[COUNT()], sum(IsRefresh)=[SUM($2)], agg#2=[SUM($3)], agg#3=[COUNT($3)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(WatchID=[$98], ClientIP=[$6], IsRefresh=[$40], ResolutionWidth=[$69], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$2], dir0=[DESC-nulls-last], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], c=[COUNT()], sum(IsRefresh)=[SUM($2)], agg#2=[SUM($3)], agg#3=[COUNT($3)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(WatchID=[$98], ClientIP=[$6], IsRefresh=[$40], ResolutionWidth=[$69], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$2], sum(IsRefresh)=[$3], avg(ResolutionWidth)=[$4], WatchID=[$0], ClientIP=[$1], viableBackends=[[datafusion]]) + OpenSearchProject(WatchID=[$0], ClientIP=[$1], c=[$2], sum(IsRefresh)=[$3], avg(ResolutionWidth)=[ANNOTATED_PROJECT_EXPR(id=6, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], CAST($4):DOUBLE), $5))], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], c=[SUM($2)], sum(IsRefresh)=[SUM($3)], $f4=[SUM($4)], $f5=[SUM($5)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[WatchID@0 as WatchID, ClientIP@1 as ClientIP, count(Int64(1))[count]@2 as c, sum(.IsRefresh)[sum]@3 as sum(IsRefresh), sum(.ResolutionWidth)[sum]@4 as $f4, count(.ResolutionWidth)[count]@5 as $f5] + SortPreservingMergeExec: [count(Int64(1))@2 DESC NULLS LAST], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@2 DESC NULLS LAST], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[WatchID@0 as WatchID, ClientIP@1 as ClientIP], aggr=[count(Int64(1)), sum(.IsRefresh), sum(.ResolutionWidth), count(.ResolutionWidth)] + RepartitionExec: partitioning=Hash([WatchID@0, ClientIP@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[WatchID@0 as WatchID, ClientIP@1 as ClientIP], aggr=[count(Int64(1)), sum(.IsRefresh), sum(.ResolutionWidth), count(.ResolutionWidth)] + FilterExec: SearchPhrase@3 != , projection=[WatchID@4, ClientIP@0, IsRefresh@1, ResolutionWidth@2] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[ClientIP, IsRefresh, ResolutionWidth, SearchPhrase, WatchID], file_type=parquet, predicate=SearchPhrase@63 != , pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] + shard_physical_nseg: | + ProjectionExec: expr=[WatchID@0 as WatchID, ClientIP@1 as ClientIP, count(Int64(1))[count]@2 as c, sum(.IsRefresh)[sum]@3 as sum(IsRefresh), sum(.ResolutionWidth)[sum]@4 as $f4, count(.ResolutionWidth)[count]@5 as $f5] + SortPreservingMergeExec: [count(Int64(1))@2 DESC NULLS LAST], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@2 DESC NULLS LAST], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[WatchID@0 as WatchID, ClientIP@1 as ClientIP], aggr=[count(Int64(1)), sum(.IsRefresh), sum(.ResolutionWidth), count(.ResolutionWidth)] + RepartitionExec: partitioning=Hash([WatchID@0, ClientIP@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[WatchID@0 as WatchID, ClientIP@1 as ClientIP], aggr=[count(Int64(1)), sum(.IsRefresh), sum(.ResolutionWidth), count(.ResolutionWidth)] + FilterExec: SearchPhrase@3 != , projection=[WatchID@4, ClientIP@0, IsRefresh@1, ResolutionWidth@2] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[ClientIP, IsRefresh, ResolutionWidth, SearchPhrase, WatchID], file_type=parquet, predicate=SearchPhrase@63 != , pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] + coord_physical: | + ProjectionExec: expr=[sum(input-0.c)@0 as c, sum(input-0.sum(IsRefresh))@1 as sum(IsRefresh), CASE WHEN sum(input-0.$f5) = Int64(0) THEN Float64(NULL) ELSE sum(input-0.$f4) / sum(input-0.$f5) END@2 as avg(ResolutionWidth), WatchID@3 as WatchID, ClientIP@4 as ClientIP] + SortPreservingMergeExec: [sum(input-0.c)@0 DESC NULLS LAST], fetch=10 + SortExec: TopK(fetch=10), expr=[sum(input-0.c)@0 DESC NULLS LAST], preserve_partitioning=[true] + ProjectionExec: expr=[sum(input-0.c)@2 as sum(input-0.c), sum(input-0.sum(IsRefresh))@3 as sum(input-0.sum(IsRefresh)), CASE WHEN sum(input-0.$f5)@5 = 0 THEN NULL ELSE CAST(sum(input-0.$f4)@4 AS Float64) / CAST(sum(input-0.$f5)@5 AS Float64) END as CASE WHEN sum(input-0.$f5) = Int64(0) THEN Float64(NULL) ELSE sum(input-0.$f4) / sum(input-0.$f5) END, WatchID@0 as WatchID, ClientIP@1 as ClientIP] + AggregateExec: mode=FinalPartitioned, gby=[WatchID@0 as WatchID, ClientIP@1 as ClientIP], aggr=[sum(input-0.c), sum(input-0.sum(IsRefresh)), sum(input-0.$f4), sum(input-0.$f5)] + RepartitionExec: partitioning=Hash([WatchID@0, ClientIP@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[WatchID@0 as WatchID, ClientIP@1 as ClientIP], aggr=[sum(input-0.c), sum(input-0.sum(IsRefresh)), sum(input-0.$f4), sum(input-0.$f5)] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + StreamingTableExec: partition_sizes=1, projection=[WatchID, ClientIP, c, sum(IsRefresh), $f4, $f5] + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$2], sum(IsRefresh)=[$3], avg(ResolutionWidth)=[$4], WatchID=[$0], ClientIP=[$1], viableBackends=[[datafusion]]) + OpenSearchProject(WatchID=[$0], ClientIP=[$1], c=[$2], sum(IsRefresh)=[$3], avg(ResolutionWidth)=[ANNOTATED_PROJECT_EXPR(id=6, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], CAST($4):DOUBLE), $5))], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], c=[COUNT()], sum(IsRefresh)=[SUM($2)], agg#2=[SUM($3)], agg#3=[COUNT($3)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(WatchID=[$98], ClientIP=[$6], IsRefresh=[$40], ResolutionWidth=[$69], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$2], sum(IsRefresh)=[$3], avg(ResolutionWidth)=[$4], WatchID=[$0], ClientIP=[$1], viableBackends=[[datafusion]]) + OpenSearchProject(WatchID=[$0], ClientIP=[$1], c=[$2], sum(IsRefresh)=[$3], avg(ResolutionWidth)=[ANNOTATED_PROJECT_EXPR(id=6, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], CAST($4):DOUBLE), $5))], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], c=[COUNT()], sum(IsRefresh)=[SUM($2)], agg#2=[SUM($3)], agg#3=[COUNT($3)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(WatchID=[$98], ClientIP=[$6], IsRefresh=[$40], ResolutionWidth=[$69], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($74, ''))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[count(Int64(1))@0 as c, sum(.IsRefresh)@1 as sum(IsRefresh), CASE WHEN count(.ResolutionWidth) = Int64(0) THEN Float64(NULL) ELSE sum(.ResolutionWidth) / count(.ResolutionWidth) END@2 as avg(ResolutionWidth), WatchID@3 as WatchID, ClientIP@4 as ClientIP] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@0 DESC NULLS LAST], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@2 as count(Int64(1)), sum(.IsRefresh)@3 as sum(.IsRefresh), CASE WHEN count(.ResolutionWidth)@5 = 0 THEN NULL ELSE CAST(sum(.ResolutionWidth)@4 AS Float64) / CAST(count(.ResolutionWidth)@5 AS Float64) END as CASE WHEN count(.ResolutionWidth) = Int64(0) THEN Float64(NULL) ELSE sum(.ResolutionWidth) / count(.ResolutionWidth) END, WatchID@0 as WatchID, ClientIP@1 as ClientIP] + AggregateExec: mode=FinalPartitioned, gby=[WatchID@0 as WatchID, ClientIP@1 as ClientIP], aggr=[count(Int64(1)), sum(.IsRefresh), sum(.ResolutionWidth), count(.ResolutionWidth)] + RepartitionExec: partitioning=Hash([WatchID@0, ClientIP@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[WatchID@0 as WatchID, ClientIP@1 as ClientIP], aggr=[count(Int64(1)), sum(.IsRefresh), sum(.ResolutionWidth), count(.ResolutionWidth)] + FilterExec: SearchPhrase@3 != , projection=[WatchID@4, ClientIP@0, IsRefresh@1, ResolutionWidth@2] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[ClientIP, IsRefresh, ResolutionWidth, SearchPhrase, WatchID], file_type=parquet, predicate=SearchPhrase@63 != , pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] + shard_physical_nseg: | + ProjectionExec: expr=[count(Int64(1))@0 as c, sum(.IsRefresh)@1 as sum(IsRefresh), CASE WHEN count(.ResolutionWidth) = Int64(0) THEN Float64(NULL) ELSE sum(.ResolutionWidth) / count(.ResolutionWidth) END@2 as avg(ResolutionWidth), WatchID@3 as WatchID, ClientIP@4 as ClientIP] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@0 DESC NULLS LAST], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@2 as count(Int64(1)), sum(.IsRefresh)@3 as sum(.IsRefresh), CASE WHEN count(.ResolutionWidth)@5 = 0 THEN NULL ELSE CAST(sum(.ResolutionWidth)@4 AS Float64) / CAST(count(.ResolutionWidth)@5 AS Float64) END as CASE WHEN count(.ResolutionWidth) = Int64(0) THEN Float64(NULL) ELSE sum(.ResolutionWidth) / count(.ResolutionWidth) END, WatchID@0 as WatchID, ClientIP@1 as ClientIP] + AggregateExec: mode=FinalPartitioned, gby=[WatchID@0 as WatchID, ClientIP@1 as ClientIP], aggr=[count(Int64(1)), sum(.IsRefresh), sum(.ResolutionWidth), count(.ResolutionWidth)] + RepartitionExec: partitioning=Hash([WatchID@0, ClientIP@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[WatchID@0 as WatchID, ClientIP@1 as ClientIP], aggr=[count(Int64(1)), sum(.IsRefresh), sum(.ResolutionWidth), count(.ResolutionWidth)] + FilterExec: SearchPhrase@3 != , projection=[WatchID@4, ClientIP@0, IsRefresh@1, ResolutionWidth@2] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[ClientIP, IsRefresh, ResolutionWidth, SearchPhrase, WatchID], file_type=parquet, predicate=SearchPhrase@63 != , pruning_predicate=SearchPhrase_null_count@2 != row_count@3 AND (SearchPhrase_min@0 != OR != SearchPhrase_max@1), required_guarantees=[SearchPhrase not in ()] diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q33.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q33.plan.yaml new file mode 100644 index 0000000000000..39d406f24edec --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q33.plan.yaml @@ -0,0 +1,94 @@ +# Mixed aggregates: COUNT + SUM + AVG in one grouped query with TopK. +# AVG split into SUM+COUNT partial, CASE WHEN null-guard division at coordinator. +query: q33 +ppl_file: q33.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$3], sort2=[$4], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$3], sort2=[$4], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$2], sum(IsRefresh)=[$3], avg(ResolutionWidth)=[$4], WatchID=[$0], ClientIP=[$1], viableBackends=[[datafusion]]) + OpenSearchProject(WatchID=[$0], ClientIP=[$1], c=[$2], sum(IsRefresh)=[$3], avg(ResolutionWidth)=[ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=4, backends=[datafusion], CAST($4):DOUBLE), $5))], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], c=[SUM($2)], sum(IsRefresh)=[SUM($3)], $f4=[SUM($4)], $f5=[SUM($5)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchSort(sort0=[$2], sort1=[$0], sort2=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], c=[COUNT()], sum(IsRefresh)=[SUM($2)], agg#2=[SUM($3)], agg#3=[COUNT($3)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(WatchID=[$98], ClientIP=[$6], IsRefresh=[$40], ResolutionWidth=[$69], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$2], sort1=[$0], sort2=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], c=[COUNT()], sum(IsRefresh)=[SUM($2)], agg#2=[SUM($3)], agg#3=[COUNT($3)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(WatchID=[$98], ClientIP=[$6], IsRefresh=[$40], ResolutionWidth=[$69], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$3], sort2=[$4], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$3], sort2=[$4], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$2], sum(IsRefresh)=[$3], avg(ResolutionWidth)=[$4], WatchID=[$0], ClientIP=[$1], viableBackends=[[datafusion]]) + OpenSearchProject(WatchID=[$0], ClientIP=[$1], c=[$2], sum(IsRefresh)=[$3], avg(ResolutionWidth)=[ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=4, backends=[datafusion], CAST($4):DOUBLE), $5))], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], c=[SUM($2)], sum(IsRefresh)=[SUM($3)], $f4=[SUM($4)], $f5=[SUM($5)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[WatchID@0 as WatchID, ClientIP@1 as ClientIP, count(Int64(1))[count]@2 as c, sum(.IsRefresh)[sum]@3 as sum(IsRefresh), sum(.ResolutionWidth)[sum]@4 as $f4, count(.ResolutionWidth)[count]@5 as $f5] + SortPreservingMergeExec: [count(Int64(1))@2 DESC NULLS LAST, WatchID@0 ASC, ClientIP@1 ASC], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@2 DESC NULLS LAST, WatchID@0 ASC, ClientIP@1 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[WatchID@0 as WatchID, ClientIP@1 as ClientIP], aggr=[count(Int64(1)), sum(.IsRefresh), sum(.ResolutionWidth), count(.ResolutionWidth)] + RepartitionExec: partitioning=Hash([WatchID@0, ClientIP@1], 4), input_partitions=1 + AggregateExec: mode=Partial, gby=[WatchID@0 as WatchID, ClientIP@1 as ClientIP], aggr=[count(Int64(1)), sum(.IsRefresh), sum(.ResolutionWidth), count(.ResolutionWidth)] + DataSourceExec: file_groups={}, projection=[WatchID, ClientIP, IsRefresh, ResolutionWidth], file_type=parquet + shard_physical_nseg: | + ProjectionExec: expr=[WatchID@0 as WatchID, ClientIP@1 as ClientIP, count(Int64(1))[count]@2 as c, sum(.IsRefresh)[sum]@3 as sum(IsRefresh), sum(.ResolutionWidth)[sum]@4 as $f4, count(.ResolutionWidth)[count]@5 as $f5] + SortPreservingMergeExec: [count(Int64(1))@2 DESC NULLS LAST, WatchID@0 ASC, ClientIP@1 ASC], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@2 DESC NULLS LAST, WatchID@0 ASC, ClientIP@1 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[WatchID@0 as WatchID, ClientIP@1 as ClientIP], aggr=[count(Int64(1)), sum(.IsRefresh), sum(.ResolutionWidth), count(.ResolutionWidth)] + RepartitionExec: partitioning=Hash([WatchID@0, ClientIP@1], 4), input_partitions=2 + AggregateExec: mode=Partial, gby=[WatchID@0 as WatchID, ClientIP@1 as ClientIP], aggr=[count(Int64(1)), sum(.IsRefresh), sum(.ResolutionWidth), count(.ResolutionWidth)] + DataSourceExec: file_groups={}, projection=[WatchID, ClientIP, IsRefresh, ResolutionWidth], file_type=parquet + coord_physical: | + ProjectionExec: expr=[sum(input-0.c)@0 as c, sum(input-0.sum(IsRefresh))@1 as sum(IsRefresh), CASE WHEN sum(input-0.$f5) = Int64(0) THEN Float64(NULL) ELSE sum(input-0.$f4) / sum(input-0.$f5) END@2 as avg(ResolutionWidth), WatchID@3 as WatchID, ClientIP@4 as ClientIP] + SortPreservingMergeExec: [sum(input-0.c)@0 DESC NULLS LAST, WatchID@3 ASC, ClientIP@4 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[sum(input-0.c)@0 DESC NULLS LAST, WatchID@3 ASC, ClientIP@4 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[sum(input-0.c)@2 as sum(input-0.c), sum(input-0.sum(IsRefresh))@3 as sum(input-0.sum(IsRefresh)), CASE WHEN sum(input-0.$f5)@5 = 0 THEN NULL ELSE CAST(sum(input-0.$f4)@4 AS Float64) / CAST(sum(input-0.$f5)@5 AS Float64) END as CASE WHEN sum(input-0.$f5) = Int64(0) THEN Float64(NULL) ELSE sum(input-0.$f4) / sum(input-0.$f5) END, WatchID@0 as WatchID, ClientIP@1 as ClientIP] + AggregateExec: mode=FinalPartitioned, gby=[WatchID@0 as WatchID, ClientIP@1 as ClientIP], aggr=[sum(input-0.c), sum(input-0.sum(IsRefresh)), sum(input-0.$f4), sum(input-0.$f5)] + RepartitionExec: partitioning=Hash([WatchID@0, ClientIP@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[WatchID@0 as WatchID, ClientIP@1 as ClientIP], aggr=[sum(input-0.c), sum(input-0.sum(IsRefresh)), sum(input-0.$f4), sum(input-0.$f5)] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + StreamingTableExec: partition_sizes=1, projection=[WatchID, ClientIP, c, sum(IsRefresh), $f4, $f5] + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$3], sort2=[$4], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$3], sort2=[$4], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$2], sum(IsRefresh)=[$3], avg(ResolutionWidth)=[$4], WatchID=[$0], ClientIP=[$1], viableBackends=[[datafusion]]) + OpenSearchProject(WatchID=[$0], ClientIP=[$1], c=[$2], sum(IsRefresh)=[$3], avg(ResolutionWidth)=[ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=4, backends=[datafusion], CAST($4):DOUBLE), $5))], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], c=[COUNT()], sum(IsRefresh)=[SUM($2)], agg#2=[SUM($3)], agg#3=[COUNT($3)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(WatchID=[$98], ClientIP=[$6], IsRefresh=[$40], ResolutionWidth=[$69], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$3], sort2=[$4], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$3], sort2=[$4], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$2], sum(IsRefresh)=[$3], avg(ResolutionWidth)=[$4], WatchID=[$0], ClientIP=[$1], viableBackends=[[datafusion]]) + OpenSearchProject(WatchID=[$0], ClientIP=[$1], c=[$2], sum(IsRefresh)=[$3], avg(ResolutionWidth)=[ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=4, backends=[datafusion], CAST($4):DOUBLE), $5))], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], c=[COUNT()], sum(IsRefresh)=[SUM($2)], agg#2=[SUM($3)], agg#3=[COUNT($3)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(WatchID=[$98], ClientIP=[$6], IsRefresh=[$40], ResolutionWidth=[$69], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[count(Int64(1))@0 as c, sum(.IsRefresh)@1 as sum(IsRefresh), CASE WHEN count(.ResolutionWidth) = Int64(0) THEN Float64(NULL) ELSE sum(.ResolutionWidth) / count(.ResolutionWidth) END@2 as avg(ResolutionWidth), WatchID@3 as WatchID, ClientIP@4 as ClientIP] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST, WatchID@3 ASC, ClientIP@4 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@0 DESC NULLS LAST, WatchID@3 ASC, ClientIP@4 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@2 as count(Int64(1)), sum(.IsRefresh)@3 as sum(.IsRefresh), CASE WHEN count(.ResolutionWidth)@5 = 0 THEN NULL ELSE CAST(sum(.ResolutionWidth)@4 AS Float64) / CAST(count(.ResolutionWidth)@5 AS Float64) END as CASE WHEN count(.ResolutionWidth) = Int64(0) THEN Float64(NULL) ELSE sum(.ResolutionWidth) / count(.ResolutionWidth) END, WatchID@0 as WatchID, ClientIP@1 as ClientIP] + AggregateExec: mode=FinalPartitioned, gby=[WatchID@0 as WatchID, ClientIP@1 as ClientIP], aggr=[count(Int64(1)), sum(.IsRefresh), sum(.ResolutionWidth), count(.ResolutionWidth)] + RepartitionExec: partitioning=Hash([WatchID@0, ClientIP@1], 4), input_partitions=1 + AggregateExec: mode=Partial, gby=[WatchID@0 as WatchID, ClientIP@1 as ClientIP], aggr=[count(Int64(1)), sum(.IsRefresh), sum(.ResolutionWidth), count(.ResolutionWidth)] + DataSourceExec: file_groups={}, projection=[WatchID, ClientIP, IsRefresh, ResolutionWidth], file_type=parquet + shard_physical_nseg: | + ProjectionExec: expr=[count(Int64(1))@0 as c, sum(.IsRefresh)@1 as sum(IsRefresh), CASE WHEN count(.ResolutionWidth) = Int64(0) THEN Float64(NULL) ELSE sum(.ResolutionWidth) / count(.ResolutionWidth) END@2 as avg(ResolutionWidth), WatchID@3 as WatchID, ClientIP@4 as ClientIP] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST, WatchID@3 ASC, ClientIP@4 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@0 DESC NULLS LAST, WatchID@3 ASC, ClientIP@4 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@2 as count(Int64(1)), sum(.IsRefresh)@3 as sum(.IsRefresh), CASE WHEN count(.ResolutionWidth)@5 = 0 THEN NULL ELSE CAST(sum(.ResolutionWidth)@4 AS Float64) / CAST(count(.ResolutionWidth)@5 AS Float64) END as CASE WHEN count(.ResolutionWidth) = Int64(0) THEN Float64(NULL) ELSE sum(.ResolutionWidth) / count(.ResolutionWidth) END, WatchID@0 as WatchID, ClientIP@1 as ClientIP] + AggregateExec: mode=FinalPartitioned, gby=[WatchID@0 as WatchID, ClientIP@1 as ClientIP], aggr=[count(Int64(1)), sum(.IsRefresh), sum(.ResolutionWidth), count(.ResolutionWidth)] + RepartitionExec: partitioning=Hash([WatchID@0, ClientIP@1], 4), input_partitions=2 + AggregateExec: mode=Partial, gby=[WatchID@0 as WatchID, ClientIP@1 as ClientIP], aggr=[count(Int64(1)), sum(.IsRefresh), sum(.ResolutionWidth), count(.ResolutionWidth)] + DataSourceExec: file_groups={}, projection=[WatchID, ClientIP, IsRefresh, ResolutionWidth], file_type=parquet diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q34.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q34.plan.yaml new file mode 100644 index 0000000000000..f5a3106abd076 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q34.plan.yaml @@ -0,0 +1,90 @@ +# High-cardinality grouping by URL (keyword), no filter → parquet DataSourceExec with TopK. +# Structurally simplest TopK plan — single key, single agg, 3x fetch. +query: q34 +ppl_file: q34.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$1], URL=[$0], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{0}], c=[SUM($1)], mode=[FINAL], viableBackends=[[lucene, datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchSort(sort0=[$1], sort1=[$0], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[30], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{0}], c=[COUNT()], mode=[PARTIAL], viableBackends=[[lucene, datafusion]]) + OpenSearchProject(URL=[$85], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$1], sort1=[$0], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[30], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{0}], c=[COUNT()], mode=[PARTIAL], viableBackends=[[lucene, datafusion]]) + OpenSearchProject(URL=[$85], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$1], URL=[$0], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{0}], c=[SUM($1)], mode=[FINAL], viableBackends=[[lucene, datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[URL@0 as URL, count(Int64(1))[count]@1 as c] + SortPreservingMergeExec: [count(Int64(1))@1 DESC NULLS LAST, URL@0 ASC], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@1 DESC NULLS LAST, URL@0 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[URL@0 as URL], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([URL@0], 4), input_partitions=1 + AggregateExec: mode=Partial, gby=[URL@0 as URL], aggr=[count(Int64(1))] + DataSourceExec: file_groups={}, projection=[URL], file_type=parquet + shard_physical_nseg: | + ProjectionExec: expr=[URL@0 as URL, count(Int64(1))[count]@1 as c] + SortPreservingMergeExec: [count(Int64(1))@1 DESC NULLS LAST, URL@0 ASC], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@1 DESC NULLS LAST, URL@0 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[URL@0 as URL], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([URL@0], 4), input_partitions=2 + AggregateExec: mode=Partial, gby=[URL@0 as URL], aggr=[count(Int64(1))] + DataSourceExec: file_groups={}, projection=[URL], file_type=parquet + coord_physical: | + ProjectionExec: expr=[sum(input-0.c)@0 as c, URL@1 as URL] + SortPreservingMergeExec: [sum(input-0.c)@0 DESC NULLS LAST, URL@1 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[sum(input-0.c)@0 DESC NULLS LAST, URL@1 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[sum(input-0.c)@1 as sum(input-0.c), URL@0 as URL] + AggregateExec: mode=FinalPartitioned, gby=[URL@0 as URL], aggr=[sum(input-0.c)] + RepartitionExec: partitioning=Hash([URL@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[URL@0 as URL], aggr=[sum(input-0.c)] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + StreamingTableExec: partition_sizes=1, projection=[URL, c] + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$1], URL=[$0], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{0}], c=[COUNT()], mode=[SINGLE], viableBackends=[[lucene, datafusion]]) + OpenSearchProject(URL=[$85], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$1], URL=[$0], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{0}], c=[COUNT()], mode=[SINGLE], viableBackends=[[lucene, datafusion]]) + OpenSearchProject(URL=[$85], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[count(Int64(1))@0 as c, URL@1 as URL] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST, URL@1 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@0 DESC NULLS LAST, URL@1 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@1 as count(Int64(1)), URL@0 as URL] + AggregateExec: mode=FinalPartitioned, gby=[URL@0 as URL], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([URL@0], 4), input_partitions=1 + AggregateExec: mode=Partial, gby=[URL@0 as URL], aggr=[count(Int64(1))] + DataSourceExec: file_groups={}, projection=[URL], file_type=parquet + shard_physical_nseg: | + ProjectionExec: expr=[count(Int64(1))@0 as c, URL@1 as URL] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST, URL@1 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@0 DESC NULLS LAST, URL@1 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@1 as count(Int64(1)), URL@0 as URL] + AggregateExec: mode=FinalPartitioned, gby=[URL@0 as URL], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([URL@0], 4), input_partitions=2 + AggregateExec: mode=Partial, gby=[URL@0 as URL], aggr=[count(Int64(1))] + DataSourceExec: file_groups={}, projection=[URL], file_type=parquet diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q35.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q35.plan.yaml new file mode 100644 index 0000000000000..a19b87863992f --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q35.plan.yaml @@ -0,0 +1,90 @@ +# ordering_mode=PartiallySorted([0]): constant group key enables DF partial-sort optimization. +# Literal 1 materialized in DataSourceExec projection, not a separate ProjectionExec. +query: q35 +ppl_file: q35.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$2], const=[1], URL=[$1], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{0, 1}], c=[SUM($2)], mode=[FINAL], viableBackends=[[lucene, datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchSort(sort0=[$2], dir0=[DESC-nulls-last], fetch=[30], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{0, 1}], c=[COUNT()], mode=[PARTIAL], viableBackends=[[lucene, datafusion]]) + OpenSearchProject(const=[1], URL=[$85], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$2], dir0=[DESC-nulls-last], fetch=[30], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{0, 1}], c=[COUNT()], mode=[PARTIAL], viableBackends=[[lucene, datafusion]]) + OpenSearchProject(const=[1], URL=[$85], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$2], const=[1], URL=[$1], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{0, 1}], c=[SUM($2)], mode=[FINAL], viableBackends=[[lucene, datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[Int32(1)@0 as const, URL@1 as URL, count(Int64(1))[count]@2 as c] + SortPreservingMergeExec: [count(Int64(1))@2 DESC NULLS LAST], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@2 DESC NULLS LAST], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[Int32(1)@0 as Int32(1), URL@1 as URL], aggr=[count(Int64(1))], ordering_mode=PartiallySorted([0]) + RepartitionExec: partitioning=Hash([Int32(1)@0, URL@1], 4), input_partitions=1 + AggregateExec: mode=Partial, gby=[Int32(1)@0 as Int32(1), URL@1 as URL], aggr=[count(Int64(1))], ordering_mode=PartiallySorted([0]) + DataSourceExec: file_groups={}, projection=[1 as Int32(1), URL], file_type=parquet + shard_physical_nseg: | + ProjectionExec: expr=[Int32(1)@0 as const, URL@1 as URL, count(Int64(1))[count]@2 as c] + SortPreservingMergeExec: [count(Int64(1))@2 DESC NULLS LAST], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@2 DESC NULLS LAST], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[Int32(1)@0 as Int32(1), URL@1 as URL], aggr=[count(Int64(1))], ordering_mode=PartiallySorted([0]) + RepartitionExec: partitioning=Hash([Int32(1)@0, URL@1], 4), input_partitions=2 + AggregateExec: mode=Partial, gby=[Int32(1)@0 as Int32(1), URL@1 as URL], aggr=[count(Int64(1))], ordering_mode=PartiallySorted([0]) + DataSourceExec: file_groups={}, projection=[1 as Int32(1), URL], file_type=parquet + coord_physical: | + ProjectionExec: expr=[sum(input-0.c)@0 as c, Int32(1)@1 as const, URL@2 as URL] + SortPreservingMergeExec: [sum(input-0.c)@0 DESC NULLS LAST], fetch=10 + SortExec: TopK(fetch=10), expr=[sum(input-0.c)@0 DESC NULLS LAST], preserve_partitioning=[true] + ProjectionExec: expr=[sum(input-0.c)@2 as sum(input-0.c), 1 as Int32(1), URL@1 as URL] + AggregateExec: mode=FinalPartitioned, gby=[const@0 as const, URL@1 as URL], aggr=[sum(input-0.c)] + RepartitionExec: partitioning=Hash([const@0, URL@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[const@0 as const, URL@1 as URL], aggr=[sum(input-0.c)] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + StreamingTableExec: partition_sizes=1, projection=[const, URL, c] + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$2], const=[1], URL=[$1], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{0, 1}], c=[COUNT()], mode=[SINGLE], viableBackends=[[lucene, datafusion]]) + OpenSearchProject(const=[1], URL=[$85], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], dir0=[DESC-nulls-last], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$2], const=[1], URL=[$1], viableBackends=[[lucene, datafusion]]) + OpenSearchAggregate(group=[{0, 1}], c=[COUNT()], mode=[SINGLE], viableBackends=[[lucene, datafusion]]) + OpenSearchProject(const=[1], URL=[$85], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[count(Int64(1))@0 as c, Int32(1)@1 as const, URL@2 as URL] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@0 DESC NULLS LAST], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@2 as count(Int64(1)), 1 as Int32(1), URL@1 as URL] + AggregateExec: mode=FinalPartitioned, gby=[Int32(1)@0 as Int32(1), URL@1 as URL], aggr=[count(Int64(1))], ordering_mode=PartiallySorted([0]) + RepartitionExec: partitioning=Hash([Int32(1)@0, URL@1], 4), input_partitions=1 + AggregateExec: mode=Partial, gby=[Int32(1)@0 as Int32(1), URL@1 as URL], aggr=[count(Int64(1))], ordering_mode=PartiallySorted([0]) + DataSourceExec: file_groups={}, projection=[1 as Int32(1), URL], file_type=parquet + shard_physical_nseg: | + ProjectionExec: expr=[count(Int64(1))@0 as c, Int32(1)@1 as const, URL@2 as URL] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@0 DESC NULLS LAST], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@2 as count(Int64(1)), 1 as Int32(1), URL@1 as URL] + AggregateExec: mode=FinalPartitioned, gby=[Int32(1)@0 as Int32(1), URL@1 as URL], aggr=[count(Int64(1))], ordering_mode=PartiallySorted([0]) + RepartitionExec: partitioning=Hash([Int32(1)@0, URL@1], 4), input_partitions=2 + AggregateExec: mode=Partial, gby=[Int32(1)@0 as Int32(1), URL@1 as URL], aggr=[count(Int64(1))], ordering_mode=PartiallySorted([0]) + DataSourceExec: file_groups={}, projection=[1 as Int32(1), URL], file_type=parquet diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q36.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q36.plan.yaml new file mode 100644 index 0000000000000..12b831f4b5d4f --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q36.plan.yaml @@ -0,0 +1,90 @@ +# Derived-expression group keys: arithmetic (ClientIP-N) pushed into DataSourceExec. +# 4-key hash repartition with functionally-dependent keys (all from ClientIP). +query: q36 +ppl_file: q36.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$4], ClientIP=[$0], ClientIP - 1=[$1], ClientIP - 2=[$2], ClientIP - 3=[$3], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1, 2, 3}], c=[SUM($4)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchSort(sort0=[$4], sort1=[$0], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1, 2, 3}], c=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(ClientIP=[$6], ClientIP - 1=[ANNOTATED_PROJECT_EXPR(id=0, backends=[datafusion], -($6, 1))], ClientIP - 2=[ANNOTATED_PROJECT_EXPR(id=1, backends=[datafusion], -($6, 2))], ClientIP - 3=[ANNOTATED_PROJECT_EXPR(id=2, backends=[datafusion], -($6, 3))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$4], sort1=[$0], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1, 2, 3}], c=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(ClientIP=[$6], ClientIP - 1=[ANNOTATED_PROJECT_EXPR(id=0, backends=[datafusion], -($6, 1))], ClientIP - 2=[ANNOTATED_PROJECT_EXPR(id=1, backends=[datafusion], -($6, 2))], ClientIP - 3=[ANNOTATED_PROJECT_EXPR(id=2, backends=[datafusion], -($6, 3))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$4], ClientIP=[$0], ClientIP - 1=[$1], ClientIP - 2=[$2], ClientIP - 3=[$3], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1, 2, 3}], c=[SUM($4)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[ClientIP@0 as ClientIP, .ClientIP - Int32(1)@1 as ClientIP - 1, .ClientIP - Int32(2)@2 as ClientIP - 2, .ClientIP - Int32(3)@3 as ClientIP - 3, count(Int64(1))[count]@4 as c] + SortPreservingMergeExec: [count(Int64(1))@4 DESC NULLS LAST, ClientIP@0 ASC], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@4 DESC NULLS LAST, ClientIP@0 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[ClientIP@0 as ClientIP, .ClientIP - Int32(1)@1 as .ClientIP - Int32(1), .ClientIP - Int32(2)@2 as .ClientIP - Int32(2), .ClientIP - Int32(3)@3 as .ClientIP - Int32(3)], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([ClientIP@0, .ClientIP - Int32(1)@1, .ClientIP - Int32(2)@2, .ClientIP - Int32(3)@3], 4), input_partitions=1 + AggregateExec: mode=Partial, gby=[ClientIP@0 as ClientIP, .ClientIP - Int32(1)@1 as .ClientIP - Int32(1), .ClientIP - Int32(2)@2 as .ClientIP - Int32(2), .ClientIP - Int32(3)@3 as .ClientIP - Int32(3)], aggr=[count(Int64(1))] + DataSourceExec: file_groups={}, projection=[ClientIP, ClientIP@79 - 1 as .ClientIP - Int32(1), ClientIP@79 - 2 as .ClientIP - Int32(2), ClientIP@79 - 3 as .ClientIP - Int32(3)], file_type=parquet + shard_physical_nseg: | + ProjectionExec: expr=[ClientIP@0 as ClientIP, .ClientIP - Int32(1)@1 as ClientIP - 1, .ClientIP - Int32(2)@2 as ClientIP - 2, .ClientIP - Int32(3)@3 as ClientIP - 3, count(Int64(1))[count]@4 as c] + SortPreservingMergeExec: [count(Int64(1))@4 DESC NULLS LAST, ClientIP@0 ASC], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@4 DESC NULLS LAST, ClientIP@0 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[ClientIP@0 as ClientIP, .ClientIP - Int32(1)@1 as .ClientIP - Int32(1), .ClientIP - Int32(2)@2 as .ClientIP - Int32(2), .ClientIP - Int32(3)@3 as .ClientIP - Int32(3)], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([ClientIP@0, .ClientIP - Int32(1)@1, .ClientIP - Int32(2)@2, .ClientIP - Int32(3)@3], 4), input_partitions=2 + AggregateExec: mode=Partial, gby=[ClientIP@0 as ClientIP, .ClientIP - Int32(1)@1 as .ClientIP - Int32(1), .ClientIP - Int32(2)@2 as .ClientIP - Int32(2), .ClientIP - Int32(3)@3 as .ClientIP - Int32(3)], aggr=[count(Int64(1))] + DataSourceExec: file_groups={}, projection=[ClientIP, ClientIP@79 - 1 as .ClientIP - Int32(1), ClientIP@79 - 2 as .ClientIP - Int32(2), ClientIP@79 - 3 as .ClientIP - Int32(3)], file_type=parquet + coord_physical: | + ProjectionExec: expr=[sum(input-0.c)@0 as c, ClientIP@1 as ClientIP, ClientIP - 1@2 as ClientIP - 1, ClientIP - 2@3 as ClientIP - 2, ClientIP - 3@4 as ClientIP - 3] + SortPreservingMergeExec: [sum(input-0.c)@0 DESC NULLS LAST, ClientIP@1 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[sum(input-0.c)@0 DESC NULLS LAST, ClientIP@1 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[sum(input-0.c)@4 as sum(input-0.c), ClientIP@0 as ClientIP, ClientIP - 1@1 as ClientIP - 1, ClientIP - 2@2 as ClientIP - 2, ClientIP - 3@3 as ClientIP - 3] + AggregateExec: mode=FinalPartitioned, gby=[ClientIP@0 as ClientIP, ClientIP - 1@1 as ClientIP - 1, ClientIP - 2@2 as ClientIP - 2, ClientIP - 3@3 as ClientIP - 3], aggr=[sum(input-0.c)] + RepartitionExec: partitioning=Hash([ClientIP@0, ClientIP - 1@1, ClientIP - 2@2, ClientIP - 3@3], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[ClientIP@0 as ClientIP, ClientIP - 1@1 as ClientIP - 1, ClientIP - 2@2 as ClientIP - 2, ClientIP - 3@3 as ClientIP - 3], aggr=[sum(input-0.c)] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + StreamingTableExec: partition_sizes=1, projection=[ClientIP, ClientIP - 1, ClientIP - 2, ClientIP - 3, c] + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$4], ClientIP=[$0], ClientIP - 1=[$1], ClientIP - 2=[$2], ClientIP - 3=[$3], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1, 2, 3}], c=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(ClientIP=[$6], ClientIP - 1=[ANNOTATED_PROJECT_EXPR(id=0, backends=[datafusion], -($6, 1))], ClientIP - 2=[ANNOTATED_PROJECT_EXPR(id=1, backends=[datafusion], -($6, 2))], ClientIP - 3=[ANNOTATED_PROJECT_EXPR(id=2, backends=[datafusion], -($6, 3))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(c=[$4], ClientIP=[$0], ClientIP - 1=[$1], ClientIP - 2=[$2], ClientIP - 3=[$3], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1, 2, 3}], c=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(ClientIP=[$6], ClientIP - 1=[ANNOTATED_PROJECT_EXPR(id=0, backends=[datafusion], -($6, 1))], ClientIP - 2=[ANNOTATED_PROJECT_EXPR(id=1, backends=[datafusion], -($6, 2))], ClientIP - 3=[ANNOTATED_PROJECT_EXPR(id=2, backends=[datafusion], -($6, 3))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[count(Int64(1))@0 as c, ClientIP@1 as ClientIP, .ClientIP - Int32(1)@2 as ClientIP - 1, .ClientIP - Int32(2)@3 as ClientIP - 2, .ClientIP - Int32(3)@4 as ClientIP - 3] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST, ClientIP@1 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@0 DESC NULLS LAST, ClientIP@1 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@4 as count(Int64(1)), ClientIP@0 as ClientIP, .ClientIP - Int32(1)@1 as .ClientIP - Int32(1), .ClientIP - Int32(2)@2 as .ClientIP - Int32(2), .ClientIP - Int32(3)@3 as .ClientIP - Int32(3)] + AggregateExec: mode=FinalPartitioned, gby=[ClientIP@0 as ClientIP, .ClientIP - Int32(1)@1 as .ClientIP - Int32(1), .ClientIP - Int32(2)@2 as .ClientIP - Int32(2), .ClientIP - Int32(3)@3 as .ClientIP - Int32(3)], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([ClientIP@0, .ClientIP - Int32(1)@1, .ClientIP - Int32(2)@2, .ClientIP - Int32(3)@3], 4), input_partitions=1 + AggregateExec: mode=Partial, gby=[ClientIP@0 as ClientIP, .ClientIP - Int32(1)@1 as .ClientIP - Int32(1), .ClientIP - Int32(2)@2 as .ClientIP - Int32(2), .ClientIP - Int32(3)@3 as .ClientIP - Int32(3)], aggr=[count(Int64(1))] + DataSourceExec: file_groups={}, projection=[ClientIP, ClientIP@79 - 1 as .ClientIP - Int32(1), ClientIP@79 - 2 as .ClientIP - Int32(2), ClientIP@79 - 3 as .ClientIP - Int32(3)], file_type=parquet + shard_physical_nseg: | + ProjectionExec: expr=[count(Int64(1))@0 as c, ClientIP@1 as ClientIP, .ClientIP - Int32(1)@2 as ClientIP - 1, .ClientIP - Int32(2)@3 as ClientIP - 2, .ClientIP - Int32(3)@4 as ClientIP - 3] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST, ClientIP@1 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@0 DESC NULLS LAST, ClientIP@1 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@4 as count(Int64(1)), ClientIP@0 as ClientIP, .ClientIP - Int32(1)@1 as .ClientIP - Int32(1), .ClientIP - Int32(2)@2 as .ClientIP - Int32(2), .ClientIP - Int32(3)@3 as .ClientIP - Int32(3)] + AggregateExec: mode=FinalPartitioned, gby=[ClientIP@0 as ClientIP, .ClientIP - Int32(1)@1 as .ClientIP - Int32(1), .ClientIP - Int32(2)@2 as .ClientIP - Int32(2), .ClientIP - Int32(3)@3 as .ClientIP - Int32(3)], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([ClientIP@0, .ClientIP - Int32(1)@1, .ClientIP - Int32(2)@2, .ClientIP - Int32(3)@3], 4), input_partitions=2 + AggregateExec: mode=Partial, gby=[ClientIP@0 as ClientIP, .ClientIP - Int32(1)@1 as .ClientIP - Int32(1), .ClientIP - Int32(2)@2 as .ClientIP - Int32(2), .ClientIP - Int32(3)@3 as .ClientIP - Int32(3)], aggr=[count(Int64(1))] + DataSourceExec: file_groups={}, projection=[ClientIP, ClientIP@79 - 1 as .ClientIP - Int32(1), ClientIP@79 - 2 as .ClientIP - Int32(2), ClientIP@79 - 3 as .ClientIP - Int32(3)], file_type=parquet diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q37.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q37.plan.yaml new file mode 100644 index 0000000000000..c1426e00eb1c9 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q37.plan.yaml @@ -0,0 +1,102 @@ +# Multi-predicate conjunction (CounterID=62, date range, flags, URL!='') +# All predicates routed to datafusion (!=, range blocked from lucene); parquet DataSourceExec with TopK. +query: q37 +ppl_file: q37.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(PageViews=[$1], URL=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], PageViews=[SUM($1)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchSort(sort0=[$1], sort1=[$0], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], PageViews=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(URL=[$85], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($12, 62)), ANNOTATED_PREDICATE(id=1, backends=[datafusion], >=($15, TIMESTAMP('2013-07-01':VARCHAR))), ANNOTATED_PREDICATE(id=2, backends=[datafusion], <=($15, TIMESTAMP('2013-07-31':VARCHAR))), ANNOTATED_PREDICATE(id=3, backends=[datafusion], =($14, 0)), ANNOTATED_PREDICATE(id=4, backends=[datafusion], =($40, 0)), ANNOTATED_PREDICATE(id=5, backends=[datafusion], <>($85, '')))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$1], sort1=[$0], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], PageViews=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(URL=[$85], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($12, 62)), ANNOTATED_PREDICATE(id=1, backends=[datafusion], >=($15, TIMESTAMP('2013-07-01':VARCHAR))), ANNOTATED_PREDICATE(id=2, backends=[datafusion], <=($15, TIMESTAMP('2013-07-31':VARCHAR))), ANNOTATED_PREDICATE(id=3, backends=[datafusion], =($14, 0)), ANNOTATED_PREDICATE(id=4, backends=[datafusion], =($40, 0)), ANNOTATED_PREDICATE(id=5, backends=[datafusion], <>($85, '')))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(PageViews=[$1], URL=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], PageViews=[SUM($1)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[URL@0 as URL, count(Int64(1))[count]@1 as PageViews] + SortPreservingMergeExec: [count(Int64(1))@1 DESC NULLS LAST, URL@0 ASC], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@1 DESC NULLS LAST, URL@0 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[URL@0 as URL], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([URL@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[URL@0 as URL], aggr=[count(Int64(1))] + FilterExec: CounterID@0 = 62 AND EventDate@2 >= 1372636800000 AND EventDate@2 <= 1375228800000 AND DontCountHits@1 = 0 AND IsRefresh@3 = 0 AND URL@4 != , projection=[URL@4] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[CounterID, DontCountHits, EventDate, IsRefresh, URL], file_type=parquet, predicate=CounterID@107 = 62 AND EventDate@0 >= 1372636800000 AND EventDate@0 <= 1375228800000 AND DontCountHits@43 = 0 AND IsRefresh@74 = 0 AND URL@27 != , pruning_predicate=CounterID_null_count@2 != row_count@3 AND CounterID_min@0 <= 62 AND 62 <= CounterID_max@1 AND EventDate_null_count@5 != row_count@3 AND EventDate_max@4 >= 1372636800000 AND EventDate_null_count@5 != row_count@3 AND EventDate_min@6 <= 1375228800000 AND DontCountHits_null_count@9 != row_count@3 AND DontCountHits_min@7 <= 0 AND 0 <= DontCountHits_max@8 AND IsRefresh_null_count@12 != row_count@3 AND IsRefresh_min@10 <= 0 AND 0 <= IsRefresh_max@11 AND URL_null_count@15 != row_count@3 AND (URL_min@13 != OR != URL_max@14), required_guarantees=[CounterID in (62), DontCountHits in (0), IsRefresh in (0), URL not in ()] + shard_physical_nseg: | + ProjectionExec: expr=[URL@0 as URL, count(Int64(1))[count]@1 as PageViews] + SortPreservingMergeExec: [count(Int64(1))@1 DESC NULLS LAST, URL@0 ASC], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@1 DESC NULLS LAST, URL@0 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[URL@0 as URL], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([URL@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[URL@0 as URL], aggr=[count(Int64(1))] + FilterExec: CounterID@0 = 62 AND EventDate@2 >= 1372636800000 AND EventDate@2 <= 1375228800000 AND DontCountHits@1 = 0 AND IsRefresh@3 = 0 AND URL@4 != , projection=[URL@4] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[CounterID, DontCountHits, EventDate, IsRefresh, URL], file_type=parquet, predicate=CounterID@107 = 62 AND EventDate@0 >= 1372636800000 AND EventDate@0 <= 1375228800000 AND DontCountHits@43 = 0 AND IsRefresh@74 = 0 AND URL@27 != , pruning_predicate=CounterID_null_count@2 != row_count@3 AND CounterID_min@0 <= 62 AND 62 <= CounterID_max@1 AND EventDate_null_count@5 != row_count@3 AND EventDate_max@4 >= 1372636800000 AND EventDate_null_count@5 != row_count@3 AND EventDate_min@6 <= 1375228800000 AND DontCountHits_null_count@9 != row_count@3 AND DontCountHits_min@7 <= 0 AND 0 <= DontCountHits_max@8 AND IsRefresh_null_count@12 != row_count@3 AND IsRefresh_min@10 <= 0 AND 0 <= IsRefresh_max@11 AND URL_null_count@15 != row_count@3 AND (URL_min@13 != OR != URL_max@14), required_guarantees=[CounterID in (62), DontCountHits in (0), IsRefresh in (0), URL not in ()] + coord_physical: | + ProjectionExec: expr=[sum(input-0.PageViews)@0 as PageViews, URL@1 as URL] + SortPreservingMergeExec: [sum(input-0.PageViews)@0 DESC NULLS LAST, URL@1 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[sum(input-0.PageViews)@0 DESC NULLS LAST, URL@1 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[sum(input-0.PageViews)@1 as sum(input-0.PageViews), URL@0 as URL] + AggregateExec: mode=FinalPartitioned, gby=[URL@0 as URL], aggr=[sum(input-0.PageViews)] + RepartitionExec: partitioning=Hash([URL@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[URL@0 as URL], aggr=[sum(input-0.PageViews)] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + StreamingTableExec: partition_sizes=1, projection=[URL, PageViews] + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(PageViews=[$1], URL=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], PageViews=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(URL=[$85], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($12, 62)), ANNOTATED_PREDICATE(id=1, backends=[datafusion], >=($15, TIMESTAMP('2013-07-01':VARCHAR))), ANNOTATED_PREDICATE(id=2, backends=[datafusion], <=($15, TIMESTAMP('2013-07-31':VARCHAR))), ANNOTATED_PREDICATE(id=3, backends=[datafusion], =($14, 0)), ANNOTATED_PREDICATE(id=4, backends=[datafusion], =($40, 0)), ANNOTATED_PREDICATE(id=5, backends=[datafusion], <>($85, '')))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(PageViews=[$1], URL=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], PageViews=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(URL=[$85], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($12, 62)), ANNOTATED_PREDICATE(id=1, backends=[datafusion], >=($15, TIMESTAMP('2013-07-01':VARCHAR))), ANNOTATED_PREDICATE(id=2, backends=[datafusion], <=($15, TIMESTAMP('2013-07-31':VARCHAR))), ANNOTATED_PREDICATE(id=3, backends=[datafusion], =($14, 0)), ANNOTATED_PREDICATE(id=4, backends=[datafusion], =($40, 0)), ANNOTATED_PREDICATE(id=5, backends=[datafusion], <>($85, '')))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[count(Int64(1))@0 as PageViews, URL@1 as URL] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST, URL@1 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@0 DESC NULLS LAST, URL@1 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@1 as count(Int64(1)), URL@0 as URL] + AggregateExec: mode=FinalPartitioned, gby=[URL@0 as URL], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([URL@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[URL@0 as URL], aggr=[count(Int64(1))] + FilterExec: CounterID@0 = 62 AND EventDate@2 >= 1372636800000 AND EventDate@2 <= 1375228800000 AND DontCountHits@1 = 0 AND IsRefresh@3 = 0 AND URL@4 != , projection=[URL@4] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[CounterID, DontCountHits, EventDate, IsRefresh, URL], file_type=parquet, predicate=CounterID@107 = 62 AND EventDate@0 >= 1372636800000 AND EventDate@0 <= 1375228800000 AND DontCountHits@43 = 0 AND IsRefresh@74 = 0 AND URL@27 != , pruning_predicate=CounterID_null_count@2 != row_count@3 AND CounterID_min@0 <= 62 AND 62 <= CounterID_max@1 AND EventDate_null_count@5 != row_count@3 AND EventDate_max@4 >= 1372636800000 AND EventDate_null_count@5 != row_count@3 AND EventDate_min@6 <= 1375228800000 AND DontCountHits_null_count@9 != row_count@3 AND DontCountHits_min@7 <= 0 AND 0 <= DontCountHits_max@8 AND IsRefresh_null_count@12 != row_count@3 AND IsRefresh_min@10 <= 0 AND 0 <= IsRefresh_max@11 AND URL_null_count@15 != row_count@3 AND (URL_min@13 != OR != URL_max@14), required_guarantees=[CounterID in (62), DontCountHits in (0), IsRefresh in (0), URL not in ()] + shard_physical_nseg: | + ProjectionExec: expr=[count(Int64(1))@0 as PageViews, URL@1 as URL] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST, URL@1 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@0 DESC NULLS LAST, URL@1 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@1 as count(Int64(1)), URL@0 as URL] + AggregateExec: mode=FinalPartitioned, gby=[URL@0 as URL], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([URL@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[URL@0 as URL], aggr=[count(Int64(1))] + FilterExec: CounterID@0 = 62 AND EventDate@2 >= 1372636800000 AND EventDate@2 <= 1375228800000 AND DontCountHits@1 = 0 AND IsRefresh@3 = 0 AND URL@4 != , projection=[URL@4] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[CounterID, DontCountHits, EventDate, IsRefresh, URL], file_type=parquet, predicate=CounterID@107 = 62 AND EventDate@0 >= 1372636800000 AND EventDate@0 <= 1375228800000 AND DontCountHits@43 = 0 AND IsRefresh@74 = 0 AND URL@27 != , pruning_predicate=CounterID_null_count@2 != row_count@3 AND CounterID_min@0 <= 62 AND 62 <= CounterID_max@1 AND EventDate_null_count@5 != row_count@3 AND EventDate_max@4 >= 1372636800000 AND EventDate_null_count@5 != row_count@3 AND EventDate_min@6 <= 1375228800000 AND DontCountHits_null_count@9 != row_count@3 AND DontCountHits_min@7 <= 0 AND 0 <= DontCountHits_max@8 AND IsRefresh_null_count@12 != row_count@3 AND IsRefresh_min@10 <= 0 AND 0 <= IsRefresh_max@11 AND URL_null_count@15 != row_count@3 AND (URL_min@13 != OR != URL_max@14), required_guarantees=[CounterID in (62), DontCountHits in (0), IsRefresh in (0), URL not in ()] diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q38.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q38.plan.yaml new file mode 100644 index 0000000000000..19844344bf357 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q38.plan.yaml @@ -0,0 +1,102 @@ +# Same filter pattern as q37 but grouped by Title instead of URL. +# Title != '' blocked from lucene; parquet DataSourceExec with full predicate pushdown. +query: q38 +ppl_file: q38.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(PageViews=[$1], Title=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], PageViews=[SUM($1)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchSort(sort0=[$1], sort1=[$0], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], PageViews=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(Title=[$83], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($12, 62)), ANNOTATED_PREDICATE(id=1, backends=[datafusion], >=($15, TIMESTAMP('2013-07-01':VARCHAR))), ANNOTATED_PREDICATE(id=2, backends=[datafusion], <=($15, TIMESTAMP('2013-07-31':VARCHAR))), ANNOTATED_PREDICATE(id=3, backends=[datafusion], =($14, 0)), ANNOTATED_PREDICATE(id=4, backends=[datafusion], =($40, 0)), ANNOTATED_PREDICATE(id=5, backends=[datafusion], <>($83, '')))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$1], sort1=[$0], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], PageViews=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(Title=[$83], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($12, 62)), ANNOTATED_PREDICATE(id=1, backends=[datafusion], >=($15, TIMESTAMP('2013-07-01':VARCHAR))), ANNOTATED_PREDICATE(id=2, backends=[datafusion], <=($15, TIMESTAMP('2013-07-31':VARCHAR))), ANNOTATED_PREDICATE(id=3, backends=[datafusion], =($14, 0)), ANNOTATED_PREDICATE(id=4, backends=[datafusion], =($40, 0)), ANNOTATED_PREDICATE(id=5, backends=[datafusion], <>($83, '')))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(PageViews=[$1], Title=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], PageViews=[SUM($1)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[Title@0 as Title, count(Int64(1))[count]@1 as PageViews] + SortPreservingMergeExec: [count(Int64(1))@1 DESC NULLS LAST, Title@0 ASC], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@1 DESC NULLS LAST, Title@0 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[Title@0 as Title], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([Title@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[Title@0 as Title], aggr=[count(Int64(1))] + FilterExec: CounterID@0 = 62 AND EventDate@2 >= 1372636800000 AND EventDate@2 <= 1375228800000 AND DontCountHits@1 = 0 AND IsRefresh@3 = 0 AND Title@4 != , projection=[Title@4] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[CounterID, DontCountHits, EventDate, IsRefresh, Title], file_type=parquet, predicate=CounterID@107 = 62 AND EventDate@0 >= 1372636800000 AND EventDate@0 <= 1375228800000 AND DontCountHits@43 = 0 AND IsRefresh@74 = 0 AND Title@101 != , pruning_predicate=CounterID_null_count@2 != row_count@3 AND CounterID_min@0 <= 62 AND 62 <= CounterID_max@1 AND EventDate_null_count@5 != row_count@3 AND EventDate_max@4 >= 1372636800000 AND EventDate_null_count@5 != row_count@3 AND EventDate_min@6 <= 1375228800000 AND DontCountHits_null_count@9 != row_count@3 AND DontCountHits_min@7 <= 0 AND 0 <= DontCountHits_max@8 AND IsRefresh_null_count@12 != row_count@3 AND IsRefresh_min@10 <= 0 AND 0 <= IsRefresh_max@11 AND Title_null_count@15 != row_count@3 AND (Title_min@13 != OR != Title_max@14), required_guarantees=[CounterID in (62), DontCountHits in (0), IsRefresh in (0), Title not in ()] + shard_physical_nseg: | + ProjectionExec: expr=[Title@0 as Title, count(Int64(1))[count]@1 as PageViews] + SortPreservingMergeExec: [count(Int64(1))@1 DESC NULLS LAST, Title@0 ASC], fetch=30 + SortExec: TopK(fetch=30), expr=[count(Int64(1))@1 DESC NULLS LAST, Title@0 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[Title@0 as Title], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([Title@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[Title@0 as Title], aggr=[count(Int64(1))] + FilterExec: CounterID@0 = 62 AND EventDate@2 >= 1372636800000 AND EventDate@2 <= 1375228800000 AND DontCountHits@1 = 0 AND IsRefresh@3 = 0 AND Title@4 != , projection=[Title@4] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[CounterID, DontCountHits, EventDate, IsRefresh, Title], file_type=parquet, predicate=CounterID@107 = 62 AND EventDate@0 >= 1372636800000 AND EventDate@0 <= 1375228800000 AND DontCountHits@43 = 0 AND IsRefresh@74 = 0 AND Title@101 != , pruning_predicate=CounterID_null_count@2 != row_count@3 AND CounterID_min@0 <= 62 AND 62 <= CounterID_max@1 AND EventDate_null_count@5 != row_count@3 AND EventDate_max@4 >= 1372636800000 AND EventDate_null_count@5 != row_count@3 AND EventDate_min@6 <= 1375228800000 AND DontCountHits_null_count@9 != row_count@3 AND DontCountHits_min@7 <= 0 AND 0 <= DontCountHits_max@8 AND IsRefresh_null_count@12 != row_count@3 AND IsRefresh_min@10 <= 0 AND 0 <= IsRefresh_max@11 AND Title_null_count@15 != row_count@3 AND (Title_min@13 != OR != Title_max@14), required_guarantees=[CounterID in (62), DontCountHits in (0), IsRefresh in (0), Title not in ()] + coord_physical: | + ProjectionExec: expr=[sum(input-0.PageViews)@0 as PageViews, Title@1 as Title] + SortPreservingMergeExec: [sum(input-0.PageViews)@0 DESC NULLS LAST, Title@1 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[sum(input-0.PageViews)@0 DESC NULLS LAST, Title@1 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[sum(input-0.PageViews)@1 as sum(input-0.PageViews), Title@0 as Title] + AggregateExec: mode=FinalPartitioned, gby=[Title@0 as Title], aggr=[sum(input-0.PageViews)] + RepartitionExec: partitioning=Hash([Title@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[Title@0 as Title], aggr=[sum(input-0.PageViews)] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + StreamingTableExec: partition_sizes=1, projection=[Title, PageViews] + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(PageViews=[$1], Title=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], PageViews=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(Title=[$83], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($12, 62)), ANNOTATED_PREDICATE(id=1, backends=[datafusion], >=($15, TIMESTAMP('2013-07-01':VARCHAR))), ANNOTATED_PREDICATE(id=2, backends=[datafusion], <=($15, TIMESTAMP('2013-07-31':VARCHAR))), ANNOTATED_PREDICATE(id=3, backends=[datafusion], =($14, 0)), ANNOTATED_PREDICATE(id=4, backends=[datafusion], =($40, 0)), ANNOTATED_PREDICATE(id=5, backends=[datafusion], <>($83, '')))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(PageViews=[$1], Title=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], PageViews=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(Title=[$83], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($12, 62)), ANNOTATED_PREDICATE(id=1, backends=[datafusion], >=($15, TIMESTAMP('2013-07-01':VARCHAR))), ANNOTATED_PREDICATE(id=2, backends=[datafusion], <=($15, TIMESTAMP('2013-07-31':VARCHAR))), ANNOTATED_PREDICATE(id=3, backends=[datafusion], =($14, 0)), ANNOTATED_PREDICATE(id=4, backends=[datafusion], =($40, 0)), ANNOTATED_PREDICATE(id=5, backends=[datafusion], <>($83, '')))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[count(Int64(1))@0 as PageViews, Title@1 as Title] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST, Title@1 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@0 DESC NULLS LAST, Title@1 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@1 as count(Int64(1)), Title@0 as Title] + AggregateExec: mode=FinalPartitioned, gby=[Title@0 as Title], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([Title@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[Title@0 as Title], aggr=[count(Int64(1))] + FilterExec: CounterID@0 = 62 AND EventDate@2 >= 1372636800000 AND EventDate@2 <= 1375228800000 AND DontCountHits@1 = 0 AND IsRefresh@3 = 0 AND Title@4 != , projection=[Title@4] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[CounterID, DontCountHits, EventDate, IsRefresh, Title], file_type=parquet, predicate=CounterID@107 = 62 AND EventDate@0 >= 1372636800000 AND EventDate@0 <= 1375228800000 AND DontCountHits@43 = 0 AND IsRefresh@74 = 0 AND Title@101 != , pruning_predicate=CounterID_null_count@2 != row_count@3 AND CounterID_min@0 <= 62 AND 62 <= CounterID_max@1 AND EventDate_null_count@5 != row_count@3 AND EventDate_max@4 >= 1372636800000 AND EventDate_null_count@5 != row_count@3 AND EventDate_min@6 <= 1375228800000 AND DontCountHits_null_count@9 != row_count@3 AND DontCountHits_min@7 <= 0 AND 0 <= DontCountHits_max@8 AND IsRefresh_null_count@12 != row_count@3 AND IsRefresh_min@10 <= 0 AND 0 <= IsRefresh_max@11 AND Title_null_count@15 != row_count@3 AND (Title_min@13 != OR != Title_max@14), required_guarantees=[CounterID in (62), DontCountHits in (0), IsRefresh in (0), Title not in ()] + shard_physical_nseg: | + ProjectionExec: expr=[count(Int64(1))@0 as PageViews, Title@1 as Title] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST, Title@1 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[count(Int64(1))@0 DESC NULLS LAST, Title@1 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@1 as count(Int64(1)), Title@0 as Title] + AggregateExec: mode=FinalPartitioned, gby=[Title@0 as Title], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([Title@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[Title@0 as Title], aggr=[count(Int64(1))] + FilterExec: CounterID@0 = 62 AND EventDate@2 >= 1372636800000 AND EventDate@2 <= 1375228800000 AND DontCountHits@1 = 0 AND IsRefresh@3 = 0 AND Title@4 != , projection=[Title@4] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[CounterID, DontCountHits, EventDate, IsRefresh, Title], file_type=parquet, predicate=CounterID@107 = 62 AND EventDate@0 >= 1372636800000 AND EventDate@0 <= 1375228800000 AND DontCountHits@43 = 0 AND IsRefresh@74 = 0 AND Title@101 != , pruning_predicate=CounterID_null_count@2 != row_count@3 AND CounterID_min@0 <= 62 AND 62 <= CounterID_max@1 AND EventDate_null_count@5 != row_count@3 AND EventDate_max@4 >= 1372636800000 AND EventDate_null_count@5 != row_count@3 AND EventDate_min@6 <= 1375228800000 AND DontCountHits_null_count@9 != row_count@3 AND DontCountHits_min@7 <= 0 AND 0 <= DontCountHits_max@8 AND IsRefresh_null_count@12 != row_count@3 AND IsRefresh_min@10 <= 0 AND 0 <= IsRefresh_max@11 AND Title_null_count@15 != row_count@3 AND (Title_min@13 != OR != Title_max@14), required_guarantees=[CounterID in (62), DontCountHits in (0), IsRefresh in (0), Title not in ()] diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q39.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q39.plan.yaml new file mode 100644 index 0000000000000..34756cc0ac24b --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q39.plan.yaml @@ -0,0 +1,105 @@ +# OFFSET: head 10 from 5 → effective limit 15, shard TopK fetch=45 (3x). +# All predicates numeric/date ([datafusion]); parquet DataSourceExec, tree_shape=NONE. +query: q39 +ppl_file: q39.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], offset=[5], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(PageViews=[$1], URL=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], PageViews=[SUM($1)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchSort(sort0=[$1], sort1=[$0], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[45], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], PageViews=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(URL=[$85], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($12, 62)), ANNOTATED_PREDICATE(id=1, backends=[datafusion], >=($15, TIMESTAMP('2013-07-01':VARCHAR))), ANNOTATED_PREDICATE(id=2, backends=[datafusion], <=($15, TIMESTAMP('2013-07-31':VARCHAR))), ANNOTATED_PREDICATE(id=3, backends=[datafusion], =($40, 0)), ANNOTATED_PREDICATE(id=4, backends=[datafusion], <>($35, 0)), ANNOTATED_PREDICATE(id=5, backends=[datafusion], =($33, 0)))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$1], sort1=[$0], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[45], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], PageViews=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(URL=[$85], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($12, 62)), ANNOTATED_PREDICATE(id=1, backends=[datafusion], >=($15, TIMESTAMP('2013-07-01':VARCHAR))), ANNOTATED_PREDICATE(id=2, backends=[datafusion], <=($15, TIMESTAMP('2013-07-31':VARCHAR))), ANNOTATED_PREDICATE(id=3, backends=[datafusion], =($40, 0)), ANNOTATED_PREDICATE(id=4, backends=[datafusion], <>($35, 0)), ANNOTATED_PREDICATE(id=5, backends=[datafusion], =($33, 0)))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], offset=[5], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(PageViews=[$1], URL=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], PageViews=[SUM($1)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[URL@0 as URL, count(Int64(1))[count]@1 as PageViews] + SortPreservingMergeExec: [count(Int64(1))@1 DESC NULLS LAST, URL@0 ASC], fetch=45 + SortExec: TopK(fetch=45), expr=[count(Int64(1))@1 DESC NULLS LAST, URL@0 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[URL@0 as URL], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([URL@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[URL@0 as URL], aggr=[count(Int64(1))] + FilterExec: CounterID@0 = 62 AND EventDate@1 >= 1372636800000 AND EventDate@1 <= 1375228800000 AND IsRefresh@4 = 0 AND IsLink@3 != 0 AND IsDownload@2 = 0, projection=[URL@5] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[CounterID, EventDate, IsDownload, IsLink, IsRefresh, URL], file_type=parquet, predicate=CounterID@107 = 62 AND EventDate@0 >= 1372636800000 AND EventDate@0 <= 1375228800000 AND IsRefresh@74 = 0 AND IsLink@49 != 0 AND IsDownload@36 = 0, pruning_predicate=CounterID_null_count@2 != row_count@3 AND CounterID_min@0 <= 62 AND 62 <= CounterID_max@1 AND EventDate_null_count@5 != row_count@3 AND EventDate_max@4 >= 1372636800000 AND EventDate_null_count@5 != row_count@3 AND EventDate_min@6 <= 1375228800000 AND IsRefresh_null_count@9 != row_count@3 AND IsRefresh_min@7 <= 0 AND 0 <= IsRefresh_max@8 AND IsLink_null_count@12 != row_count@3 AND (IsLink_min@10 != 0 OR 0 != IsLink_max@11) AND IsDownload_null_count@15 != row_count@3 AND IsDownload_min@13 <= 0 AND 0 <= IsDownload_max@14, required_guarantees=[CounterID in (62), IsDownload in (0), IsLink not in (0), IsRefresh in (0)] + shard_physical_nseg: | + ProjectionExec: expr=[URL@0 as URL, count(Int64(1))[count]@1 as PageViews] + SortPreservingMergeExec: [count(Int64(1))@1 DESC NULLS LAST, URL@0 ASC], fetch=45 + SortExec: TopK(fetch=45), expr=[count(Int64(1))@1 DESC NULLS LAST, URL@0 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[URL@0 as URL], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([URL@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[URL@0 as URL], aggr=[count(Int64(1))] + FilterExec: CounterID@0 = 62 AND EventDate@1 >= 1372636800000 AND EventDate@1 <= 1375228800000 AND IsRefresh@4 = 0 AND IsLink@3 != 0 AND IsDownload@2 = 0, projection=[URL@5] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[CounterID, EventDate, IsDownload, IsLink, IsRefresh, URL], file_type=parquet, predicate=CounterID@107 = 62 AND EventDate@0 >= 1372636800000 AND EventDate@0 <= 1375228800000 AND IsRefresh@74 = 0 AND IsLink@49 != 0 AND IsDownload@36 = 0, pruning_predicate=CounterID_null_count@2 != row_count@3 AND CounterID_min@0 <= 62 AND 62 <= CounterID_max@1 AND EventDate_null_count@5 != row_count@3 AND EventDate_max@4 >= 1372636800000 AND EventDate_null_count@5 != row_count@3 AND EventDate_min@6 <= 1375228800000 AND IsRefresh_null_count@9 != row_count@3 AND IsRefresh_min@7 <= 0 AND 0 <= IsRefresh_max@8 AND IsLink_null_count@12 != row_count@3 AND (IsLink_min@10 != 0 OR 0 != IsLink_max@11) AND IsDownload_null_count@15 != row_count@3 AND IsDownload_min@13 <= 0 AND 0 <= IsDownload_max@14, required_guarantees=[CounterID in (62), IsDownload in (0), IsLink not in (0), IsRefresh in (0)] + coord_physical: | + ProjectionExec: expr=[sum(input-0.PageViews)@0 as PageViews, URL@1 as URL] + GlobalLimitExec: skip=5, fetch=10 + SortPreservingMergeExec: [sum(input-0.PageViews)@0 DESC NULLS LAST, URL@1 ASC], fetch=15 + SortExec: TopK(fetch=15), expr=[sum(input-0.PageViews)@0 DESC NULLS LAST, URL@1 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[sum(input-0.PageViews)@1 as sum(input-0.PageViews), URL@0 as URL] + AggregateExec: mode=FinalPartitioned, gby=[URL@0 as URL], aggr=[sum(input-0.PageViews)] + RepartitionExec: partitioning=Hash([URL@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[URL@0 as URL], aggr=[sum(input-0.PageViews)] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + StreamingTableExec: partition_sizes=1, projection=[URL, PageViews] + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], offset=[5], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(PageViews=[$1], URL=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], PageViews=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(URL=[$85], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($12, 62)), ANNOTATED_PREDICATE(id=1, backends=[datafusion], >=($15, TIMESTAMP('2013-07-01':VARCHAR))), ANNOTATED_PREDICATE(id=2, backends=[datafusion], <=($15, TIMESTAMP('2013-07-31':VARCHAR))), ANNOTATED_PREDICATE(id=3, backends=[datafusion], =($40, 0)), ANNOTATED_PREDICATE(id=4, backends=[datafusion], <>($35, 0)), ANNOTATED_PREDICATE(id=5, backends=[datafusion], =($33, 0)))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], offset=[5], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(PageViews=[$1], URL=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], PageViews=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(URL=[$85], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($12, 62)), ANNOTATED_PREDICATE(id=1, backends=[datafusion], >=($15, TIMESTAMP('2013-07-01':VARCHAR))), ANNOTATED_PREDICATE(id=2, backends=[datafusion], <=($15, TIMESTAMP('2013-07-31':VARCHAR))), ANNOTATED_PREDICATE(id=3, backends=[datafusion], =($40, 0)), ANNOTATED_PREDICATE(id=4, backends=[datafusion], <>($35, 0)), ANNOTATED_PREDICATE(id=5, backends=[datafusion], =($33, 0)))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[count(Int64(1))@0 as PageViews, URL@1 as URL] + GlobalLimitExec: skip=5, fetch=10 + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST, URL@1 ASC], fetch=15 + SortExec: TopK(fetch=15), expr=[count(Int64(1))@0 DESC NULLS LAST, URL@1 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@1 as count(Int64(1)), URL@0 as URL] + AggregateExec: mode=FinalPartitioned, gby=[URL@0 as URL], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([URL@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[URL@0 as URL], aggr=[count(Int64(1))] + FilterExec: CounterID@0 = 62 AND EventDate@1 >= 1372636800000 AND EventDate@1 <= 1375228800000 AND IsRefresh@4 = 0 AND IsLink@3 != 0 AND IsDownload@2 = 0, projection=[URL@5] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[CounterID, EventDate, IsDownload, IsLink, IsRefresh, URL], file_type=parquet, predicate=CounterID@107 = 62 AND EventDate@0 >= 1372636800000 AND EventDate@0 <= 1375228800000 AND IsRefresh@74 = 0 AND IsLink@49 != 0 AND IsDownload@36 = 0, pruning_predicate=CounterID_null_count@2 != row_count@3 AND CounterID_min@0 <= 62 AND 62 <= CounterID_max@1 AND EventDate_null_count@5 != row_count@3 AND EventDate_max@4 >= 1372636800000 AND EventDate_null_count@5 != row_count@3 AND EventDate_min@6 <= 1375228800000 AND IsRefresh_null_count@9 != row_count@3 AND IsRefresh_min@7 <= 0 AND 0 <= IsRefresh_max@8 AND IsLink_null_count@12 != row_count@3 AND (IsLink_min@10 != 0 OR 0 != IsLink_max@11) AND IsDownload_null_count@15 != row_count@3 AND IsDownload_min@13 <= 0 AND 0 <= IsDownload_max@14, required_guarantees=[CounterID in (62), IsDownload in (0), IsLink not in (0), IsRefresh in (0)] + shard_physical_nseg: | + ProjectionExec: expr=[count(Int64(1))@0 as PageViews, URL@1 as URL] + GlobalLimitExec: skip=5, fetch=10 + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST, URL@1 ASC], fetch=15 + SortExec: TopK(fetch=15), expr=[count(Int64(1))@0 DESC NULLS LAST, URL@1 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@1 as count(Int64(1)), URL@0 as URL] + AggregateExec: mode=FinalPartitioned, gby=[URL@0 as URL], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([URL@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[URL@0 as URL], aggr=[count(Int64(1))] + FilterExec: CounterID@0 = 62 AND EventDate@1 >= 1372636800000 AND EventDate@1 <= 1375228800000 AND IsRefresh@4 = 0 AND IsLink@3 != 0 AND IsDownload@2 = 0, projection=[URL@5] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[CounterID, EventDate, IsDownload, IsLink, IsRefresh, URL], file_type=parquet, predicate=CounterID@107 = 62 AND EventDate@0 >= 1372636800000 AND EventDate@0 <= 1375228800000 AND IsRefresh@74 = 0 AND IsLink@49 != 0 AND IsDownload@36 = 0, pruning_predicate=CounterID_null_count@2 != row_count@3 AND CounterID_min@0 <= 62 AND 62 <= CounterID_max@1 AND EventDate_null_count@5 != row_count@3 AND EventDate_max@4 >= 1372636800000 AND EventDate_null_count@5 != row_count@3 AND EventDate_min@6 <= 1375228800000 AND IsRefresh_null_count@9 != row_count@3 AND IsRefresh_min@7 <= 0 AND 0 <= IsRefresh_max@8 AND IsLink_null_count@12 != row_count@3 AND (IsLink_min@10 != 0 OR 0 != IsLink_max@11) AND IsDownload_null_count@15 != row_count@3 AND IsDownload_min@13 <= 0 AND 0 <= IsDownload_max@14, required_guarantees=[CounterID in (62), IsDownload in (0), IsLink not in (0), IsRefresh in (0)] diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q4.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q4.plan.yaml new file mode 100644 index 0000000000000..454ea57199ff1 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q4.plan.yaml @@ -0,0 +1,58 @@ +# Pure avg decomposition: ships exactly 2 partial values (SUM, COUNT) for one aggregate. +# Division expression in final Project forces datafusion-only (lucene cannot evaluate). +query: q4 +ppl_file: q4.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchProject(avg(UserID)=[ANNOTATED_PROJECT_EXPR(id=3, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=2, backends=[datafusion], CAST($0):DOUBLE), $1))], viableBackends=[[datafusion]]) + OpenSearchProject($f0=[$0], $f1=[CAST($1):BIGINT NOT NULL], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{}], $f0=[SUM($0)], $f1=[SUM($1)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchAggregate(group=[{}], agg#0=[SUM($0)], agg#1=[COUNT($0)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(UserID=[$97], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchAggregate(group=[{}], agg#0=[SUM($0)], agg#1=[COUNT($0)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(UserID=[$97], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchProject(avg(UserID)=[ANNOTATED_PROJECT_EXPR(id=3, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=2, backends=[datafusion], CAST($0):DOUBLE), $1))], viableBackends=[[datafusion]]) + OpenSearchProject($f0=[$0], $f1=[CAST($1):BIGINT NOT NULL], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{}], $f0=[SUM($0)], $f1=[SUM($1)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical: | + AggregateExec: mode=Partial, gby=[], aggr=[sum(.UserID) as $f0, count(.UserID) as $f1] + DataSourceExec: file_groups={}, projection=[UserID], file_type=parquet + coord_physical: | + ProjectionExec: expr=[CASE WHEN sum(input-0.$f1)@1 = 0 THEN NULL ELSE CAST(sum(input-0.$f0)@0 AS Float64) / CAST(sum(input-0.$f1)@1 AS Float64) END as avg(UserID)] + AggregateExec: mode=Final, gby=[], aggr=[sum(input-0.$f0), sum(input-0.$f1)] + CoalescePartitionsExec + AggregateExec: mode=Partial, gby=[], aggr=[sum(input-0.$f0), sum(input-0.$f1)] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + StreamingTableExec: partition_sizes=1, projection=[$f0, $f1] + prod1s: + post_cbo: | + OpenSearchProject(avg(UserID)=[ANNOTATED_PROJECT_EXPR(id=3, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=2, backends=[datafusion], CAST($0):DOUBLE), $1))], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{}], agg#0=[SUM($0)], agg#1=[COUNT($0)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(UserID=[$97], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchProject(avg(UserID)=[ANNOTATED_PROJECT_EXPR(id=3, backends=[datafusion], /(ANNOTATED_PROJECT_EXPR(id=2, backends=[datafusion], CAST($0):DOUBLE), $1))], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{}], agg#0=[SUM($0)], agg#1=[COUNT($0)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(UserID=[$97], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[CASE WHEN count(.UserID)@1 = 0 THEN NULL ELSE CAST(sum(.UserID)@0 AS Float64) / CAST(count(.UserID)@1 AS Float64) END as avg(UserID)] + AggregateExec: mode=Single, gby=[], aggr=[sum(.UserID), count(.UserID)] + DataSourceExec: file_groups={}, projection=[UserID], file_type=parquet + shard_physical_nseg: | + ProjectionExec: expr=[CASE WHEN count(.UserID)@1 = 0 THEN NULL ELSE CAST(sum(.UserID)@0 AS Float64) / CAST(count(.UserID)@1 AS Float64) END as avg(UserID)] + AggregateExec: mode=Final, gby=[], aggr=[sum(.UserID), count(.UserID)] + CoalescePartitionsExec + AggregateExec: mode=Partial, gby=[], aggr=[sum(.UserID), count(.UserID)] + DataSourceExec: file_groups={}, projection=[UserID], file_type=parquet diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q40.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q40.plan.yaml new file mode 100644 index 0000000000000..52dbe24503e3f --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q40.plan.yaml @@ -0,0 +1,109 @@ +# CASE expression in eval: conditional Referer/URL assignment. +# Tests CASE WHEN pushdown into grouped TopK with 5 group keys and offset; parquet DataSourceExec. +query: q40 +ppl_file: q40.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], sort3=[$3], sort4=[$4], sort5=[$5], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], dir3=[ASC-nulls-first], dir4=[ASC-nulls-first], dir5=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], sort3=[$3], sort4=[$4], sort5=[$5], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], dir3=[ASC-nulls-first], dir4=[ASC-nulls-first], dir5=[ASC-nulls-first], offset=[5], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(PageViews=[$5], TraficSourceID=[$0], SearchEngineID=[$1], AdvEngineID=[$2], Src=[$3], Dst=[$4], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1, 2, 3, 4}], PageViews=[SUM($5)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchSort(sort0=[$5], sort1=[$0], sort2=[$1], sort3=[$2], sort4=[$3], sort5=[$4], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], dir3=[ASC-nulls-first], dir4=[ASC-nulls-first], dir5=[ASC-nulls-first], fetch=[45], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1, 2, 3, 4}], PageViews=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(TraficSourceID=[$84], SearchEngineID=[$73], AdvEngineID=[$0], Src=[ANNOTATED_PROJECT_EXPR(id=7, backends=[datafusion], CASE(ANNOTATED_PROJECT_EXPR(id=6, backends=[datafusion], AND(ANNOTATED_PROJECT_EXPR(id=4, backends=[datafusion], =($73, 0)), ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], =($0, 0)))), $61, '':VARCHAR))], Dst=[$85], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($12, 62)), ANNOTATED_PREDICATE(id=1, backends=[datafusion], >=($15, TIMESTAMP('2013-07-01':VARCHAR))), ANNOTATED_PREDICATE(id=2, backends=[datafusion], <=($15, TIMESTAMP('2013-07-31':VARCHAR))), ANNOTATED_PREDICATE(id=3, backends=[datafusion], =($40, 0)))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$5], sort1=[$0], sort2=[$1], sort3=[$2], sort4=[$3], sort5=[$4], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], dir3=[ASC-nulls-first], dir4=[ASC-nulls-first], dir5=[ASC-nulls-first], fetch=[45], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1, 2, 3, 4}], PageViews=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(TraficSourceID=[$84], SearchEngineID=[$73], AdvEngineID=[$0], Src=[ANNOTATED_PROJECT_EXPR(id=7, backends=[datafusion], CASE(ANNOTATED_PROJECT_EXPR(id=6, backends=[datafusion], AND(ANNOTATED_PROJECT_EXPR(id=4, backends=[datafusion], =($73, 0)), ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], =($0, 0)))), $61, '':VARCHAR))], Dst=[$85], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($12, 62)), ANNOTATED_PREDICATE(id=1, backends=[datafusion], >=($15, TIMESTAMP('2013-07-01':VARCHAR))), ANNOTATED_PREDICATE(id=2, backends=[datafusion], <=($15, TIMESTAMP('2013-07-31':VARCHAR))), ANNOTATED_PREDICATE(id=3, backends=[datafusion], =($40, 0)))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], sort3=[$3], sort4=[$4], sort5=[$5], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], dir3=[ASC-nulls-first], dir4=[ASC-nulls-first], dir5=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], sort3=[$3], sort4=[$4], sort5=[$5], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], dir3=[ASC-nulls-first], dir4=[ASC-nulls-first], dir5=[ASC-nulls-first], offset=[5], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(PageViews=[$5], TraficSourceID=[$0], SearchEngineID=[$1], AdvEngineID=[$2], Src=[$3], Dst=[$4], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1, 2, 3, 4}], PageViews=[SUM($5)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[TraficSourceID@0 as TraficSourceID, SearchEngineID@1 as SearchEngineID, AdvEngineID@2 as AdvEngineID, CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END@3 as Src, URL@4 as Dst, count(Int64(1))[count]@5 as PageViews] + SortPreservingMergeExec: [count(Int64(1))@5 DESC NULLS LAST, TraficSourceID@0 ASC, SearchEngineID@1 ASC, AdvEngineID@2 ASC, CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END@3 ASC, URL@4 ASC], fetch=45 + SortExec: TopK(fetch=45), expr=[count(Int64(1))@5 DESC NULLS LAST, TraficSourceID@0 ASC, SearchEngineID@1 ASC, AdvEngineID@2 ASC, CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END@3 ASC, URL@4 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[TraficSourceID@0 as TraficSourceID, SearchEngineID@1 as SearchEngineID, AdvEngineID@2 as AdvEngineID, CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END@3 as CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END, URL@4 as URL], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([TraficSourceID@0, SearchEngineID@1, AdvEngineID@2, CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END@3, URL@4], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[TraficSourceID@0 as TraficSourceID, SearchEngineID@1 as SearchEngineID, AdvEngineID@2 as AdvEngineID, CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END@3 as CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END, URL@4 as URL], aggr=[count(Int64(1))] + ProjectionExec: expr=[TraficSourceID@0 as TraficSourceID, SearchEngineID@1 as SearchEngineID, AdvEngineID@2 as AdvEngineID, CASE WHEN SearchEngineID@1 = 0 AND AdvEngineID@2 = 0 THEN Referer@3 ELSE END as CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END, URL@4 as URL] + FilterExec: CounterID@1 = 62 AND EventDate@2 >= 1372636800000 AND EventDate@2 <= 1375228800000 AND IsRefresh@3 = 0, projection=[TraficSourceID@6, SearchEngineID@5, AdvEngineID@0, Referer@4, URL@7] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[AdvEngineID, CounterID, EventDate, IsRefresh, Referer, SearchEngineID, TraficSourceID, URL], file_type=parquet, predicate=CounterID@107 = 62 AND EventDate@0 >= 1372636800000 AND EventDate@0 <= 1375228800000 AND IsRefresh@74 = 0, pruning_predicate=CounterID_null_count@2 != row_count@3 AND CounterID_min@0 <= 62 AND 62 <= CounterID_max@1 AND EventDate_null_count@5 != row_count@3 AND EventDate_max@4 >= 1372636800000 AND EventDate_null_count@5 != row_count@3 AND EventDate_min@6 <= 1375228800000 AND IsRefresh_null_count@9 != row_count@3 AND IsRefresh_min@7 <= 0 AND 0 <= IsRefresh_max@8, required_guarantees=[CounterID in (62), IsRefresh in (0)] + shard_physical_nseg: | + ProjectionExec: expr=[TraficSourceID@0 as TraficSourceID, SearchEngineID@1 as SearchEngineID, AdvEngineID@2 as AdvEngineID, CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END@3 as Src, URL@4 as Dst, count(Int64(1))[count]@5 as PageViews] + SortPreservingMergeExec: [count(Int64(1))@5 DESC NULLS LAST, TraficSourceID@0 ASC, SearchEngineID@1 ASC, AdvEngineID@2 ASC, CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END@3 ASC, URL@4 ASC], fetch=45 + SortExec: TopK(fetch=45), expr=[count(Int64(1))@5 DESC NULLS LAST, TraficSourceID@0 ASC, SearchEngineID@1 ASC, AdvEngineID@2 ASC, CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END@3 ASC, URL@4 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[TraficSourceID@0 as TraficSourceID, SearchEngineID@1 as SearchEngineID, AdvEngineID@2 as AdvEngineID, CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END@3 as CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END, URL@4 as URL], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([TraficSourceID@0, SearchEngineID@1, AdvEngineID@2, CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END@3, URL@4], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[TraficSourceID@0 as TraficSourceID, SearchEngineID@1 as SearchEngineID, AdvEngineID@2 as AdvEngineID, CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END@3 as CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END, URL@4 as URL], aggr=[count(Int64(1))] + ProjectionExec: expr=[TraficSourceID@0 as TraficSourceID, SearchEngineID@1 as SearchEngineID, AdvEngineID@2 as AdvEngineID, CASE WHEN SearchEngineID@1 = 0 AND AdvEngineID@2 = 0 THEN Referer@3 ELSE END as CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END, URL@4 as URL] + FilterExec: CounterID@1 = 62 AND EventDate@2 >= 1372636800000 AND EventDate@2 <= 1375228800000 AND IsRefresh@3 = 0, projection=[TraficSourceID@6, SearchEngineID@5, AdvEngineID@0, Referer@4, URL@7] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[AdvEngineID, CounterID, EventDate, IsRefresh, Referer, SearchEngineID, TraficSourceID, URL], file_type=parquet, predicate=CounterID@107 = 62 AND EventDate@0 >= 1372636800000 AND EventDate@0 <= 1375228800000 AND IsRefresh@74 = 0, pruning_predicate=CounterID_null_count@2 != row_count@3 AND CounterID_min@0 <= 62 AND 62 <= CounterID_max@1 AND EventDate_null_count@5 != row_count@3 AND EventDate_max@4 >= 1372636800000 AND EventDate_null_count@5 != row_count@3 AND EventDate_min@6 <= 1375228800000 AND IsRefresh_null_count@9 != row_count@3 AND IsRefresh_min@7 <= 0 AND 0 <= IsRefresh_max@8, required_guarantees=[CounterID in (62), IsRefresh in (0)] + coord_physical: | + ProjectionExec: expr=[sum(input-0.PageViews)@0 as PageViews, TraficSourceID@1 as TraficSourceID, SearchEngineID@2 as SearchEngineID, AdvEngineID@3 as AdvEngineID, Src@4 as Src, Dst@5 as Dst] + GlobalLimitExec: skip=5, fetch=10 + SortPreservingMergeExec: [sum(input-0.PageViews)@0 DESC NULLS LAST, TraficSourceID@1 ASC, SearchEngineID@2 ASC, AdvEngineID@3 ASC, Src@4 ASC, Dst@5 ASC], fetch=15 + SortExec: TopK(fetch=15), expr=[sum(input-0.PageViews)@0 DESC NULLS LAST, TraficSourceID@1 ASC, SearchEngineID@2 ASC, AdvEngineID@3 ASC, Src@4 ASC, Dst@5 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[sum(input-0.PageViews)@5 as sum(input-0.PageViews), TraficSourceID@0 as TraficSourceID, SearchEngineID@1 as SearchEngineID, AdvEngineID@2 as AdvEngineID, Src@3 as Src, Dst@4 as Dst] + AggregateExec: mode=FinalPartitioned, gby=[TraficSourceID@0 as TraficSourceID, SearchEngineID@1 as SearchEngineID, AdvEngineID@2 as AdvEngineID, Src@3 as Src, Dst@4 as Dst], aggr=[sum(input-0.PageViews)] + RepartitionExec: partitioning=Hash([TraficSourceID@0, SearchEngineID@1, AdvEngineID@2, Src@3, Dst@4], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[TraficSourceID@0 as TraficSourceID, SearchEngineID@1 as SearchEngineID, AdvEngineID@2 as AdvEngineID, Src@3 as Src, Dst@4 as Dst], aggr=[sum(input-0.PageViews)] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + StreamingTableExec: partition_sizes=1, projection=[TraficSourceID, SearchEngineID, AdvEngineID, Src, Dst, PageViews] + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], sort3=[$3], sort4=[$4], sort5=[$5], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], dir3=[ASC-nulls-first], dir4=[ASC-nulls-first], dir5=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], sort3=[$3], sort4=[$4], sort5=[$5], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], dir3=[ASC-nulls-first], dir4=[ASC-nulls-first], dir5=[ASC-nulls-first], offset=[5], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(PageViews=[$5], TraficSourceID=[$0], SearchEngineID=[$1], AdvEngineID=[$2], Src=[$3], Dst=[$4], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1, 2, 3, 4}], PageViews=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(TraficSourceID=[$84], SearchEngineID=[$73], AdvEngineID=[$0], Src=[ANNOTATED_PROJECT_EXPR(id=7, backends=[datafusion], CASE(ANNOTATED_PROJECT_EXPR(id=6, backends=[datafusion], AND(ANNOTATED_PROJECT_EXPR(id=4, backends=[datafusion], =($73, 0)), ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], =($0, 0)))), $61, '':VARCHAR))], Dst=[$85], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($12, 62)), ANNOTATED_PREDICATE(id=1, backends=[datafusion], >=($15, TIMESTAMP('2013-07-01':VARCHAR))), ANNOTATED_PREDICATE(id=2, backends=[datafusion], <=($15, TIMESTAMP('2013-07-31':VARCHAR))), ANNOTATED_PREDICATE(id=3, backends=[datafusion], =($40, 0)))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], sort3=[$3], sort4=[$4], sort5=[$5], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], dir3=[ASC-nulls-first], dir4=[ASC-nulls-first], dir5=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], sort3=[$3], sort4=[$4], sort5=[$5], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], dir3=[ASC-nulls-first], dir4=[ASC-nulls-first], dir5=[ASC-nulls-first], offset=[5], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(PageViews=[$5], TraficSourceID=[$0], SearchEngineID=[$1], AdvEngineID=[$2], Src=[$3], Dst=[$4], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1, 2, 3, 4}], PageViews=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(TraficSourceID=[$84], SearchEngineID=[$73], AdvEngineID=[$0], Src=[ANNOTATED_PROJECT_EXPR(id=7, backends=[datafusion], CASE(ANNOTATED_PROJECT_EXPR(id=6, backends=[datafusion], AND(ANNOTATED_PROJECT_EXPR(id=4, backends=[datafusion], =($73, 0)), ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], =($0, 0)))), $61, '':VARCHAR))], Dst=[$85], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($12, 62)), ANNOTATED_PREDICATE(id=1, backends=[datafusion], >=($15, TIMESTAMP('2013-07-01':VARCHAR))), ANNOTATED_PREDICATE(id=2, backends=[datafusion], <=($15, TIMESTAMP('2013-07-31':VARCHAR))), ANNOTATED_PREDICATE(id=3, backends=[datafusion], =($40, 0)))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[count(Int64(1))@0 as PageViews, TraficSourceID@1 as TraficSourceID, SearchEngineID@2 as SearchEngineID, AdvEngineID@3 as AdvEngineID, CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END@4 as Src, URL@5 as Dst] + GlobalLimitExec: skip=5, fetch=10 + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST, TraficSourceID@1 ASC, SearchEngineID@2 ASC, AdvEngineID@3 ASC, CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END@4 ASC, URL@5 ASC], fetch=15 + SortExec: TopK(fetch=15), expr=[count(Int64(1))@0 DESC NULLS LAST, TraficSourceID@1 ASC, SearchEngineID@2 ASC, AdvEngineID@3 ASC, CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END@4 ASC, URL@5 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@5 as count(Int64(1)), TraficSourceID@0 as TraficSourceID, SearchEngineID@1 as SearchEngineID, AdvEngineID@2 as AdvEngineID, CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END@3 as CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END, URL@4 as URL] + AggregateExec: mode=FinalPartitioned, gby=[TraficSourceID@0 as TraficSourceID, SearchEngineID@1 as SearchEngineID, AdvEngineID@2 as AdvEngineID, CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END@3 as CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END, URL@4 as URL], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([TraficSourceID@0, SearchEngineID@1, AdvEngineID@2, CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END@3, URL@4], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[TraficSourceID@0 as TraficSourceID, SearchEngineID@1 as SearchEngineID, AdvEngineID@2 as AdvEngineID, CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END@3 as CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END, URL@4 as URL], aggr=[count(Int64(1))] + ProjectionExec: expr=[TraficSourceID@0 as TraficSourceID, SearchEngineID@1 as SearchEngineID, AdvEngineID@2 as AdvEngineID, CASE WHEN SearchEngineID@1 = 0 AND AdvEngineID@2 = 0 THEN Referer@3 ELSE END as CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END, URL@4 as URL] + FilterExec: CounterID@1 = 62 AND EventDate@2 >= 1372636800000 AND EventDate@2 <= 1375228800000 AND IsRefresh@3 = 0, projection=[TraficSourceID@6, SearchEngineID@5, AdvEngineID@0, Referer@4, URL@7] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[AdvEngineID, CounterID, EventDate, IsRefresh, Referer, SearchEngineID, TraficSourceID, URL], file_type=parquet, predicate=CounterID@107 = 62 AND EventDate@0 >= 1372636800000 AND EventDate@0 <= 1375228800000 AND IsRefresh@74 = 0, pruning_predicate=CounterID_null_count@2 != row_count@3 AND CounterID_min@0 <= 62 AND 62 <= CounterID_max@1 AND EventDate_null_count@5 != row_count@3 AND EventDate_max@4 >= 1372636800000 AND EventDate_null_count@5 != row_count@3 AND EventDate_min@6 <= 1375228800000 AND IsRefresh_null_count@9 != row_count@3 AND IsRefresh_min@7 <= 0 AND 0 <= IsRefresh_max@8, required_guarantees=[CounterID in (62), IsRefresh in (0)] + shard_physical_nseg: | + ProjectionExec: expr=[count(Int64(1))@0 as PageViews, TraficSourceID@1 as TraficSourceID, SearchEngineID@2 as SearchEngineID, AdvEngineID@3 as AdvEngineID, CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END@4 as Src, URL@5 as Dst] + GlobalLimitExec: skip=5, fetch=10 + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST, TraficSourceID@1 ASC, SearchEngineID@2 ASC, AdvEngineID@3 ASC, CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END@4 ASC, URL@5 ASC], fetch=15 + SortExec: TopK(fetch=15), expr=[count(Int64(1))@0 DESC NULLS LAST, TraficSourceID@1 ASC, SearchEngineID@2 ASC, AdvEngineID@3 ASC, CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END@4 ASC, URL@5 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@5 as count(Int64(1)), TraficSourceID@0 as TraficSourceID, SearchEngineID@1 as SearchEngineID, AdvEngineID@2 as AdvEngineID, CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END@3 as CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END, URL@4 as URL] + AggregateExec: mode=FinalPartitioned, gby=[TraficSourceID@0 as TraficSourceID, SearchEngineID@1 as SearchEngineID, AdvEngineID@2 as AdvEngineID, CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END@3 as CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END, URL@4 as URL], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([TraficSourceID@0, SearchEngineID@1, AdvEngineID@2, CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END@3, URL@4], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[TraficSourceID@0 as TraficSourceID, SearchEngineID@1 as SearchEngineID, AdvEngineID@2 as AdvEngineID, CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END@3 as CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END, URL@4 as URL], aggr=[count(Int64(1))] + ProjectionExec: expr=[TraficSourceID@0 as TraficSourceID, SearchEngineID@1 as SearchEngineID, AdvEngineID@2 as AdvEngineID, CASE WHEN SearchEngineID@1 = 0 AND AdvEngineID@2 = 0 THEN Referer@3 ELSE END as CASE WHEN .SearchEngineID = Int32(0) AND .AdvEngineID = Int32(0) THEN .Referer ELSE Utf8("") END, URL@4 as URL] + FilterExec: CounterID@1 = 62 AND EventDate@2 >= 1372636800000 AND EventDate@2 <= 1375228800000 AND IsRefresh@3 = 0, projection=[TraficSourceID@6, SearchEngineID@5, AdvEngineID@0, Referer@4, URL@7] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[AdvEngineID, CounterID, EventDate, IsRefresh, Referer, SearchEngineID, TraficSourceID, URL], file_type=parquet, predicate=CounterID@107 = 62 AND EventDate@0 >= 1372636800000 AND EventDate@0 <= 1375228800000 AND IsRefresh@74 = 0, pruning_predicate=CounterID_null_count@2 != row_count@3 AND CounterID_min@0 <= 62 AND 62 <= CounterID_max@1 AND EventDate_null_count@5 != row_count@3 AND EventDate_max@4 >= 1372636800000 AND EventDate_null_count@5 != row_count@3 AND EventDate_min@6 <= 1375228800000 AND IsRefresh_null_count@9 != row_count@3 AND IsRefresh_min@7 <= 0 AND 0 <= IsRefresh_max@8, required_guarantees=[CounterID in (62), IsRefresh in (0)] diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q41.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q41.plan.yaml new file mode 100644 index 0000000000000..6de64fa9aabc0 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q41.plan.yaml @@ -0,0 +1,105 @@ +# IN predicate + RefererHash equality in multi-predicate conjunction. +# All numeric/date filters; parquet DataSourceExec with grouped TopK and offset. +query: q41 +ppl_file: q41.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], offset=[2], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(PageViews=[$2], URLHash=[$0], EventDate=[$1], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], PageViews=[SUM($2)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchSort(sort0=[$2], sort1=[$0], sort2=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[36], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], PageViews=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(URLHash=[$87], EventDate=[$15], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($12, 62)), ANNOTATED_PREDICATE(id=1, backends=[datafusion], >=($15, TIMESTAMP('2013-07-01':VARCHAR))), ANNOTATED_PREDICATE(id=2, backends=[datafusion], <=($15, TIMESTAMP('2013-07-31':VARCHAR))), ANNOTATED_PREDICATE(id=3, backends=[datafusion], =($40, 0)), ANNOTATED_PREDICATE(id=4, backends=[datafusion], SEARCH($84, Sarg[-1, 6])), ANNOTATED_PREDICATE(id=5, backends=[datafusion], =($63, 3594120000172545465)))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$2], sort1=[$0], sort2=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[36], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], PageViews=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(URLHash=[$87], EventDate=[$15], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($12, 62)), ANNOTATED_PREDICATE(id=1, backends=[datafusion], >=($15, TIMESTAMP('2013-07-01':VARCHAR))), ANNOTATED_PREDICATE(id=2, backends=[datafusion], <=($15, TIMESTAMP('2013-07-31':VARCHAR))), ANNOTATED_PREDICATE(id=3, backends=[datafusion], =($40, 0)), ANNOTATED_PREDICATE(id=4, backends=[datafusion], SEARCH($84, Sarg[-1, 6])), ANNOTATED_PREDICATE(id=5, backends=[datafusion], =($63, 3594120000172545465)))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], offset=[2], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(PageViews=[$2], URLHash=[$0], EventDate=[$1], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], PageViews=[SUM($2)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[URLHash@0 as URLHash, EventDate@1 as EventDate, count(Int64(1))[count]@2 as PageViews] + SortPreservingMergeExec: [count(Int64(1))@2 DESC NULLS LAST, URLHash@0 ASC, EventDate@1 ASC], fetch=36 + SortExec: TopK(fetch=36), expr=[count(Int64(1))@2 DESC NULLS LAST, URLHash@0 ASC, EventDate@1 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[URLHash@0 as URLHash, EventDate@1 as EventDate], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([URLHash@0, EventDate@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[URLHash@0 as URLHash, EventDate@1 as EventDate], aggr=[count(Int64(1))] + FilterExec: CounterID@0 = 62 AND EventDate@1 >= 1372636800000 AND EventDate@1 <= 1375228800000 AND IsRefresh@2 = 0 AND (TraficSourceID@4 = -1 OR TraficSourceID@4 = 6) AND RefererHash@3 = 3594120000172545465, projection=[URLHash@5, EventDate@1] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[CounterID, EventDate, IsRefresh, RefererHash, TraficSourceID, URLHash], file_type=parquet, predicate=CounterID@107 = 62 AND EventDate@0 >= 1372636800000 AND EventDate@0 <= 1375228800000 AND IsRefresh@74 = 0 AND (TraficSourceID@13 = -1 OR TraficSourceID@13 = 6) AND RefererHash@12 = 3594120000172545465, pruning_predicate=CounterID_null_count@2 != row_count@3 AND CounterID_min@0 <= 62 AND 62 <= CounterID_max@1 AND EventDate_null_count@5 != row_count@3 AND EventDate_max@4 >= 1372636800000 AND EventDate_null_count@5 != row_count@3 AND EventDate_min@6 <= 1375228800000 AND IsRefresh_null_count@9 != row_count@3 AND IsRefresh_min@7 <= 0 AND 0 <= IsRefresh_max@8 AND (TraficSourceID_null_count@12 != row_count@3 AND TraficSourceID_min@10 <= -1 AND -1 <= TraficSourceID_max@11 OR TraficSourceID_null_count@12 != row_count@3 AND TraficSourceID_min@10 <= 6 AND 6 <= TraficSourceID_max@11) AND RefererHash_null_count@15 != row_count@3 AND RefererHash_min@13 <= 3594120000172545465 AND 3594120000172545465 <= RefererHash_max@14, required_guarantees=[CounterID in (62), IsRefresh in (0), RefererHash in (3594120000172545465), TraficSourceID in (-1, 6)] + shard_physical_nseg: | + ProjectionExec: expr=[URLHash@0 as URLHash, EventDate@1 as EventDate, count(Int64(1))[count]@2 as PageViews] + SortPreservingMergeExec: [count(Int64(1))@2 DESC NULLS LAST, URLHash@0 ASC, EventDate@1 ASC], fetch=36 + SortExec: TopK(fetch=36), expr=[count(Int64(1))@2 DESC NULLS LAST, URLHash@0 ASC, EventDate@1 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[URLHash@0 as URLHash, EventDate@1 as EventDate], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([URLHash@0, EventDate@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[URLHash@0 as URLHash, EventDate@1 as EventDate], aggr=[count(Int64(1))] + FilterExec: CounterID@0 = 62 AND EventDate@1 >= 1372636800000 AND EventDate@1 <= 1375228800000 AND IsRefresh@2 = 0 AND (TraficSourceID@4 = -1 OR TraficSourceID@4 = 6) AND RefererHash@3 = 3594120000172545465, projection=[URLHash@5, EventDate@1] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[CounterID, EventDate, IsRefresh, RefererHash, TraficSourceID, URLHash], file_type=parquet, predicate=CounterID@107 = 62 AND EventDate@0 >= 1372636800000 AND EventDate@0 <= 1375228800000 AND IsRefresh@74 = 0 AND (TraficSourceID@13 = -1 OR TraficSourceID@13 = 6) AND RefererHash@12 = 3594120000172545465, pruning_predicate=CounterID_null_count@2 != row_count@3 AND CounterID_min@0 <= 62 AND 62 <= CounterID_max@1 AND EventDate_null_count@5 != row_count@3 AND EventDate_max@4 >= 1372636800000 AND EventDate_null_count@5 != row_count@3 AND EventDate_min@6 <= 1375228800000 AND IsRefresh_null_count@9 != row_count@3 AND IsRefresh_min@7 <= 0 AND 0 <= IsRefresh_max@8 AND (TraficSourceID_null_count@12 != row_count@3 AND TraficSourceID_min@10 <= -1 AND -1 <= TraficSourceID_max@11 OR TraficSourceID_null_count@12 != row_count@3 AND TraficSourceID_min@10 <= 6 AND 6 <= TraficSourceID_max@11) AND RefererHash_null_count@15 != row_count@3 AND RefererHash_min@13 <= 3594120000172545465 AND 3594120000172545465 <= RefererHash_max@14, required_guarantees=[CounterID in (62), IsRefresh in (0), RefererHash in (3594120000172545465), TraficSourceID in (-1, 6)] + coord_physical: | + ProjectionExec: expr=[sum(input-0.PageViews)@0 as PageViews, URLHash@1 as URLHash, EventDate@2 as EventDate] + GlobalLimitExec: skip=2, fetch=10 + SortPreservingMergeExec: [sum(input-0.PageViews)@0 DESC NULLS LAST, URLHash@1 ASC, EventDate@2 ASC], fetch=12 + SortExec: TopK(fetch=12), expr=[sum(input-0.PageViews)@0 DESC NULLS LAST, URLHash@1 ASC, EventDate@2 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[sum(input-0.PageViews)@2 as sum(input-0.PageViews), URLHash@0 as URLHash, EventDate@1 as EventDate] + AggregateExec: mode=FinalPartitioned, gby=[URLHash@0 as URLHash, EventDate@1 as EventDate], aggr=[sum(input-0.PageViews)] + RepartitionExec: partitioning=Hash([URLHash@0, EventDate@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[URLHash@0 as URLHash, EventDate@1 as EventDate], aggr=[sum(input-0.PageViews)] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + StreamingTableExec: partition_sizes=1, projection=[URLHash, EventDate, PageViews] + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], offset=[2], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(PageViews=[$2], URLHash=[$0], EventDate=[$1], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], PageViews=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(URLHash=[$87], EventDate=[$15], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($12, 62)), ANNOTATED_PREDICATE(id=1, backends=[datafusion], >=($15, TIMESTAMP('2013-07-01':VARCHAR))), ANNOTATED_PREDICATE(id=2, backends=[datafusion], <=($15, TIMESTAMP('2013-07-31':VARCHAR))), ANNOTATED_PREDICATE(id=3, backends=[datafusion], =($40, 0)), ANNOTATED_PREDICATE(id=4, backends=[datafusion], SEARCH($84, Sarg[-1, 6])), ANNOTATED_PREDICATE(id=5, backends=[datafusion], =($63, 3594120000172545465)))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], offset=[2], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(PageViews=[$2], URLHash=[$0], EventDate=[$1], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], PageViews=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(URLHash=[$87], EventDate=[$15], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($12, 62)), ANNOTATED_PREDICATE(id=1, backends=[datafusion], >=($15, TIMESTAMP('2013-07-01':VARCHAR))), ANNOTATED_PREDICATE(id=2, backends=[datafusion], <=($15, TIMESTAMP('2013-07-31':VARCHAR))), ANNOTATED_PREDICATE(id=3, backends=[datafusion], =($40, 0)), ANNOTATED_PREDICATE(id=4, backends=[datafusion], SEARCH($84, Sarg[-1, 6])), ANNOTATED_PREDICATE(id=5, backends=[datafusion], =($63, 3594120000172545465)))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[count(Int64(1))@0 as PageViews, URLHash@1 as URLHash, EventDate@2 as EventDate] + GlobalLimitExec: skip=2, fetch=10 + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST, URLHash@1 ASC, EventDate@2 ASC], fetch=12 + SortExec: TopK(fetch=12), expr=[count(Int64(1))@0 DESC NULLS LAST, URLHash@1 ASC, EventDate@2 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@2 as count(Int64(1)), URLHash@0 as URLHash, EventDate@1 as EventDate] + AggregateExec: mode=FinalPartitioned, gby=[URLHash@0 as URLHash, EventDate@1 as EventDate], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([URLHash@0, EventDate@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[URLHash@0 as URLHash, EventDate@1 as EventDate], aggr=[count(Int64(1))] + FilterExec: CounterID@0 = 62 AND EventDate@1 >= 1372636800000 AND EventDate@1 <= 1375228800000 AND IsRefresh@2 = 0 AND (TraficSourceID@4 = -1 OR TraficSourceID@4 = 6) AND RefererHash@3 = 3594120000172545465, projection=[URLHash@5, EventDate@1] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[CounterID, EventDate, IsRefresh, RefererHash, TraficSourceID, URLHash], file_type=parquet, predicate=CounterID@107 = 62 AND EventDate@0 >= 1372636800000 AND EventDate@0 <= 1375228800000 AND IsRefresh@74 = 0 AND (TraficSourceID@13 = -1 OR TraficSourceID@13 = 6) AND RefererHash@12 = 3594120000172545465, pruning_predicate=CounterID_null_count@2 != row_count@3 AND CounterID_min@0 <= 62 AND 62 <= CounterID_max@1 AND EventDate_null_count@5 != row_count@3 AND EventDate_max@4 >= 1372636800000 AND EventDate_null_count@5 != row_count@3 AND EventDate_min@6 <= 1375228800000 AND IsRefresh_null_count@9 != row_count@3 AND IsRefresh_min@7 <= 0 AND 0 <= IsRefresh_max@8 AND (TraficSourceID_null_count@12 != row_count@3 AND TraficSourceID_min@10 <= -1 AND -1 <= TraficSourceID_max@11 OR TraficSourceID_null_count@12 != row_count@3 AND TraficSourceID_min@10 <= 6 AND 6 <= TraficSourceID_max@11) AND RefererHash_null_count@15 != row_count@3 AND RefererHash_min@13 <= 3594120000172545465 AND 3594120000172545465 <= RefererHash_max@14, required_guarantees=[CounterID in (62), IsRefresh in (0), RefererHash in (3594120000172545465), TraficSourceID in (-1, 6)] + shard_physical_nseg: | + ProjectionExec: expr=[count(Int64(1))@0 as PageViews, URLHash@1 as URLHash, EventDate@2 as EventDate] + GlobalLimitExec: skip=2, fetch=10 + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST, URLHash@1 ASC, EventDate@2 ASC], fetch=12 + SortExec: TopK(fetch=12), expr=[count(Int64(1))@0 DESC NULLS LAST, URLHash@1 ASC, EventDate@2 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@2 as count(Int64(1)), URLHash@0 as URLHash, EventDate@1 as EventDate] + AggregateExec: mode=FinalPartitioned, gby=[URLHash@0 as URLHash, EventDate@1 as EventDate], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([URLHash@0, EventDate@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[URLHash@0 as URLHash, EventDate@1 as EventDate], aggr=[count(Int64(1))] + FilterExec: CounterID@0 = 62 AND EventDate@1 >= 1372636800000 AND EventDate@1 <= 1375228800000 AND IsRefresh@2 = 0 AND (TraficSourceID@4 = -1 OR TraficSourceID@4 = 6) AND RefererHash@3 = 3594120000172545465, projection=[URLHash@5, EventDate@1] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[CounterID, EventDate, IsRefresh, RefererHash, TraficSourceID, URLHash], file_type=parquet, predicate=CounterID@107 = 62 AND EventDate@0 >= 1372636800000 AND EventDate@0 <= 1375228800000 AND IsRefresh@74 = 0 AND (TraficSourceID@13 = -1 OR TraficSourceID@13 = 6) AND RefererHash@12 = 3594120000172545465, pruning_predicate=CounterID_null_count@2 != row_count@3 AND CounterID_min@0 <= 62 AND 62 <= CounterID_max@1 AND EventDate_null_count@5 != row_count@3 AND EventDate_max@4 >= 1372636800000 AND EventDate_null_count@5 != row_count@3 AND EventDate_min@6 <= 1375228800000 AND IsRefresh_null_count@9 != row_count@3 AND IsRefresh_min@7 <= 0 AND 0 <= IsRefresh_max@8 AND (TraficSourceID_null_count@12 != row_count@3 AND TraficSourceID_min@10 <= -1 AND -1 <= TraficSourceID_max@11 OR TraficSourceID_null_count@12 != row_count@3 AND TraficSourceID_min@10 <= 6 AND 6 <= TraficSourceID_max@11) AND RefererHash_null_count@15 != row_count@3 AND RefererHash_min@13 <= 3594120000172545465 AND 3594120000172545465 <= RefererHash_max@14, required_guarantees=[CounterID in (62), IsRefresh in (0), RefererHash in (3594120000172545465), TraficSourceID in (-1, 6)] diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q42.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q42.plan.yaml new file mode 100644 index 0000000000000..2083105e1ede4 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q42.plan.yaml @@ -0,0 +1,105 @@ +# URLHash equality in deep multi-predicate conjunction. +# Tests hash-value long equality with (WindowClientWidth, WindowClientHeight) grouping. +query: q42 +ppl_file: q42.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], offset=[5], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(PageViews=[$2], WindowClientWidth=[$0], WindowClientHeight=[$1], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], PageViews=[SUM($2)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchSort(sort0=[$2], sort1=[$0], sort2=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[45], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], PageViews=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(WindowClientWidth=[$100], WindowClientHeight=[$99], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($12, 62)), ANNOTATED_PREDICATE(id=1, backends=[datafusion], >=($15, TIMESTAMP('2013-07-01':VARCHAR))), ANNOTATED_PREDICATE(id=2, backends=[datafusion], <=($15, TIMESTAMP('2013-07-31':VARCHAR))), ANNOTATED_PREDICATE(id=3, backends=[datafusion], =($40, 0)), ANNOTATED_PREDICATE(id=4, backends=[datafusion], =($14, 0)), ANNOTATED_PREDICATE(id=5, backends=[datafusion], =($87, 2868770270353813622)))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$2], sort1=[$0], sort2=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[45], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], PageViews=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(WindowClientWidth=[$100], WindowClientHeight=[$99], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($12, 62)), ANNOTATED_PREDICATE(id=1, backends=[datafusion], >=($15, TIMESTAMP('2013-07-01':VARCHAR))), ANNOTATED_PREDICATE(id=2, backends=[datafusion], <=($15, TIMESTAMP('2013-07-31':VARCHAR))), ANNOTATED_PREDICATE(id=3, backends=[datafusion], =($40, 0)), ANNOTATED_PREDICATE(id=4, backends=[datafusion], =($14, 0)), ANNOTATED_PREDICATE(id=5, backends=[datafusion], =($87, 2868770270353813622)))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], offset=[5], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(PageViews=[$2], WindowClientWidth=[$0], WindowClientHeight=[$1], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], PageViews=[SUM($2)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[WindowClientWidth@0 as WindowClientWidth, WindowClientHeight@1 as WindowClientHeight, count(Int64(1))[count]@2 as PageViews] + SortPreservingMergeExec: [count(Int64(1))@2 DESC NULLS LAST, WindowClientWidth@0 ASC, WindowClientHeight@1 ASC], fetch=45 + SortExec: TopK(fetch=45), expr=[count(Int64(1))@2 DESC NULLS LAST, WindowClientWidth@0 ASC, WindowClientHeight@1 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[WindowClientWidth@0 as WindowClientWidth, WindowClientHeight@1 as WindowClientHeight], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([WindowClientWidth@0, WindowClientHeight@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[WindowClientWidth@0 as WindowClientWidth, WindowClientHeight@1 as WindowClientHeight], aggr=[count(Int64(1))] + FilterExec: CounterID@0 = 62 AND EventDate@2 >= 1372636800000 AND EventDate@2 <= 1375228800000 AND IsRefresh@3 = 0 AND DontCountHits@1 = 0 AND URLHash@4 = 2868770270353813622, projection=[WindowClientWidth@6, WindowClientHeight@5] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[CounterID, DontCountHits, EventDate, IsRefresh, URLHash, WindowClientHeight, WindowClientWidth], file_type=parquet, predicate=CounterID@107 = 62 AND EventDate@0 >= 1372636800000 AND EventDate@0 <= 1375228800000 AND IsRefresh@74 = 0 AND DontCountHits@43 = 0 AND URLHash@26 = 2868770270353813622, pruning_predicate=CounterID_null_count@2 != row_count@3 AND CounterID_min@0 <= 62 AND 62 <= CounterID_max@1 AND EventDate_null_count@5 != row_count@3 AND EventDate_max@4 >= 1372636800000 AND EventDate_null_count@5 != row_count@3 AND EventDate_min@6 <= 1375228800000 AND IsRefresh_null_count@9 != row_count@3 AND IsRefresh_min@7 <= 0 AND 0 <= IsRefresh_max@8 AND DontCountHits_null_count@12 != row_count@3 AND DontCountHits_min@10 <= 0 AND 0 <= DontCountHits_max@11 AND URLHash_null_count@15 != row_count@3 AND URLHash_min@13 <= 2868770270353813622 AND 2868770270353813622 <= URLHash_max@14, required_guarantees=[CounterID in (62), DontCountHits in (0), IsRefresh in (0), URLHash in (2868770270353813622)] + shard_physical_nseg: | + ProjectionExec: expr=[WindowClientWidth@0 as WindowClientWidth, WindowClientHeight@1 as WindowClientHeight, count(Int64(1))[count]@2 as PageViews] + SortPreservingMergeExec: [count(Int64(1))@2 DESC NULLS LAST, WindowClientWidth@0 ASC, WindowClientHeight@1 ASC], fetch=45 + SortExec: TopK(fetch=45), expr=[count(Int64(1))@2 DESC NULLS LAST, WindowClientWidth@0 ASC, WindowClientHeight@1 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[WindowClientWidth@0 as WindowClientWidth, WindowClientHeight@1 as WindowClientHeight], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([WindowClientWidth@0, WindowClientHeight@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[WindowClientWidth@0 as WindowClientWidth, WindowClientHeight@1 as WindowClientHeight], aggr=[count(Int64(1))] + FilterExec: CounterID@0 = 62 AND EventDate@2 >= 1372636800000 AND EventDate@2 <= 1375228800000 AND IsRefresh@3 = 0 AND DontCountHits@1 = 0 AND URLHash@4 = 2868770270353813622, projection=[WindowClientWidth@6, WindowClientHeight@5] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[CounterID, DontCountHits, EventDate, IsRefresh, URLHash, WindowClientHeight, WindowClientWidth], file_type=parquet, predicate=CounterID@107 = 62 AND EventDate@0 >= 1372636800000 AND EventDate@0 <= 1375228800000 AND IsRefresh@74 = 0 AND DontCountHits@43 = 0 AND URLHash@26 = 2868770270353813622, pruning_predicate=CounterID_null_count@2 != row_count@3 AND CounterID_min@0 <= 62 AND 62 <= CounterID_max@1 AND EventDate_null_count@5 != row_count@3 AND EventDate_max@4 >= 1372636800000 AND EventDate_null_count@5 != row_count@3 AND EventDate_min@6 <= 1375228800000 AND IsRefresh_null_count@9 != row_count@3 AND IsRefresh_min@7 <= 0 AND 0 <= IsRefresh_max@8 AND DontCountHits_null_count@12 != row_count@3 AND DontCountHits_min@10 <= 0 AND 0 <= DontCountHits_max@11 AND URLHash_null_count@15 != row_count@3 AND URLHash_min@13 <= 2868770270353813622 AND 2868770270353813622 <= URLHash_max@14, required_guarantees=[CounterID in (62), DontCountHits in (0), IsRefresh in (0), URLHash in (2868770270353813622)] + coord_physical: | + ProjectionExec: expr=[sum(input-0.PageViews)@0 as PageViews, WindowClientWidth@1 as WindowClientWidth, WindowClientHeight@2 as WindowClientHeight] + GlobalLimitExec: skip=5, fetch=10 + SortPreservingMergeExec: [sum(input-0.PageViews)@0 DESC NULLS LAST, WindowClientWidth@1 ASC, WindowClientHeight@2 ASC], fetch=15 + SortExec: TopK(fetch=15), expr=[sum(input-0.PageViews)@0 DESC NULLS LAST, WindowClientWidth@1 ASC, WindowClientHeight@2 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[sum(input-0.PageViews)@2 as sum(input-0.PageViews), WindowClientWidth@0 as WindowClientWidth, WindowClientHeight@1 as WindowClientHeight] + AggregateExec: mode=FinalPartitioned, gby=[WindowClientWidth@0 as WindowClientWidth, WindowClientHeight@1 as WindowClientHeight], aggr=[sum(input-0.PageViews)] + RepartitionExec: partitioning=Hash([WindowClientWidth@0, WindowClientHeight@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[WindowClientWidth@0 as WindowClientWidth, WindowClientHeight@1 as WindowClientHeight], aggr=[sum(input-0.PageViews)] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + StreamingTableExec: partition_sizes=1, projection=[WindowClientWidth, WindowClientHeight, PageViews] + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], offset=[5], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(PageViews=[$2], WindowClientWidth=[$0], WindowClientHeight=[$1], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], PageViews=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(WindowClientWidth=[$100], WindowClientHeight=[$99], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($12, 62)), ANNOTATED_PREDICATE(id=1, backends=[datafusion], >=($15, TIMESTAMP('2013-07-01':VARCHAR))), ANNOTATED_PREDICATE(id=2, backends=[datafusion], <=($15, TIMESTAMP('2013-07-31':VARCHAR))), ANNOTATED_PREDICATE(id=3, backends=[datafusion], =($40, 0)), ANNOTATED_PREDICATE(id=4, backends=[datafusion], =($14, 0)), ANNOTATED_PREDICATE(id=5, backends=[datafusion], =($87, 2868770270353813622)))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], sort2=[$2], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], offset=[5], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(PageViews=[$2], WindowClientWidth=[$0], WindowClientHeight=[$1], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0, 1}], PageViews=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(WindowClientWidth=[$100], WindowClientHeight=[$99], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($12, 62)), ANNOTATED_PREDICATE(id=1, backends=[datafusion], >=($15, TIMESTAMP('2013-07-01':VARCHAR))), ANNOTATED_PREDICATE(id=2, backends=[datafusion], <=($15, TIMESTAMP('2013-07-31':VARCHAR))), ANNOTATED_PREDICATE(id=3, backends=[datafusion], =($40, 0)), ANNOTATED_PREDICATE(id=4, backends=[datafusion], =($14, 0)), ANNOTATED_PREDICATE(id=5, backends=[datafusion], =($87, 2868770270353813622)))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[count(Int64(1))@0 as PageViews, WindowClientWidth@1 as WindowClientWidth, WindowClientHeight@2 as WindowClientHeight] + GlobalLimitExec: skip=5, fetch=10 + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST, WindowClientWidth@1 ASC, WindowClientHeight@2 ASC], fetch=15 + SortExec: TopK(fetch=15), expr=[count(Int64(1))@0 DESC NULLS LAST, WindowClientWidth@1 ASC, WindowClientHeight@2 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@2 as count(Int64(1)), WindowClientWidth@0 as WindowClientWidth, WindowClientHeight@1 as WindowClientHeight] + AggregateExec: mode=FinalPartitioned, gby=[WindowClientWidth@0 as WindowClientWidth, WindowClientHeight@1 as WindowClientHeight], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([WindowClientWidth@0, WindowClientHeight@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[WindowClientWidth@0 as WindowClientWidth, WindowClientHeight@1 as WindowClientHeight], aggr=[count(Int64(1))] + FilterExec: CounterID@0 = 62 AND EventDate@2 >= 1372636800000 AND EventDate@2 <= 1375228800000 AND IsRefresh@3 = 0 AND DontCountHits@1 = 0 AND URLHash@4 = 2868770270353813622, projection=[WindowClientWidth@6, WindowClientHeight@5] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[CounterID, DontCountHits, EventDate, IsRefresh, URLHash, WindowClientHeight, WindowClientWidth], file_type=parquet, predicate=CounterID@107 = 62 AND EventDate@0 >= 1372636800000 AND EventDate@0 <= 1375228800000 AND IsRefresh@74 = 0 AND DontCountHits@43 = 0 AND URLHash@26 = 2868770270353813622, pruning_predicate=CounterID_null_count@2 != row_count@3 AND CounterID_min@0 <= 62 AND 62 <= CounterID_max@1 AND EventDate_null_count@5 != row_count@3 AND EventDate_max@4 >= 1372636800000 AND EventDate_null_count@5 != row_count@3 AND EventDate_min@6 <= 1375228800000 AND IsRefresh_null_count@9 != row_count@3 AND IsRefresh_min@7 <= 0 AND 0 <= IsRefresh_max@8 AND DontCountHits_null_count@12 != row_count@3 AND DontCountHits_min@10 <= 0 AND 0 <= DontCountHits_max@11 AND URLHash_null_count@15 != row_count@3 AND URLHash_min@13 <= 2868770270353813622 AND 2868770270353813622 <= URLHash_max@14, required_guarantees=[CounterID in (62), DontCountHits in (0), IsRefresh in (0), URLHash in (2868770270353813622)] + shard_physical_nseg: | + ProjectionExec: expr=[count(Int64(1))@0 as PageViews, WindowClientWidth@1 as WindowClientWidth, WindowClientHeight@2 as WindowClientHeight] + GlobalLimitExec: skip=5, fetch=10 + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST, WindowClientWidth@1 ASC, WindowClientHeight@2 ASC], fetch=15 + SortExec: TopK(fetch=15), expr=[count(Int64(1))@0 DESC NULLS LAST, WindowClientWidth@1 ASC, WindowClientHeight@2 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@2 as count(Int64(1)), WindowClientWidth@0 as WindowClientWidth, WindowClientHeight@1 as WindowClientHeight] + AggregateExec: mode=FinalPartitioned, gby=[WindowClientWidth@0 as WindowClientWidth, WindowClientHeight@1 as WindowClientHeight], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([WindowClientWidth@0, WindowClientHeight@1], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[WindowClientWidth@0 as WindowClientWidth, WindowClientHeight@1 as WindowClientHeight], aggr=[count(Int64(1))] + FilterExec: CounterID@0 = 62 AND EventDate@2 >= 1372636800000 AND EventDate@2 <= 1375228800000 AND IsRefresh@3 = 0 AND DontCountHits@1 = 0 AND URLHash@4 = 2868770270353813622, projection=[WindowClientWidth@6, WindowClientHeight@5] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[CounterID, DontCountHits, EventDate, IsRefresh, URLHash, WindowClientHeight, WindowClientWidth], file_type=parquet, predicate=CounterID@107 = 62 AND EventDate@0 >= 1372636800000 AND EventDate@0 <= 1375228800000 AND IsRefresh@74 = 0 AND DontCountHits@43 = 0 AND URLHash@26 = 2868770270353813622, pruning_predicate=CounterID_null_count@2 != row_count@3 AND CounterID_min@0 <= 62 AND 62 <= CounterID_max@1 AND EventDate_null_count@5 != row_count@3 AND EventDate_max@4 >= 1372636800000 AND EventDate_null_count@5 != row_count@3 AND EventDate_min@6 <= 1375228800000 AND IsRefresh_null_count@9 != row_count@3 AND IsRefresh_min@7 <= 0 AND 0 <= IsRefresh_max@8 AND DontCountHits_null_count@12 != row_count@3 AND DontCountHits_min@10 <= 0 AND 0 <= DontCountHits_max@11 AND URLHash_null_count@15 != row_count@3 AND URLHash_min@13 <= 2868770270353813622 AND 2868770270353813622 <= URLHash_max@14, required_guarantees=[CounterID in (62), DontCountHits in (0), IsRefresh in (0), URLHash in (2868770270353813622)] diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q43.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q43.plan.yaml new file mode 100644 index 0000000000000..fa82fdefd7984 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q43.plan.yaml @@ -0,0 +1,109 @@ +# date_format eval + narrow date range + grouped count + sort by derived column. +# Temporal formatting expression pushdown with multi-predicate filter and offset. +query: q43 +ppl_file: q43.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$1], dir0=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$1], dir0=[ASC-nulls-first], offset=[5], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(PageViews=[$1], M=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], PageViews=[SUM($1)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchSort(sort0=[$0], dir0=[ASC-nulls-first], fetch=[45], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], PageViews=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(M=[ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], DATE_FORMAT($16, '%Y-%m-%d %H:%i:00':VARCHAR))], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($12, 62)), ANNOTATED_PREDICATE(id=1, backends=[datafusion], >=($15, TIMESTAMP('2013-07-14':VARCHAR))), ANNOTATED_PREDICATE(id=2, backends=[datafusion], <=($15, TIMESTAMP('2013-07-15':VARCHAR))), ANNOTATED_PREDICATE(id=3, backends=[datafusion], =($40, 0)), ANNOTATED_PREDICATE(id=4, backends=[datafusion], =($14, 0)))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], dir0=[ASC-nulls-first], fetch=[45], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], PageViews=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(M=[ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], DATE_FORMAT($16, '%Y-%m-%d %H:%i:00':VARCHAR))], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($12, 62)), ANNOTATED_PREDICATE(id=1, backends=[datafusion], >=($15, TIMESTAMP('2013-07-14':VARCHAR))), ANNOTATED_PREDICATE(id=2, backends=[datafusion], <=($15, TIMESTAMP('2013-07-15':VARCHAR))), ANNOTATED_PREDICATE(id=3, backends=[datafusion], =($40, 0)), ANNOTATED_PREDICATE(id=4, backends=[datafusion], =($14, 0)))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$1], dir0=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$1], dir0=[ASC-nulls-first], offset=[5], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(PageViews=[$1], M=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], PageViews=[SUM($1)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))@0 as M, count(Int64(1))[count]@1 as PageViews] + SortPreservingMergeExec: [date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))@0 ASC], fetch=45 + SortExec: TopK(fetch=45), expr=[date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))@0 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))@0 as date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))@0 as date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))], aggr=[count(Int64(1))] + ProjectionExec: expr=[date_format(CAST(EventTime@0 AS Timestamp(µs)), %Y-%m-%d %H:%i:00) as date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))] + FilterExec: CounterID@0 = 62 AND EventDate@2 >= 1373760000000 AND EventDate@2 <= 1373846400000 AND IsRefresh@4 = 0 AND DontCountHits@1 = 0, projection=[EventTime@3] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[CounterID, DontCountHits, EventDate, EventTime, IsRefresh], file_type=parquet, predicate=CounterID@107 = 62 AND EventDate@0 >= 1373760000000 AND EventDate@0 <= 1373846400000 AND IsRefresh@74 = 0 AND DontCountHits@43 = 0 AND DynamicFilter [ ], pruning_predicate=CounterID_null_count@2 != row_count@3 AND CounterID_min@0 <= 62 AND 62 <= CounterID_max@1 AND EventDate_null_count@5 != row_count@3 AND EventDate_max@4 >= 1373760000000 AND EventDate_null_count@5 != row_count@3 AND EventDate_min@6 <= 1373846400000 AND IsRefresh_null_count@9 != row_count@3 AND IsRefresh_min@7 <= 0 AND 0 <= IsRefresh_max@8 AND DontCountHits_null_count@12 != row_count@3 AND DontCountHits_min@10 <= 0 AND 0 <= DontCountHits_max@11, required_guarantees=[CounterID in (62), DontCountHits in (0), IsRefresh in (0)] + shard_physical_nseg: | + ProjectionExec: expr=[date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))@0 as M, count(Int64(1))[count]@1 as PageViews] + SortPreservingMergeExec: [date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))@0 ASC], fetch=45 + SortExec: TopK(fetch=45), expr=[date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))@0 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))@0 as date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))@0 as date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))], aggr=[count(Int64(1))] + ProjectionExec: expr=[date_format(CAST(EventTime@0 AS Timestamp(µs)), %Y-%m-%d %H:%i:00) as date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))] + FilterExec: CounterID@0 = 62 AND EventDate@2 >= 1373760000000 AND EventDate@2 <= 1373846400000 AND IsRefresh@4 = 0 AND DontCountHits@1 = 0, projection=[EventTime@3] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[CounterID, DontCountHits, EventDate, EventTime, IsRefresh], file_type=parquet, predicate=CounterID@107 = 62 AND EventDate@0 >= 1373760000000 AND EventDate@0 <= 1373846400000 AND IsRefresh@74 = 0 AND DontCountHits@43 = 0 AND DynamicFilter [ ], pruning_predicate=CounterID_null_count@2 != row_count@3 AND CounterID_min@0 <= 62 AND 62 <= CounterID_max@1 AND EventDate_null_count@5 != row_count@3 AND EventDate_max@4 >= 1373760000000 AND EventDate_null_count@5 != row_count@3 AND EventDate_min@6 <= 1373846400000 AND IsRefresh_null_count@9 != row_count@3 AND IsRefresh_min@7 <= 0 AND 0 <= IsRefresh_max@8 AND DontCountHits_null_count@12 != row_count@3 AND DontCountHits_min@10 <= 0 AND 0 <= DontCountHits_max@11, required_guarantees=[CounterID in (62), DontCountHits in (0), IsRefresh in (0)] + coord_physical: | + ProjectionExec: expr=[sum(input-0.PageViews)@0 as PageViews, M@1 as M] + GlobalLimitExec: skip=5, fetch=10 + SortPreservingMergeExec: [M@1 ASC], fetch=15 + SortExec: TopK(fetch=15), expr=[M@1 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[sum(input-0.PageViews)@1 as sum(input-0.PageViews), M@0 as M] + AggregateExec: mode=FinalPartitioned, gby=[M@0 as M], aggr=[sum(input-0.PageViews)] + RepartitionExec: partitioning=Hash([M@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[M@0 as M], aggr=[sum(input-0.PageViews)] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + StreamingTableExec: partition_sizes=1, projection=[M, PageViews] + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$1], dir0=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$1], dir0=[ASC-nulls-first], offset=[5], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(PageViews=[$1], M=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], PageViews=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(M=[ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], DATE_FORMAT($16, '%Y-%m-%d %H:%i:00':VARCHAR))], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($12, 62)), ANNOTATED_PREDICATE(id=1, backends=[datafusion], >=($15, TIMESTAMP('2013-07-14':VARCHAR))), ANNOTATED_PREDICATE(id=2, backends=[datafusion], <=($15, TIMESTAMP('2013-07-15':VARCHAR))), ANNOTATED_PREDICATE(id=3, backends=[datafusion], =($40, 0)), ANNOTATED_PREDICATE(id=4, backends=[datafusion], =($14, 0)))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$1], dir0=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$1], dir0=[ASC-nulls-first], offset=[5], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(PageViews=[$1], M=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], PageViews=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(M=[ANNOTATED_PROJECT_EXPR(id=5, backends=[datafusion], DATE_FORMAT($16, '%Y-%m-%d %H:%i:00':VARCHAR))], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[AND(ANNOTATED_PREDICATE(id=0, backends=[datafusion], =($12, 62)), ANNOTATED_PREDICATE(id=1, backends=[datafusion], >=($15, TIMESTAMP('2013-07-14':VARCHAR))), ANNOTATED_PREDICATE(id=2, backends=[datafusion], <=($15, TIMESTAMP('2013-07-15':VARCHAR))), ANNOTATED_PREDICATE(id=3, backends=[datafusion], =($40, 0)), ANNOTATED_PREDICATE(id=4, backends=[datafusion], =($14, 0)))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[count(Int64(1))@0 as PageViews, date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))@1 as M] + GlobalLimitExec: skip=5, fetch=10 + SortPreservingMergeExec: [date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))@1 ASC], fetch=15 + SortExec: TopK(fetch=15), expr=[date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))@1 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@1 as count(Int64(1)), date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))@0 as date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))] + AggregateExec: mode=FinalPartitioned, gby=[date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))@0 as date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))@0 as date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))], aggr=[count(Int64(1))] + ProjectionExec: expr=[date_format(CAST(EventTime@0 AS Timestamp(µs)), %Y-%m-%d %H:%i:00) as date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))] + FilterExec: CounterID@0 = 62 AND EventDate@2 >= 1373760000000 AND EventDate@2 <= 1373846400000 AND IsRefresh@4 = 0 AND DontCountHits@1 = 0, projection=[EventTime@3] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[CounterID, DontCountHits, EventDate, EventTime, IsRefresh], file_type=parquet, predicate=CounterID@107 = 62 AND EventDate@0 >= 1373760000000 AND EventDate@0 <= 1373846400000 AND IsRefresh@74 = 0 AND DontCountHits@43 = 0 AND DynamicFilter [ ], pruning_predicate=CounterID_null_count@2 != row_count@3 AND CounterID_min@0 <= 62 AND 62 <= CounterID_max@1 AND EventDate_null_count@5 != row_count@3 AND EventDate_max@4 >= 1373760000000 AND EventDate_null_count@5 != row_count@3 AND EventDate_min@6 <= 1373846400000 AND IsRefresh_null_count@9 != row_count@3 AND IsRefresh_min@7 <= 0 AND 0 <= IsRefresh_max@8 AND DontCountHits_null_count@12 != row_count@3 AND DontCountHits_min@10 <= 0 AND 0 <= DontCountHits_max@11, required_guarantees=[CounterID in (62), DontCountHits in (0), IsRefresh in (0)] + shard_physical_nseg: | + ProjectionExec: expr=[count(Int64(1))@0 as PageViews, date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))@1 as M] + GlobalLimitExec: skip=5, fetch=10 + SortPreservingMergeExec: [date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))@1 ASC], fetch=15 + SortExec: TopK(fetch=15), expr=[date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))@1 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@1 as count(Int64(1)), date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))@0 as date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))] + AggregateExec: mode=FinalPartitioned, gby=[date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))@0 as date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))@0 as date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))], aggr=[count(Int64(1))] + ProjectionExec: expr=[date_format(CAST(EventTime@0 AS Timestamp(µs)), %Y-%m-%d %H:%i:00) as date_format(.EventTime,Utf8("%Y-%m-%d %H:%i:00"))] + FilterExec: CounterID@0 = 62 AND EventDate@2 >= 1373760000000 AND EventDate@2 <= 1373846400000 AND IsRefresh@4 = 0 AND DontCountHits@1 = 0, projection=[EventTime@3] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[CounterID, DontCountHits, EventDate, EventTime, IsRefresh], file_type=parquet, predicate=CounterID@107 = 62 AND EventDate@0 >= 1373760000000 AND EventDate@0 <= 1373846400000 AND IsRefresh@74 = 0 AND DontCountHits@43 = 0 AND DynamicFilter [ ], pruning_predicate=CounterID_null_count@2 != row_count@3 AND CounterID_min@0 <= 62 AND 62 <= CounterID_max@1 AND EventDate_null_count@5 != row_count@3 AND EventDate_max@4 >= 1373760000000 AND EventDate_null_count@5 != row_count@3 AND EventDate_min@6 <= 1373846400000 AND IsRefresh_null_count@9 != row_count@3 AND IsRefresh_min@7 <= 0 AND 0 <= IsRefresh_max@8 AND DontCountHits_null_count@12 != row_count@3 AND DontCountHits_min@10 <= 0 AND 0 <= DontCountHits_max@11, required_guarantees=[CounterID in (62), DontCountHits in (0), IsRefresh in (0)] diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q5.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q5.plan.yaml new file mode 100644 index 0000000000000..1a3bf1d6f5ac4 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q5.plan.yaml @@ -0,0 +1,62 @@ +# dc → APPROX_COUNT_DISTINCT with HLL sketch merge across shards. +# Implicit IS NOT NULL filter blocked from lucene (production blocklist); parquet predicate pushdown. +query: q5 +ppl_file: q5.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchAggregate(group=[{}], dc(UserID)=[APPROX_COUNT_DISTINCT($0)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchAggregate(group=[{}], dc(UserID)=[APPROX_COUNT_DISTINCT($0)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(UserID=[$97], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], IS NOT NULL($97))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchAggregate(group=[{}], dc(UserID)=[APPROX_COUNT_DISTINCT($0)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(UserID=[$97], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], IS NOT NULL($97))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchAggregate(group=[{}], dc(UserID)=[APPROX_COUNT_DISTINCT($0)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + AggregateExec: mode=Partial, gby=[], aggr=[approx_distinct(.UserID) as dc(UserID)] + FilterExec: UserID@0 IS NOT NULL + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[UserID], file_type=parquet, predicate=UserID@89 IS NOT NULL, pruning_predicate=UserID_null_count@1 != row_count@0, required_guarantees=[] + shard_physical_nseg: | + AggregateExec: mode=Partial, gby=[], aggr=[approx_distinct(.UserID) as dc(UserID)] + FilterExec: UserID@0 IS NOT NULL + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[UserID], file_type=parquet, predicate=UserID@89 IS NOT NULL, pruning_predicate=UserID_null_count@1 != row_count@0, required_guarantees=[] + prod1s: + post_cbo: | + OpenSearchAggregate(group=[{}], dc(UserID)=[APPROX_COUNT_DISTINCT($0)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(UserID=[$97], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], IS NOT NULL($97))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchAggregate(group=[{}], dc(UserID)=[APPROX_COUNT_DISTINCT($0)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(UserID=[$97], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], IS NOT NULL($97))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + RelabelExec: schema=Schema { fields: [Field { name: "dc(UserID)", data_type: Int64, nullable: true }], metadata: {} } + AggregateExec: mode=Final, gby=[], aggr=[approx_distinct(.UserID) as dc(UserID)] + CoalescePartitionsExec + AggregateExec: mode=Partial, gby=[], aggr=[approx_distinct(.UserID) as dc(UserID)] + FilterExec: UserID@0 IS NOT NULL + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[UserID], file_type=parquet, predicate=UserID@89 IS NOT NULL, pruning_predicate=UserID_null_count@1 != row_count@0, required_guarantees=[] + shard_physical_nseg: | + RelabelExec: schema=Schema { fields: [Field { name: "dc(UserID)", data_type: Int64, nullable: true }], metadata: {} } + AggregateExec: mode=Final, gby=[], aggr=[approx_distinct(.UserID) as dc(UserID)] + CoalescePartitionsExec + AggregateExec: mode=Partial, gby=[], aggr=[approx_distinct(.UserID) as dc(UserID)] + FilterExec: UserID@0 IS NOT NULL + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[UserID], file_type=parquet, predicate=UserID@89 IS NOT NULL, pruning_predicate=UserID_null_count@1 != row_count@0, required_guarantees=[] diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q6.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q6.plan.yaml new file mode 100644 index 0000000000000..b6478c9a4bd87 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q6.plan.yaml @@ -0,0 +1,62 @@ +# dc(SearchPhrase): implicit IS NOT NULL blocked from lucene (production blocklist). +# Parquet DataSourceExec with pruning_predicate for null-count stats. +query: q6 +ppl_file: q6.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchAggregate(group=[{}], dc(SearchPhrase)=[APPROX_COUNT_DISTINCT($0)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchAggregate(group=[{}], dc(SearchPhrase)=[APPROX_COUNT_DISTINCT($0)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], IS NOT NULL($74))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchAggregate(group=[{}], dc(SearchPhrase)=[APPROX_COUNT_DISTINCT($0)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], IS NOT NULL($74))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchAggregate(group=[{}], dc(SearchPhrase)=[APPROX_COUNT_DISTINCT($0)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + AggregateExec: mode=Partial, gby=[], aggr=[approx_distinct(.SearchPhrase) as dc(SearchPhrase)] + FilterExec: SearchPhrase@0 IS NOT NULL + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[SearchPhrase], file_type=parquet, predicate=SearchPhrase@63 IS NOT NULL, pruning_predicate=SearchPhrase_null_count@1 != row_count@0, required_guarantees=[] + shard_physical_nseg: | + AggregateExec: mode=Partial, gby=[], aggr=[approx_distinct(.SearchPhrase) as dc(SearchPhrase)] + FilterExec: SearchPhrase@0 IS NOT NULL + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[SearchPhrase], file_type=parquet, predicate=SearchPhrase@63 IS NOT NULL, pruning_predicate=SearchPhrase_null_count@1 != row_count@0, required_guarantees=[] + prod1s: + post_cbo: | + OpenSearchAggregate(group=[{}], dc(SearchPhrase)=[APPROX_COUNT_DISTINCT($0)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], IS NOT NULL($74))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchAggregate(group=[{}], dc(SearchPhrase)=[APPROX_COUNT_DISTINCT($0)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(SearchPhrase=[$74], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], IS NOT NULL($74))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + RelabelExec: schema=Schema { fields: [Field { name: "dc(SearchPhrase)", data_type: Int64, nullable: true }], metadata: {} } + AggregateExec: mode=Final, gby=[], aggr=[approx_distinct(.SearchPhrase) as dc(SearchPhrase)] + CoalescePartitionsExec + AggregateExec: mode=Partial, gby=[], aggr=[approx_distinct(.SearchPhrase) as dc(SearchPhrase)] + FilterExec: SearchPhrase@0 IS NOT NULL + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[SearchPhrase], file_type=parquet, predicate=SearchPhrase@63 IS NOT NULL, pruning_predicate=SearchPhrase_null_count@1 != row_count@0, required_guarantees=[] + shard_physical_nseg: | + RelabelExec: schema=Schema { fields: [Field { name: "dc(SearchPhrase)", data_type: Int64, nullable: true }], metadata: {} } + AggregateExec: mode=Final, gby=[], aggr=[approx_distinct(.SearchPhrase) as dc(SearchPhrase)] + CoalescePartitionsExec + AggregateExec: mode=Partial, gby=[], aggr=[approx_distinct(.SearchPhrase) as dc(SearchPhrase)] + FilterExec: SearchPhrase@0 IS NOT NULL + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[SearchPhrase], file_type=parquet, predicate=SearchPhrase@63 IS NOT NULL, pruning_predicate=SearchPhrase_null_count@1 != row_count@0, required_guarantees=[] diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q7.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q7.plan.yaml new file mode 100644 index 0000000000000..ecb33d195ac3d --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q7.plan.yaml @@ -0,0 +1,44 @@ +# min/max constant-folded to PlaceholderRowExec (resolved from parquet metadata at plan time). +# Scrubbed numeric literals: actual values are data-dependent, structure is what matters. +query: q7 +ppl_file: q7.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchAggregate(group=[{}], min(EventDate)=[MIN($0)], max(EventDate)=[MAX($1)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchAggregate(group=[{}], min(EventDate)=[MIN($0)], max(EventDate)=[MAX($0)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(EventDate=[$15], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchAggregate(group=[{}], min(EventDate)=[MIN($0)], max(EventDate)=[MAX($0)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(EventDate=[$15], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchAggregate(group=[{}], min(EventDate)=[MIN($0)], max(EventDate)=[MAX($1)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical: | + ProjectionExec: expr=[ as min(EventDate), as max(EventDate)] + PlaceholderRowExec + coord_physical: | + AggregateExec: mode=Final, gby=[], aggr=[min(input-0.min(EventDate)) as min(EventDate), max(input-0.max(EventDate)) as max(EventDate)] + CoalescePartitionsExec + AggregateExec: mode=Partial, gby=[], aggr=[min(input-0.min(EventDate)) as min(EventDate), max(input-0.max(EventDate)) as max(EventDate)] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + StreamingTableExec: partition_sizes=1, projection=[min(EventDate), max(EventDate)] + prod1s: + post_cbo: | + OpenSearchAggregate(group=[{}], min(EventDate)=[MIN($0)], max(EventDate)=[MAX($0)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(EventDate=[$15], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchAggregate(group=[{}], min(EventDate)=[MIN($0)], max(EventDate)=[MAX($0)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(EventDate=[$15], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical: | + ProjectionExec: expr=[ as min(EventDate), as max(EventDate)] + PlaceholderRowExec diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q8.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q8.plan.yaml new file mode 100644 index 0000000000000..b411ccfe5f8c3 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q8.plan.yaml @@ -0,0 +1,102 @@ +# TopK with 3x fetch multiplier: shard fetch=30000 for coordinator limit=10000. +# != filter blocked from lucene; full parquet predicate stack coexists with shard-side TopK sort. +query: q8 +ppl_file: q8.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], viableBackends=[[datafusion]]) + OpenSearchProject(count()=[$1], AdvEngineID=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], count()=[SUM($1)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchSort(sort0=[$1], sort1=[$0], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[30000], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], count()=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(AdvEngineID=[$0], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($0, 0))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$1], sort1=[$0], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[30000], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], count()=[COUNT()], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(AdvEngineID=[$0], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($0, 0))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], viableBackends=[[datafusion]]) + OpenSearchProject(count()=[$1], AdvEngineID=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], count()=[SUM($1)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[AdvEngineID@0 as AdvEngineID, count(Int64(1))[count]@1 as count()] + SortPreservingMergeExec: [count(Int64(1))@1 DESC NULLS LAST, AdvEngineID@0 ASC], fetch=30000 + SortExec: TopK(fetch=30000), expr=[count(Int64(1))@1 DESC NULLS LAST, AdvEngineID@0 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[AdvEngineID@0 as AdvEngineID], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([AdvEngineID@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[AdvEngineID@0 as AdvEngineID], aggr=[count(Int64(1))] + FilterExec: AdvEngineID@0 != 0 + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[AdvEngineID], file_type=parquet, predicate=AdvEngineID@20 != 0, pruning_predicate=AdvEngineID_null_count@2 != row_count@3 AND (AdvEngineID_min@0 != 0 OR 0 != AdvEngineID_max@1), required_guarantees=[AdvEngineID not in (0)] + shard_physical_nseg: | + ProjectionExec: expr=[AdvEngineID@0 as AdvEngineID, count(Int64(1))[count]@1 as count()] + SortPreservingMergeExec: [count(Int64(1))@1 DESC NULLS LAST, AdvEngineID@0 ASC], fetch=30000 + SortExec: TopK(fetch=30000), expr=[count(Int64(1))@1 DESC NULLS LAST, AdvEngineID@0 ASC], preserve_partitioning=[true] + AggregateExec: mode=PartialReduce, gby=[AdvEngineID@0 as AdvEngineID], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([AdvEngineID@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[AdvEngineID@0 as AdvEngineID], aggr=[count(Int64(1))] + FilterExec: AdvEngineID@0 != 0 + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[AdvEngineID], file_type=parquet, predicate=AdvEngineID@20 != 0, pruning_predicate=AdvEngineID_null_count@2 != row_count@3 AND (AdvEngineID_min@0 != 0 OR 0 != AdvEngineID_max@1), required_guarantees=[AdvEngineID not in (0)] + coord_physical: | + ProjectionExec: expr=[sum(input-0.count())@0 as count(), AdvEngineID@1 as AdvEngineID] + SortPreservingMergeExec: [sum(input-0.count())@0 DESC NULLS LAST, AdvEngineID@1 ASC], fetch=10000 + SortExec: TopK(fetch=10000), expr=[sum(input-0.count())@0 DESC NULLS LAST, AdvEngineID@1 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[sum(input-0.count())@1 as sum(input-0.count()), AdvEngineID@0 as AdvEngineID] + AggregateExec: mode=FinalPartitioned, gby=[AdvEngineID@0 as AdvEngineID], aggr=[sum(input-0.count())] + RepartitionExec: partitioning=Hash([AdvEngineID@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[AdvEngineID@0 as AdvEngineID], aggr=[sum(input-0.count())] + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + StreamingTableExec: partition_sizes=1, projection=[AdvEngineID, count()] + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], viableBackends=[[datafusion]]) + OpenSearchProject(count()=[$1], AdvEngineID=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], count()=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(AdvEngineID=[$0], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($0, 0))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], viableBackends=[[datafusion]]) + OpenSearchProject(count()=[$1], AdvEngineID=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], count()=[COUNT()], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(AdvEngineID=[$0], viableBackends=[[datafusion]]) + OpenSearchFilter(condition=[ANNOTATED_PREDICATE(id=0, backends=[datafusion], <>($0, 0))], viableBackends=[[datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[count(Int64(1))@0 as count(), AdvEngineID@1 as AdvEngineID] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST, AdvEngineID@1 ASC], fetch=10000 + SortExec: TopK(fetch=10000), expr=[count(Int64(1))@0 DESC NULLS LAST, AdvEngineID@1 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@1 as count(Int64(1)), AdvEngineID@0 as AdvEngineID] + AggregateExec: mode=FinalPartitioned, gby=[AdvEngineID@0 as AdvEngineID], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([AdvEngineID@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[AdvEngineID@0 as AdvEngineID], aggr=[count(Int64(1))] + FilterExec: AdvEngineID@0 != 0 + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 + DataSourceExec: file_groups={}, projection=[AdvEngineID], file_type=parquet, predicate=AdvEngineID@20 != 0, pruning_predicate=AdvEngineID_null_count@2 != row_count@3 AND (AdvEngineID_min@0 != 0 OR 0 != AdvEngineID_max@1), required_guarantees=[AdvEngineID not in (0)] + shard_physical_nseg: | + ProjectionExec: expr=[count(Int64(1))@0 as count(), AdvEngineID@1 as AdvEngineID] + SortPreservingMergeExec: [count(Int64(1))@0 DESC NULLS LAST, AdvEngineID@1 ASC], fetch=10000 + SortExec: TopK(fetch=10000), expr=[count(Int64(1))@0 DESC NULLS LAST, AdvEngineID@1 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[count(Int64(1))@1 as count(Int64(1)), AdvEngineID@0 as AdvEngineID] + AggregateExec: mode=FinalPartitioned, gby=[AdvEngineID@0 as AdvEngineID], aggr=[count(Int64(1))] + RepartitionExec: partitioning=Hash([AdvEngineID@0], 4), input_partitions=4 + AggregateExec: mode=Partial, gby=[AdvEngineID@0 as AdvEngineID], aggr=[count(Int64(1))] + FilterExec: AdvEngineID@0 != 0 + RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=2 + DataSourceExec: file_groups={}, projection=[AdvEngineID], file_type=parquet, predicate=AdvEngineID@20 != 0, pruning_predicate=AdvEngineID_null_count@2 != row_count@3 AND (AdvEngineID_min@0 != 0 OR 0 != AdvEngineID_max@1), required_guarantees=[AdvEngineID not in (0)] diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q9.plan.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q9.plan.yaml new file mode 100644 index 0000000000000..7e305e292799a --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/clickbench/q9.plan.yaml @@ -0,0 +1,88 @@ +# reduce_eval('approx_distinct') enables TopK on HLL partial registers before coordinator. +# Shard sorts on estimated cardinality from partial HLL state, fetch=30 (3x of head 10). +query: q9 +ppl_file: q9.ppl +applies: [prod2s, prod1s] +plans: + prod2s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(u=[$1], RegionID=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], u=[APPROX_COUNT_DISTINCT($1)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchProject(RegionID=[$0], u=[$1], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$2], sort1=[$0], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchProject(RegionID=[$0], u=[$1], __reduce_eval_1=[reduce_eval('approx_distinct', $1)], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], u=[APPROX_COUNT_DISTINCT($1)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(RegionID=[$65], UserID=[$97], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchProject(RegionID=[$0], u=[$1], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$2], sort1=[$0], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[30], viableBackends=[[datafusion]]) + OpenSearchProject(RegionID=[$0], u=[$1], __reduce_eval_1=[reduce_eval('approx_distinct', $1)], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], u=[APPROX_COUNT_DISTINCT($1)], mode=[PARTIAL], viableBackends=[[datafusion]]) + OpenSearchProject(RegionID=[$65], UserID=[$97], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + [COORDINATOR_REDUCE chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(u=[$1], RegionID=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], u=[APPROX_COUNT_DISTINCT($1)], mode=[FINAL], viableBackends=[[datafusion]]) + OpenSearchExchangeReducer(viableBackends=[[datafusion]], exchange=[ExchangeInfo[distributionType=SINGLETON, partitionKeyIndices=[]]]) + OpenSearchStageInputScan(childStageId=[0], viableBackends=[[datafusion]]) + shard_physical_1seg: | + ProjectionExec: expr=[RegionID@0 as RegionID, approx_distinct(.UserID)@1 as u] + SortPreservingMergeExec: [reduce_eval(Utf8("approx_distinct"),approx_distinct(.UserID))@2 DESC NULLS LAST, RegionID@0 ASC], fetch=30 + SortExec: TopK(fetch=30), expr=[reduce_eval(Utf8("approx_distinct"),approx_distinct(.UserID))@2 DESC NULLS LAST, RegionID@0 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[RegionID@0 as RegionID, approx_distinct(.UserID)[hll_registers]@1 as approx_distinct(.UserID), reduce_eval(approx_distinct, approx_distinct(.UserID)[hll_registers]@1) as reduce_eval(Utf8("approx_distinct"),approx_distinct(.UserID))] + AggregateExec: mode=PartialReduce, gby=[RegionID@0 as RegionID], aggr=[approx_distinct(.UserID)] + RepartitionExec: partitioning=Hash([RegionID@0], 4), input_partitions=1 + AggregateExec: mode=Partial, gby=[RegionID@0 as RegionID], aggr=[approx_distinct(.UserID)] + DataSourceExec: file_groups={}, projection=[RegionID, UserID], file_type=parquet + shard_physical_nseg: | + ProjectionExec: expr=[RegionID@0 as RegionID, approx_distinct(.UserID)@1 as u] + SortPreservingMergeExec: [reduce_eval(Utf8("approx_distinct"),approx_distinct(.UserID))@2 DESC NULLS LAST, RegionID@0 ASC], fetch=30 + SortExec: TopK(fetch=30), expr=[reduce_eval(Utf8("approx_distinct"),approx_distinct(.UserID))@2 DESC NULLS LAST, RegionID@0 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[RegionID@0 as RegionID, approx_distinct(.UserID)[hll_registers]@1 as approx_distinct(.UserID), reduce_eval(approx_distinct, approx_distinct(.UserID)[hll_registers]@1) as reduce_eval(Utf8("approx_distinct"),approx_distinct(.UserID))] + AggregateExec: mode=PartialReduce, gby=[RegionID@0 as RegionID], aggr=[approx_distinct(.UserID)] + RepartitionExec: partitioning=Hash([RegionID@0], 4), input_partitions=2 + AggregateExec: mode=Partial, gby=[RegionID@0 as RegionID], aggr=[approx_distinct(.UserID)] + DataSourceExec: file_groups={}, projection=[RegionID, UserID], file_type=parquet + prod1s: + post_cbo: | + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(u=[$1], RegionID=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], u=[APPROX_COUNT_DISTINCT($1)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(RegionID=[$65], UserID=[$97], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + fragment: | + [SHARD_FRAGMENT chosen_backend=datafusion tree_shape=NONE] + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], viableBackends=[[datafusion]]) + OpenSearchSort(sort0=[$0], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10], viableBackends=[[datafusion]]) + OpenSearchProject(u=[$1], RegionID=[$0], viableBackends=[[datafusion]]) + OpenSearchAggregate(group=[{0}], u=[APPROX_COUNT_DISTINCT($1)], mode=[SINGLE], viableBackends=[[datafusion]]) + OpenSearchProject(RegionID=[$65], UserID=[$97], viableBackends=[[lucene, datafusion]]) + OpenSearchTableScan(table=[[]], viableBackends=[[lucene, datafusion]]) + shard_physical_1seg: | + RelabelExec: schema=Schema { fields: [Field { name: "u", data_type: Int64, nullable: true }, Field { name: "RegionID", data_type: Int32, nullable: true }], metadata: {} } + ProjectionExec: expr=[approx_distinct(.UserID)@0 as u, RegionID@1 as RegionID] + SortPreservingMergeExec: [approx_distinct(.UserID)@0 DESC NULLS LAST, RegionID@1 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[approx_distinct(.UserID)@0 DESC NULLS LAST, RegionID@1 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[approx_distinct(.UserID)@1 as approx_distinct(.UserID), RegionID@0 as RegionID] + AggregateExec: mode=FinalPartitioned, gby=[RegionID@0 as RegionID], aggr=[approx_distinct(.UserID)] + RepartitionExec: partitioning=Hash([RegionID@0], 4), input_partitions=1 + AggregateExec: mode=Partial, gby=[RegionID@0 as RegionID], aggr=[approx_distinct(.UserID)] + DataSourceExec: file_groups={}, projection=[RegionID, UserID], file_type=parquet + shard_physical_nseg: | + RelabelExec: schema=Schema { fields: [Field { name: "u", data_type: Int64, nullable: true }, Field { name: "RegionID", data_type: Int32, nullable: true }], metadata: {} } + ProjectionExec: expr=[approx_distinct(.UserID)@0 as u, RegionID@1 as RegionID] + SortPreservingMergeExec: [approx_distinct(.UserID)@0 DESC NULLS LAST, RegionID@1 ASC], fetch=10 + SortExec: TopK(fetch=10), expr=[approx_distinct(.UserID)@0 DESC NULLS LAST, RegionID@1 ASC], preserve_partitioning=[true] + ProjectionExec: expr=[approx_distinct(.UserID)@1 as approx_distinct(.UserID), RegionID@0 as RegionID] + AggregateExec: mode=FinalPartitioned, gby=[RegionID@0 as RegionID], aggr=[approx_distinct(.UserID)] + RepartitionExec: partitioning=Hash([RegionID@0], 4), input_partitions=2 + AggregateExec: mode=Partial, gby=[RegionID@0 as RegionID], aggr=[approx_distinct(.UserID)] + DataSourceExec: file_groups={}, projection=[RegionID, UserID], file_type=parquet diff --git a/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/combos.yaml b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/combos.yaml new file mode 100644 index 0000000000000..4aa244c76c551 --- /dev/null +++ b/sandbox/qa/analytics-engine-rest/src/test/resources/planshape/combos.yaml @@ -0,0 +1,24 @@ +# Global named settings-combos for plan-shape golden ITs. +# A combo IS its settings — no knob-token indirection. Goldens reference combos by name only. +# cluster: applied via PUT /_cluster/settings. index: applied at the index PUT (provision time). +# index.number_of_shards is REQUIRED (the universal root knob: split vs no-split). +# +# `defaults` lists the combos CI runs unless overridden with -Dplan.combos=... + +combos: + prod2s: # production default, multi-shard + index: + number_of_shards: 2 + cluster: + analytics.shard_bucket_oversampling_factor: 2.0 + search.concurrent.max_slice_count: 4 + datafusion.reduce.target_partitions: 4 + prod1s: # production default, single-shard + index: + number_of_shards: 1 + cluster: + analytics.shard_bucket_oversampling_factor: 2.0 + search.concurrent.max_slice_count: 4 + datafusion.reduce.target_partitions: 4 + +defaults: [prod2s, prod1s] diff --git a/server/licenses/lucene-analysis-common-10.4.0.jar.sha1 b/server/licenses/lucene-analysis-common-10.4.0.jar.sha1 deleted file mode 100644 index edc73cad56dd6..0000000000000 --- a/server/licenses/lucene-analysis-common-10.4.0.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -61c4a7b27753662619e84261b42f404fda85886f \ No newline at end of file diff --git a/server/licenses/lucene-analysis-common-10.5.0.jar.sha1 b/server/licenses/lucene-analysis-common-10.5.0.jar.sha1 new file mode 100644 index 0000000000000..70332e4419fcf --- /dev/null +++ b/server/licenses/lucene-analysis-common-10.5.0.jar.sha1 @@ -0,0 +1 @@ +2b314f57f18f3d9ad855f611f6f391519ac78a8d \ No newline at end of file diff --git a/server/licenses/lucene-backward-codecs-10.4.0.jar.sha1 b/server/licenses/lucene-backward-codecs-10.4.0.jar.sha1 deleted file mode 100644 index beac03c7ea7f5..0000000000000 --- a/server/licenses/lucene-backward-codecs-10.4.0.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -9cb01ee8ba10cd935133a82bd72d16faf4f8dffe \ No newline at end of file diff --git a/server/licenses/lucene-backward-codecs-10.5.0.jar.sha1 b/server/licenses/lucene-backward-codecs-10.5.0.jar.sha1 new file mode 100644 index 0000000000000..a35ed264d2f0b --- /dev/null +++ b/server/licenses/lucene-backward-codecs-10.5.0.jar.sha1 @@ -0,0 +1 @@ +0967b73127de6f17bbd83c015d258872359e8f97 \ No newline at end of file diff --git a/server/licenses/lucene-core-10.4.0.jar.sha1 b/server/licenses/lucene-core-10.4.0.jar.sha1 deleted file mode 100644 index 89d6695f37459..0000000000000 --- a/server/licenses/lucene-core-10.4.0.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -7493bc763cd5e91f2a8f7722c2f90d8ce15c6319 \ No newline at end of file diff --git a/server/licenses/lucene-core-10.5.0.jar.sha1 b/server/licenses/lucene-core-10.5.0.jar.sha1 new file mode 100644 index 0000000000000..c283fe8a56b45 --- /dev/null +++ b/server/licenses/lucene-core-10.5.0.jar.sha1 @@ -0,0 +1 @@ +383deeee9b44ef15daaf7541d57ea5183d52a7b5 \ No newline at end of file diff --git a/server/licenses/lucene-grouping-10.4.0.jar.sha1 b/server/licenses/lucene-grouping-10.4.0.jar.sha1 deleted file mode 100644 index 307cb2f07efa2..0000000000000 --- a/server/licenses/lucene-grouping-10.4.0.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -8d143b267e6575a08d5483a8b103384536fdd4c9 \ No newline at end of file diff --git a/server/licenses/lucene-grouping-10.5.0.jar.sha1 b/server/licenses/lucene-grouping-10.5.0.jar.sha1 new file mode 100644 index 0000000000000..fe687855e33a6 --- /dev/null +++ b/server/licenses/lucene-grouping-10.5.0.jar.sha1 @@ -0,0 +1 @@ +ed85d8acb132c4aa175c92778132f8e54a00ce0e \ No newline at end of file diff --git a/server/licenses/lucene-highlighter-10.4.0.jar.sha1 b/server/licenses/lucene-highlighter-10.4.0.jar.sha1 deleted file mode 100644 index 4f3d2135d7a91..0000000000000 --- a/server/licenses/lucene-highlighter-10.4.0.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -a3cd37e7588506447382a9175da271fb37c55e67 \ No newline at end of file diff --git a/server/licenses/lucene-highlighter-10.5.0.jar.sha1 b/server/licenses/lucene-highlighter-10.5.0.jar.sha1 new file mode 100644 index 0000000000000..fb76f497aadcb --- /dev/null +++ b/server/licenses/lucene-highlighter-10.5.0.jar.sha1 @@ -0,0 +1 @@ +fccffbe10fa6fd84bc884df844eb850ffcc8d51b \ No newline at end of file diff --git a/server/licenses/lucene-join-10.4.0.jar.sha1 b/server/licenses/lucene-join-10.4.0.jar.sha1 deleted file mode 100644 index 87e6d8db260b2..0000000000000 --- a/server/licenses/lucene-join-10.4.0.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -56e08fe428fe88e51dd4c89ca62c6a72bf2f8f57 \ No newline at end of file diff --git a/server/licenses/lucene-join-10.5.0.jar.sha1 b/server/licenses/lucene-join-10.5.0.jar.sha1 new file mode 100644 index 0000000000000..b1053a47da7b2 --- /dev/null +++ b/server/licenses/lucene-join-10.5.0.jar.sha1 @@ -0,0 +1 @@ +b34a23804c461495fb98ef597d72cfc134466b3b \ No newline at end of file diff --git a/server/licenses/lucene-memory-10.4.0.jar.sha1 b/server/licenses/lucene-memory-10.4.0.jar.sha1 deleted file mode 100644 index d6f6d189292b6..0000000000000 --- a/server/licenses/lucene-memory-10.4.0.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -6c6f1080466c5dff5b1d0eeeb3ead745591eb9d1 \ No newline at end of file diff --git a/server/licenses/lucene-memory-10.5.0.jar.sha1 b/server/licenses/lucene-memory-10.5.0.jar.sha1 new file mode 100644 index 0000000000000..4e2d636232a3b --- /dev/null +++ b/server/licenses/lucene-memory-10.5.0.jar.sha1 @@ -0,0 +1 @@ +a18c7a57e054836d2dc7ece7dfe28a9cad61f660 \ No newline at end of file diff --git a/server/licenses/lucene-misc-10.4.0.jar.sha1 b/server/licenses/lucene-misc-10.4.0.jar.sha1 deleted file mode 100644 index 6783f2b1d38c8..0000000000000 --- a/server/licenses/lucene-misc-10.4.0.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -e86d65b1a8b5bf8dc66f4e002fe75ddc886da62c \ No newline at end of file diff --git a/server/licenses/lucene-misc-10.5.0.jar.sha1 b/server/licenses/lucene-misc-10.5.0.jar.sha1 new file mode 100644 index 0000000000000..5e81bcd59d7c6 --- /dev/null +++ b/server/licenses/lucene-misc-10.5.0.jar.sha1 @@ -0,0 +1 @@ +113d957ae3c80c8c52052f4d38badc9ae5c1b60d \ No newline at end of file diff --git a/server/licenses/lucene-queries-10.4.0.jar.sha1 b/server/licenses/lucene-queries-10.4.0.jar.sha1 deleted file mode 100644 index 8619fd3bce7c3..0000000000000 --- a/server/licenses/lucene-queries-10.4.0.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -43b2654a448504dc4ac382f6d9b6896e2e8b67b7 \ No newline at end of file diff --git a/server/licenses/lucene-queries-10.5.0.jar.sha1 b/server/licenses/lucene-queries-10.5.0.jar.sha1 new file mode 100644 index 0000000000000..af7896ff0f36e --- /dev/null +++ b/server/licenses/lucene-queries-10.5.0.jar.sha1 @@ -0,0 +1 @@ +c96555ec32a411d860527ea13add388d72a614b2 \ No newline at end of file diff --git a/server/licenses/lucene-queryparser-10.4.0.jar.sha1 b/server/licenses/lucene-queryparser-10.4.0.jar.sha1 deleted file mode 100644 index d2dc853dd6bfd..0000000000000 --- a/server/licenses/lucene-queryparser-10.4.0.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -6e3e758b60b1e2549a85139d9498b54565c38eeb \ No newline at end of file diff --git a/server/licenses/lucene-queryparser-10.5.0.jar.sha1 b/server/licenses/lucene-queryparser-10.5.0.jar.sha1 new file mode 100644 index 0000000000000..888ca9ad534a8 --- /dev/null +++ b/server/licenses/lucene-queryparser-10.5.0.jar.sha1 @@ -0,0 +1 @@ +dea0c58101825f086127abf5adce13ced0bd0fc2 \ No newline at end of file diff --git a/server/licenses/lucene-sandbox-10.4.0.jar.sha1 b/server/licenses/lucene-sandbox-10.4.0.jar.sha1 deleted file mode 100644 index 1dfab5a550136..0000000000000 --- a/server/licenses/lucene-sandbox-10.4.0.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -15976efaae9213b1a61e3509da6f366e243b90c7 \ No newline at end of file diff --git a/server/licenses/lucene-sandbox-10.5.0.jar.sha1 b/server/licenses/lucene-sandbox-10.5.0.jar.sha1 new file mode 100644 index 0000000000000..7dd1ebd09d49a --- /dev/null +++ b/server/licenses/lucene-sandbox-10.5.0.jar.sha1 @@ -0,0 +1 @@ +928527693dc814ba0125659b07316e260713e38f \ No newline at end of file diff --git a/server/licenses/lucene-spatial-extras-10.4.0.jar.sha1 b/server/licenses/lucene-spatial-extras-10.4.0.jar.sha1 deleted file mode 100644 index 2bde1e35e7a6b..0000000000000 --- a/server/licenses/lucene-spatial-extras-10.4.0.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -1f56784003e51d7b2df7c27a99d7b127e67e97bf \ No newline at end of file diff --git a/server/licenses/lucene-spatial-extras-10.5.0.jar.sha1 b/server/licenses/lucene-spatial-extras-10.5.0.jar.sha1 new file mode 100644 index 0000000000000..0ac47da0b21b2 --- /dev/null +++ b/server/licenses/lucene-spatial-extras-10.5.0.jar.sha1 @@ -0,0 +1 @@ +900c6d78202cfd96a8e513f2271c972c4af98388 \ No newline at end of file diff --git a/server/licenses/lucene-spatial3d-10.4.0.jar.sha1 b/server/licenses/lucene-spatial3d-10.4.0.jar.sha1 deleted file mode 100644 index 188a9793bc655..0000000000000 --- a/server/licenses/lucene-spatial3d-10.4.0.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -fa337dd41be0f0ee99bc4fd463d517adf25e06a3 \ No newline at end of file diff --git a/server/licenses/lucene-spatial3d-10.5.0.jar.sha1 b/server/licenses/lucene-spatial3d-10.5.0.jar.sha1 new file mode 100644 index 0000000000000..a922121cfb346 --- /dev/null +++ b/server/licenses/lucene-spatial3d-10.5.0.jar.sha1 @@ -0,0 +1 @@ +e2a6601ca2d16796a068589128787114d15935c3 \ No newline at end of file diff --git a/server/licenses/lucene-suggest-10.4.0.jar.sha1 b/server/licenses/lucene-suggest-10.4.0.jar.sha1 deleted file mode 100644 index dcfdfac03d13b..0000000000000 --- a/server/licenses/lucene-suggest-10.4.0.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -d86d535c415cf9c79016aaefa9957dab98766a68 \ No newline at end of file diff --git a/server/licenses/lucene-suggest-10.5.0.jar.sha1 b/server/licenses/lucene-suggest-10.5.0.jar.sha1 new file mode 100644 index 0000000000000..97dccca431dd0 --- /dev/null +++ b/server/licenses/lucene-suggest-10.5.0.jar.sha1 @@ -0,0 +1 @@ +62ee5896308143da8ee6b8c0f6a1a2f724c1ffb4 \ No newline at end of file diff --git a/server/src/internalClusterTest/java/org/opensearch/action/admin/indices/create/CreateIndexDefaultReplicaCountWithNodeSettingsIT.java b/server/src/internalClusterTest/java/org/opensearch/action/admin/indices/create/CreateIndexDefaultReplicaCountWithNodeSettingsIT.java new file mode 100644 index 0000000000000..9260b8cd23972 --- /dev/null +++ b/server/src/internalClusterTest/java/org/opensearch/action/admin/indices/create/CreateIndexDefaultReplicaCountWithNodeSettingsIT.java @@ -0,0 +1,52 @@ +/* + * 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.action.admin.indices.create; + +import org.opensearch.cluster.metadata.IndexMetadata; +import org.opensearch.cluster.metadata.Metadata; +import org.opensearch.common.settings.Settings; +import org.opensearch.test.OpenSearchIntegTestCase; +import org.opensearch.test.OpenSearchIntegTestCase.ClusterScope; +import org.opensearch.test.OpenSearchIntegTestCase.Scope; + +import static org.opensearch.test.hamcrest.OpenSearchAssertions.assertAcked; +import static org.hamcrest.Matchers.equalTo; + +@ClusterScope(scope = Scope.TEST) +public class CreateIndexDefaultReplicaCountWithNodeSettingsIT extends OpenSearchIntegTestCase { + + private static final String INDEX_NAME = "test-index"; + private static final int DEFAULT_NUMBER_OF_REPLICAS = 0; + + @Override + protected Settings nodeSettings(int nodeOrdinal) { + return Settings.builder() + .put(super.nodeSettings(nodeOrdinal)) + .put(Metadata.DEFAULT_REPLICA_COUNT_SETTING.getKey(), DEFAULT_NUMBER_OF_REPLICAS) + .build(); + } + + @Override + protected void randomIndexTemplate() { + // The base template sets index.number_of_replicas and would override the cluster-level default verified here. + } + + public void testCreateIndexUsesDefaultNumberOfReplicasFromNodeSettings() { + assertAcked( + client().admin() + .indices() + .prepareCreate(INDEX_NAME) + .setSettings(Settings.builder().put(IndexMetadata.INDEX_NUMBER_OF_SHARDS_SETTING.getKey(), 1)) + .get() + ); + + Settings indexSettings = client().admin().indices().prepareGetSettings(INDEX_NAME).get().getIndexToSettings().get(INDEX_NAME); + assertThat(indexSettings.getAsInt(IndexMetadata.SETTING_NUMBER_OF_REPLICAS, null), equalTo(DEFAULT_NUMBER_OF_REPLICAS)); + } +} diff --git a/server/src/internalClusterTest/java/org/opensearch/action/search/SearchIndexPruningIT.java b/server/src/internalClusterTest/java/org/opensearch/action/search/SearchIndexPruningIT.java new file mode 100644 index 0000000000000..1e7c461df3eaf --- /dev/null +++ b/server/src/internalClusterTest/java/org/opensearch/action/search/SearchIndexPruningIT.java @@ -0,0 +1,318 @@ +/* + * 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.action.search; + +import org.opensearch.action.search.pruning.SearchIndexPruningSettings; +import org.opensearch.cluster.ClusterState; +import org.opensearch.cluster.ClusterStateUpdateTask; +import org.opensearch.cluster.metadata.IndexMetadata; +import org.opensearch.cluster.metadata.Metadata; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.Priority; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.unit.TimeValue; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.core.common.io.stream.NamedWriteableRegistry; +import org.opensearch.index.IndexModule; +import org.opensearch.index.fielddomain.DateRangeFieldDomain; +import org.opensearch.index.fielddomain.IndexFieldDomainMetadata; +import org.opensearch.index.shard.SearchOperationListener; +import org.opensearch.plugins.NetworkPlugin; +import org.opensearch.plugins.Plugin; +import org.opensearch.search.builder.PointInTimeBuilder; +import org.opensearch.search.internal.SearchContext; +import org.opensearch.search.internal.ShardSearchRequest; +import org.opensearch.search.sort.SortOrder; +import org.opensearch.tasks.Task; +import org.opensearch.test.OpenSearchIntegTestCase; +import org.opensearch.test.OpenSearchIntegTestCase.ClusterScope; +import org.opensearch.test.OpenSearchIntegTestCase.Scope; +import org.opensearch.transport.TransportChannel; +import org.opensearch.transport.TransportInterceptor; +import org.opensearch.transport.TransportRequest; +import org.opensearch.transport.TransportRequestHandler; + +import java.util.Collection; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.opensearch.index.query.QueryBuilders.rangeQuery; +import static org.opensearch.test.hamcrest.OpenSearchAssertions.assertAcked; +import static org.opensearch.test.hamcrest.OpenSearchAssertions.assertHitCount; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; + +@ClusterScope(scope = Scope.TEST, numDataNodes = 1, numClientNodes = 0, supportsDedicatedMasters = false) +public class SearchIndexPruningIT extends OpenSearchIntegTestCase { + public static class RecordOldIndexSearchPlugin extends Plugin implements NetworkPlugin { + private static final AtomicBoolean RECORD_SEARCH_EVENTS = new AtomicBoolean(); + private static final AtomicInteger OLD_INDEX_QUERY_PHASES = new AtomicInteger(); + private static final AtomicInteger OVERLAPPING_INDEX_QUERY_PHASES = new AtomicInteger(); + private static final AtomicInteger CAN_MATCH_REQUESTS = new AtomicInteger(); + private static final AtomicInteger OLD_INDEX_CAN_MATCH_REQUESTS = new AtomicInteger(); + + @Override + public void onIndexModule(IndexModule indexModule) { + indexModule.addSearchOperationListener(new SearchOperationListener() { + @Override + public void onPreQueryPhase(SearchContext searchContext) { + if (RECORD_SEARCH_EVENTS.get() == false) { + return; + } + String indexName = searchContext.indexShard().shardId().getIndex().getName(); + if ("logs-000001".equals(indexName)) { + OLD_INDEX_QUERY_PHASES.incrementAndGet(); + } else if ("logs-000002".equals(indexName) || "logs-000003".equals(indexName)) { + OVERLAPPING_INDEX_QUERY_PHASES.incrementAndGet(); + } + } + }); + } + + @Override + public List getTransportInterceptors( + NamedWriteableRegistry namedWriteableRegistry, + ThreadContext threadContext + ) { + return List.of(new TransportInterceptor() { + @Override + public TransportRequestHandler interceptHandler( + String action, + String executor, + boolean forceExecution, + TransportRequestHandler actualHandler + ) { + if (SearchTransportService.QUERY_CAN_MATCH_NAME.equals(action) == false) { + return actualHandler; + } + return new TransportRequestHandler() { + @Override + public void messageReceived(T request, TransportChannel channel, Task task) throws Exception { + recordCanMatchRequest(request); + actualHandler.messageReceived(request, channel, task); + } + }; + } + }); + } + + private static void recordCanMatchRequest(TransportRequest request) { + if (RECORD_SEARCH_EVENTS.get() && request instanceof ShardSearchRequest) { + ShardSearchRequest shardSearchRequest = (ShardSearchRequest) request; + CAN_MATCH_REQUESTS.incrementAndGet(); + if ("logs-000001".equals(shardSearchRequest.shardId().getIndexName())) { + OLD_INDEX_CAN_MATCH_REQUESTS.incrementAndGet(); + } + } + } + } + + @Override + protected Collection> nodePlugins() { + return List.of(RecordOldIndexSearchPlugin.class); + } + + @Override + public void tearDown() throws Exception { + try { + RecordOldIndexSearchPlugin.RECORD_SEARCH_EVENTS.set(false); + RecordOldIndexSearchPlugin.OLD_INDEX_QUERY_PHASES.set(0); + RecordOldIndexSearchPlugin.OVERLAPPING_INDEX_QUERY_PHASES.set(0); + RecordOldIndexSearchPlugin.CAN_MATCH_REQUESTS.set(0); + RecordOldIndexSearchPlugin.OLD_INDEX_CAN_MATCH_REQUESTS.set(0); + client().admin() + .cluster() + .prepareUpdateSettings() + .setPersistentSettings(Settings.builder().putNull("search.index_pruning.*")) + .get(); + } finally { + super.tearDown(); + } + } + + public void testSearchUsesIndexFieldDomainMetadataToSkipShardGroups() throws Exception { + SearchResponse response = searchWithIndexFieldDomainMetadata(1_000); + + assertHitCount(response, 1L); + assertThat(RecordOldIndexSearchPlugin.OLD_INDEX_QUERY_PHASES.get(), equalTo(0)); + } + + public void testSearchUsesIndexFieldDomainMetadataBeforeCanMatch() throws Exception { + SearchResponse response = searchWithIndexFieldDomainMetadata(1); + + assertHitCount(response, 1L); + assertThat(RecordOldIndexSearchPlugin.OLD_INDEX_CAN_MATCH_REQUESTS.get(), equalTo(0)); + assertThat(RecordOldIndexSearchPlugin.OLD_INDEX_QUERY_PHASES.get(), equalTo(0)); + } + + public void testPointInTimeSearchDoesNotUseIndexFieldDomainMetadata() throws Exception { + enableIndexPruning(); + createLogsIndices(); + + index("logs-000001", "_doc", "1", "@timestamp", "1970-01-01T00:00:03.500Z", "message", "old"); + index("logs-000002", "_doc", "1", "@timestamp", "1970-01-01T00:00:10Z", "message", "current"); + refresh("logs-000001", "logs-000002"); + + CreatePitRequest createPitRequest = new CreatePitRequest(TimeValue.timeValueDays(1), true); + createPitRequest.setIndices(new String[] { "logs-*" }); + CreatePitResponse pitResponse = client().execute(CreatePitAction.INSTANCE, createPitRequest).actionGet(); + + try { + publishFieldDomain("logs-000001", new DateRangeFieldDomain("@timestamp", 1_000L, 2_000L, true, "test")); + publishFieldDomain("logs-000002", new DateRangeFieldDomain("@timestamp", 3_000L, 4_000L, true, "test")); + + resetSearchEventCounters(); + RecordOldIndexSearchPlugin.RECORD_SEARCH_EVENTS.set(true); + SearchResponse response; + try { + response = client().prepareSearch("logs-*") + .setPreFilterShardSize(1_000) + .setPointInTime(new PointInTimeBuilder(pitResponse.getId()).setKeepAlive(TimeValue.timeValueDays(1))) + .setQuery(rangeQuery("@timestamp").gte("1970-01-01T00:00:03Z").lte("1970-01-01T00:00:04Z")) + .get(); + } finally { + RecordOldIndexSearchPlugin.RECORD_SEARCH_EVENTS.set(false); + } + + assertHitCount(response, 1L); + assertThat(RecordOldIndexSearchPlugin.OLD_INDEX_QUERY_PHASES.get(), greaterThan(0)); + } finally { + client().execute(DeletePitAction.INSTANCE, new DeletePitRequest(pitResponse.getId())).actionGet(); + } + } + + public void testSearchKeepsIndicesWithDomainsPartiallyOverlappingQueryRange() throws Exception { + enableIndexPruning(); + createLogsIndices(); + + index("logs-000001", "_doc", "1", "@timestamp", "1970-01-01T00:00:01Z", "message", "old"); + index("logs-000002", "_doc", "1", "@timestamp", "1970-01-01T00:00:03.500Z", "message", "partial-left"); + index("logs-000003", "_doc", "1", "@timestamp", "1970-01-01T00:00:05Z", "message", "partial-right"); + refresh("logs-000001", "logs-000002", "logs-000003"); + + publishFieldDomain("logs-000001", new DateRangeFieldDomain("@timestamp", 1_000L, 2_000L, true, "test")); + publishFieldDomain("logs-000002", new DateRangeFieldDomain("@timestamp", 2_000L, 4_000L, true, "test")); + publishFieldDomain("logs-000003", new DateRangeFieldDomain("@timestamp", 4_000L, 6_000L, true, "test")); + + resetSearchEventCounters(); + RecordOldIndexSearchPlugin.RECORD_SEARCH_EVENTS.set(true); + SearchResponse response; + try { + response = client().prepareSearch("logs-*") + .setPreFilterShardSize(1) + .setQuery(rangeQuery("@timestamp").gte("1970-01-01T00:00:03Z").lte("1970-01-01T00:00:06Z")) + .addSort("@timestamp", SortOrder.ASC) + .get(); + } finally { + RecordOldIndexSearchPlugin.RECORD_SEARCH_EVENTS.set(false); + } + + assertHitCount(response, 2L); + assertThat(RecordOldIndexSearchPlugin.OLD_INDEX_QUERY_PHASES.get(), equalTo(0)); + assertThat(RecordOldIndexSearchPlugin.OVERLAPPING_INDEX_QUERY_PHASES.get(), greaterThan(0)); + } + + private SearchResponse searchWithIndexFieldDomainMetadata(int preFilterShardSize) throws Exception { + enableIndexPruning(); + createLogsIndices(); + + index("logs-000001", "_doc", "1", "@timestamp", "1970-01-01T00:00:01Z", "message", "old"); + index("logs-000002", "_doc", "1", "@timestamp", "1970-01-01T00:00:03.500Z", "message", "current"); + refresh("logs-000001", "logs-000002"); + + publishFieldDomain("logs-000001", new DateRangeFieldDomain("@timestamp", 1_000L, 2_000L, true, "test")); + publishFieldDomain("logs-000002", new DateRangeFieldDomain("@timestamp", 3_000L, 4_000L, true, "test")); + + resetSearchEventCounters(); + RecordOldIndexSearchPlugin.RECORD_SEARCH_EVENTS.set(true); + try { + return client().prepareSearch("logs-*") + .setPreFilterShardSize(preFilterShardSize) + .setQuery(rangeQuery("@timestamp").gte("1970-01-01T00:00:03Z").lte("1970-01-01T00:00:04Z")) + .addSort("@timestamp", SortOrder.ASC) + .get(); + } finally { + RecordOldIndexSearchPlugin.RECORD_SEARCH_EVENTS.set(false); + } + } + + private void enableIndexPruning() { + assertAcked( + client().admin() + .cluster() + .prepareUpdateSettings() + .setPersistentSettings( + Settings.builder() + .put(SearchIndexPruningSettings.ENABLED.getKey(), true) + .put(SearchIndexPruningSettings.MIN_SHARDS.getKey(), 1) + .putList(SearchIndexPruningSettings.FIELDS.getKey(), "@timestamp") + ) + .get() + ); + } + + private void createLogsIndices() { + Settings indexSettings = Settings.builder() + .put(IndexMetadata.SETTING_NUMBER_OF_SHARDS, 1) + .put(IndexMetadata.SETTING_NUMBER_OF_REPLICAS, 0) + .build(); + assertAcked(prepareCreate("logs-000001").setSettings(indexSettings).setMapping("@timestamp", "type=date")); + assertAcked(prepareCreate("logs-000002").setSettings(indexSettings).setMapping("@timestamp", "type=date")); + assertAcked(prepareCreate("logs-000003").setSettings(indexSettings).setMapping("@timestamp", "type=date")); + ensureGreen("logs-000001", "logs-000002", "logs-000003"); + } + + private void resetSearchEventCounters() { + RecordOldIndexSearchPlugin.OLD_INDEX_QUERY_PHASES.set(0); + RecordOldIndexSearchPlugin.OVERLAPPING_INDEX_QUERY_PHASES.set(0); + RecordOldIndexSearchPlugin.CAN_MATCH_REQUESTS.set(0); + RecordOldIndexSearchPlugin.OLD_INDEX_CAN_MATCH_REQUESTS.set(0); + } + + private void publishFieldDomain(String index, DateRangeFieldDomain domain) throws Exception { + ClusterService clusterService = internalCluster().getClusterManagerNodeInstance(ClusterService.class); + CountDownLatch latch = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + + clusterService.submitStateUpdateTask("put-index-field-domain [" + index + "]", new ClusterStateUpdateTask(Priority.URGENT) { + @Override + public ClusterState execute(ClusterState currentState) { + IndexMetadata current = currentState.metadata().index(index); + if (current == null) { + failure.set(new IllegalStateException("index [" + index + "] not found in cluster state")); + return currentState; + } + + IndexMetadata updated = IndexFieldDomainMetadata.getInstance().putFieldDomain(current, domain); + Metadata metadata = Metadata.builder(currentState.metadata()).put(updated, true).build(); + return ClusterState.builder(currentState).metadata(metadata).build(); + } + + @Override + public void clusterStateProcessed(String source, ClusterState oldState, ClusterState newState) { + latch.countDown(); + } + + @Override + public void onFailure(String source, Exception e) { + failure.set(e); + latch.countDown(); + } + }); + + assertTrue(latch.await(30, TimeUnit.SECONDS)); + if (failure.get() != null) { + throw failure.get(); + } + } +} diff --git a/server/src/internalClusterTest/java/org/opensearch/cluster/routing/allocation/decider/ShardsLimitAllocationDeciderRemoteStoreEnabledIT.java b/server/src/internalClusterTest/java/org/opensearch/cluster/routing/allocation/decider/ShardsLimitAllocationDeciderRemoteStoreEnabledIT.java index 83f9f70abee91..cc40f89cebe78 100644 --- a/server/src/internalClusterTest/java/org/opensearch/cluster/routing/allocation/decider/ShardsLimitAllocationDeciderRemoteStoreEnabledIT.java +++ b/server/src/internalClusterTest/java/org/opensearch/cluster/routing/allocation/decider/ShardsLimitAllocationDeciderRemoteStoreEnabledIT.java @@ -12,6 +12,7 @@ import org.opensearch.action.admin.indices.settings.put.UpdateSettingsRequest; import org.opensearch.action.support.clustermanager.AcknowledgedResponse; import org.opensearch.cluster.ClusterState; +import org.opensearch.cluster.metadata.IndexMetadata; import org.opensearch.cluster.routing.IndexShardRoutingTable; import org.opensearch.cluster.routing.ShardRouting; import org.opensearch.common.settings.Settings; @@ -20,6 +21,7 @@ import org.junit.Before; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -174,6 +176,78 @@ public void testUpdatingIndexPrimaryShardLimit() throws Exception { cleanUp("test1"); } + public void testIndexTemplateWithPrimaryShardLimit() throws Exception { + // Put an index template that carries index.routing.allocation.total_primary_shards_per_node. + // On an all-remote-store cluster this must be acknowledged: the template validator should + // recognise the cluster as remote-store-enabled (via node attributes) rather than looking for + // the index-local index.remote_store.enabled flag, which a template can never carry. + AcknowledgedResponse templateResponse = client().admin() + .indices() + .preparePutTemplate("primary-shard-limit-template") + .setPatterns(Collections.singletonList("template-test*")) + .setSettings( + Settings.builder() + .put(remoteStoreIndexSettings(0, 4)) // 4 shards, 0 replicas + .put(INDEX_TOTAL_PRIMARY_SHARDS_PER_NODE_SETTING.getKey(), 1) + ) + .get(); + + assertTrue("Template carrying total_primary_shards_per_node should be acknowledged", templateResponse.isAcknowledged()); + + // Auto-create the index by indexing a document so the template (not a create-request settings + // block) fully drives the index settings — including number_of_shards and the primary shard limit. + client().prepareIndex("template-test1").setId("1").setSource("field", "value").get(); + + // The setting (and the injected remote-store flag) must have flowed from the template onto the + // concrete index — this is the core of the bug: previously the template PUT was rejected with 400. + ClusterState createdState = client().admin().cluster().prepareState().get().getState(); + Settings indexSettings = createdState.metadata().index("template-test1").getSettings(); + assertEquals( + "Index created from template should carry 4 primary shards", + 4, + createdState.metadata().index("template-test1").getNumberOfShards() + ); + assertEquals( + "Index created from template should carry the primary shard limit", + Integer.valueOf(1), + INDEX_TOTAL_PRIMARY_SHARDS_PER_NODE_SETTING.get(indexSettings) + ); + assertTrue( + "Index created from template on a remote-store cluster should be remote-store enabled", + IndexMetadata.INDEX_REMOTE_STORE_ENABLED_SETTING.get(indexSettings) + ); + + // And the limit must actually be enforced at allocation time: with 4 primaries, a limit of 1 + // per node and 3 data nodes, at least one primary stays unassigned and no node holds more than one. + assertBusy(() -> { + ClusterState state = client().admin().cluster().prepareState().get().getState(); + + int assignedShards = 0; + int unassignedShards = 0; + Map nodePrimaryCount = new HashMap<>(); + + for (IndexShardRoutingTable shardRouting : state.routingTable().index("template-test1")) { + for (ShardRouting shard : shardRouting) { + if (shard.assignedToNode()) { + assignedShards++; + nodePrimaryCount.merge(shard.currentNodeId(), 1, Integer::sum); + } else { + unassignedShards++; + } + } + } + + assertEquals("template-test1 should have 3 assigned primaries (one per data node)", 3, assignedShards); + assertEquals("template-test1 should have 1 unassigned primary (blocked by the per-node limit)", 1, unassignedShards); + for (Integer count : nodePrimaryCount.values()) { + assertTrue("No node should have more than 1 primary shard of template-test1", count <= 1); + } + }); + + client().admin().indices().prepareDeleteTemplate("primary-shard-limit-template").get(); + cleanUp("template-test1"); + } + public void testClusterPrimaryShardLimitss() throws Exception { // Update cluster setting to limit primary shards per node updateClusterSetting(CLUSTER_TOTAL_PRIMARY_SHARDS_PER_NODE_SETTING.getKey(), 1); diff --git a/server/src/main/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesConsumerWrapper.java b/server/src/main/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesConsumerWrapper.java index 580f7a1cc576b..76ecf9a337b22 100644 --- a/server/src/main/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesConsumerWrapper.java +++ b/server/src/main/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesConsumerWrapper.java @@ -31,7 +31,9 @@ public Lucene90DocValuesConsumerWrapper( String dataCodec, String dataExtension, String metaCodec, - String metaExtension + String metaExtension, + String skipIndexCodec, + String skipIndexExtension ) throws IOException { lucene90DocValuesConsumer = new Lucene90DocValuesConsumer( state, @@ -39,7 +41,9 @@ public Lucene90DocValuesConsumerWrapper( dataCodec, dataExtension, metaCodec, - metaExtension + metaExtension, + skipIndexCodec, + skipIndexExtension ); } diff --git a/server/src/main/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducerWrapper.java b/server/src/main/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducerWrapper.java index a213852c59094..3a6588b18500c 100644 --- a/server/src/main/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducerWrapper.java +++ b/server/src/main/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducerWrapper.java @@ -30,9 +30,19 @@ public Lucene90DocValuesProducerWrapper( String dataCodec, String dataExtension, String metaCodec, - String metaExtension + String metaExtension, + String skipIndexCodec, + String skipIndexExtension ) throws IOException { - lucene90DocValuesProducer = new Lucene90DocValuesProducer(state, dataCodec, dataExtension, metaCodec, metaExtension); + lucene90DocValuesProducer = new Lucene90DocValuesProducer( + state, + dataCodec, + dataExtension, + metaCodec, + metaExtension, + skipIndexCodec, + skipIndexExtension + ); } public DocValuesProducer getLucene90DocValuesProducer() { diff --git a/server/src/main/java/org/apache/lucene/index/SortedSetDocValuesWriterWrapper.java b/server/src/main/java/org/apache/lucene/index/SortedSetDocValuesWriterWrapper.java index 95aa242535e48..6b02e2456a63a 100644 --- a/server/src/main/java/org/apache/lucene/index/SortedSetDocValuesWriterWrapper.java +++ b/server/src/main/java/org/apache/lucene/index/SortedSetDocValuesWriterWrapper.java @@ -33,7 +33,12 @@ public class SortedSetDocValuesWriterWrapper implements DocValuesWriterWrapper(Arrays.asList(indexNameExpressionResolver.concreteIndexNames(state, indicesOptions, request))), - state - ); + IndexAbstraction indexAbstraction = state.metadata().getIndicesLookup().get(request.getRolloverTarget()); + if (indexAbstraction == null) { + return null; + } + IndexMetadata writeIndex = indexAbstraction.getWriteIndex(); + if (writeIndex == null) { + return null; + } + return ClusterBlocks.indicesWithRemoteSnapshotBlockedException(Collections.singletonList(writeIndex.getIndex().getName()), state); } @Override diff --git a/server/src/main/java/org/opensearch/action/search/CanMatchPreFilterSearchPhase.java b/server/src/main/java/org/opensearch/action/search/CanMatchPreFilterSearchPhase.java index 952d83b9e4539..87cf430e37120 100644 --- a/server/src/main/java/org/opensearch/action/search/CanMatchPreFilterSearchPhase.java +++ b/server/src/main/java/org/opensearch/action/search/CanMatchPreFilterSearchPhase.java @@ -47,6 +47,7 @@ import org.opensearch.telemetry.tracing.Tracer; import org.opensearch.transport.Transport; +import java.util.Arrays; import java.util.Comparator; import java.util.List; import java.util.Map; @@ -95,7 +96,49 @@ final class CanMatchPreFilterSearchPhase extends AbstractSearchAsyncAction nodeIdToConnection, + Map aliasFilter, + Map concreteIndexBoosts, + Map> indexRoutings, + Executor executor, + SearchRequest request, + ActionListener listener, + GroupShardsIterator shardsIts, + int[] activeShardIndexLookup, + TransportSearchAction.SearchTimeProvider timeProvider, + ClusterState clusterState, + SearchTask task, + Function, SearchPhase> phaseFactory, + SearchResponse.Clusters clusters, + SearchRequestContext searchRequestContext, + Tracer tracer + ) { + // Use the active shard count so can_match is effectively unthrottled without over-sizing the concurrency budget. super( SearchPhaseName.CAN_MATCH.getName(), logger, @@ -111,8 +154,8 @@ final class CanMatchPreFilterSearchPhase extends AbstractSearchAsyncAction results, return phaseFactory.apply(getIterator((CanMatchSearchPhaseResults) results, shardsIts)); } + private static int[] buildActiveShardIndexLookup(GroupShardsIterator shardsIts) { + int[] shardIndexes = new int[shardsIts.size()]; + int activeShardCount = 0; + for (int i = 0; i < shardsIts.size(); i++) { + /* + * AbstractSearchAsyncAction executes only non-skipped shard iterators. This + * compacts the shard indexes observed by can_match responses: response index 0 + * refers to the first active shard, not necessarily shardsIts.get(0). + * + * CanMatchPreFilterSearchPhase applies possibleMatches back onto the original + * GroupShardsIterator so skipped shard groups remain in their original positions + * for accounting and downstream phases. + * + * This lookup translates compacted active shard indexes back to original shard + * group indexes. + */ + if (shardsIts.get(i).skip() == false) { + shardIndexes[activeShardCount++] = i; + } + } + return Arrays.copyOf(shardIndexes, activeShardCount); + } + private GroupShardsIterator getIterator( CanMatchSearchPhaseResults results, GroupShardsIterator shardsIts @@ -155,7 +221,9 @@ private GroupShardsIterator getIterator( if (cardinality == 0) { // this is a special case where we have no hit but we need to get at least one search response in order // to produce a valid search result with all the aggs etc. - possibleMatches.set(0); + if (results.hasActiveShards()) { + possibleMatches.set(results.getFirstActiveShardIndex()); + } } SearchSourceBuilder source = getRequest().source(); int i = 0; @@ -217,18 +285,22 @@ private static Comparator shardComparator( private static final class CanMatchSearchPhaseResults extends SearchPhaseResults { private final FixedBitSet possibleMatches; private final MinAndMax[] minAndMaxes; + private final int[] activeShardIndexToOriginalShardIndex; + private final int firstActiveShardIndex; private int numPossibleMatches; - CanMatchSearchPhaseResults(int size) { - super(size); - possibleMatches = new FixedBitSet(size); - minAndMaxes = new MinAndMax[size]; + CanMatchSearchPhaseResults(int originalShardCount, int[] activeShardIndexToOriginalShardIndex) { + super(originalShardCount); + possibleMatches = new FixedBitSet(originalShardCount); + minAndMaxes = new MinAndMax[originalShardCount]; + this.activeShardIndexToOriginalShardIndex = activeShardIndexToOriginalShardIndex; + firstActiveShardIndex = activeShardIndexToOriginalShardIndex.length == 0 ? 0 : activeShardIndexToOriginalShardIndex[0]; } @Override void consumeResult(CanMatchResponse result, Runnable next) { try { - consumeResult(result.getShardIndex(), result.canMatch(), result.estimatedMinAndMax()); + consumeResult(originalShardIndex(result.getShardIndex()), result.canMatch(), result.estimatedMinAndMax()); } finally { next.run(); } @@ -242,7 +314,7 @@ boolean hasResult(int shardIndex) { @Override void consumeShardFailure(int shardIndex) { // we have to carry over shard failures in order to account for them in the response. - consumeResult(shardIndex, true, null); + consumeResult(originalShardIndex(shardIndex), true, null); } synchronized void consumeResult(int shardIndex, boolean canMatch, MinAndMax minAndMax) { @@ -261,6 +333,27 @@ synchronized FixedBitSet getPossibleMatches() { return possibleMatches; } + int getFirstActiveShardIndex() { + return firstActiveShardIndex; + } + + boolean hasActiveShards() { + return activeShardIndexToOriginalShardIndex.length > 0; + } + + private int originalShardIndex(int activeShardIndex) { + if (activeShardIndex < 0 || activeShardIndex >= activeShardIndexToOriginalShardIndex.length) { + throw new IllegalStateException( + "invalid can_match shard index [" + + activeShardIndex + + "] for active shard index range [0, " + + activeShardIndexToOriginalShardIndex.length + + ")" + ); + } + return activeShardIndexToOriginalShardIndex[activeShardIndex]; + } + @Override Stream getSuccessfulResults() { return Stream.empty(); diff --git a/server/src/main/java/org/opensearch/action/search/SearchIndexPruningService.java b/server/src/main/java/org/opensearch/action/search/SearchIndexPruningService.java new file mode 100644 index 0000000000000..83bd197f6f63e --- /dev/null +++ b/server/src/main/java/org/opensearch/action/search/SearchIndexPruningService.java @@ -0,0 +1,264 @@ +/* + * 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.action.search; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.opensearch.action.search.pruning.FieldDomainEvaluationContext; +import org.opensearch.action.search.pruning.FieldDomainEvaluators; +import org.opensearch.action.search.pruning.MandatoryQueryConstraintExtractor; +import org.opensearch.action.search.pruning.QueryConstraint; +import org.opensearch.action.search.pruning.QueryConstraintExtractor; +import org.opensearch.action.search.pruning.SearchIndexPruningResult; +import org.opensearch.action.search.pruning.SearchIndexPruningSettings; +import org.opensearch.cluster.ClusterState; +import org.opensearch.cluster.routing.GroupShardsIterator; +import org.opensearch.common.settings.ClusterSettings; +import org.opensearch.common.settings.Settings; +import org.opensearch.index.fielddomain.FieldDomain; +import org.opensearch.index.fielddomain.FieldDomainProvider; + +import java.util.BitSet; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Computes index-level shard-group pruning decisions for a search request before can-match or query execution. + * + * This service is deliberately conservative: it only returns pruned shard groups when a mandatory query constraint + * is provably disjoint from finalized index-level field domains stored in cluster state. Missing, malformed, + * unsupported, unfinalized, remote, or ambiguous metadata causes the shard group to be kept. + */ +public final class SearchIndexPruningService { + private static final Logger logger = LogManager.getLogger(SearchIndexPruningService.class); + + private final QueryConstraintExtractor constraintExtractor; + private final FieldDomainProvider domainProvider; + private final FieldDomainEvaluators fieldDomainEvaluators; + + private final AtomicReference config; + + /** + * Creates a pruning service backed by cluster settings and index field domains stored in cluster state. + * + * @param clusterSettings cluster settings used to read and subscribe to pruning settings + * @param domainProvider provider that resolves index-level field domains for candidate shard groups + */ + public SearchIndexPruningService(ClusterSettings clusterSettings, FieldDomainProvider domainProvider) { + this(clusterSettings, domainProvider, new MandatoryQueryConstraintExtractor(), FieldDomainEvaluators.defaultEvaluators()); + } + + SearchIndexPruningService( + ClusterSettings clusterSettings, + FieldDomainProvider domainProvider, + QueryConstraintExtractor constraintExtractor + ) { + this(clusterSettings, domainProvider, constraintExtractor, FieldDomainEvaluators.defaultEvaluators()); + } + + SearchIndexPruningService( + ClusterSettings clusterSettings, + FieldDomainProvider domainProvider, + QueryConstraintExtractor constraintExtractor, + FieldDomainEvaluators fieldDomainEvaluators + ) { + Objects.requireNonNull(clusterSettings, "clusterSettings must not be null"); + this.domainProvider = Objects.requireNonNull(domainProvider, "domainProvider must not be null"); + this.constraintExtractor = Objects.requireNonNull(constraintExtractor, "constraintExtractor must not be null"); + this.fieldDomainEvaluators = Objects.requireNonNull(fieldDomainEvaluators, "fieldDomainEvaluators must not be null"); + + this.config = new AtomicReference<>(SearchIndexPruningConfig.fromClusterSettings(clusterSettings)); + clusterSettings.addSettingsUpdateConsumer( + settings -> config.set(SearchIndexPruningConfig.fromSettings(settings)), + SearchIndexPruningSettings.getSettings() + ); + } + + /** + * Computes which shard groups can be skipped for the supplied search request. + * + * The returned result does not mutate the shard iterators. Callers decide when to materialize the result by marking + * the selected {@link SearchShardIterator}s as skipped. + * + * @param request search request being executed + * @param shardIterators shard groups resolved for this request + * @param clusterState cluster state used to read index metadata + * @param evaluationContext request-scoped values needed while evaluating field domains + * @return pruning result containing the original shard iterators and any pruned shard group indexes + */ + public SearchIndexPruningResult prune( + SearchRequest request, + GroupShardsIterator shardIterators, + ClusterState clusterState, + FieldDomainEvaluationContext evaluationContext + ) { + Objects.requireNonNull(shardIterators, "shardIterators must not be null"); + try { + return doPrune(request, shardIterators, clusterState, evaluationContext); + } catch (RuntimeException e) { + logger.warn("failed to compute search index pruning; continuing without pruning", e); + return SearchIndexPruningResult.notPruned(shardIterators); + } + } + + private SearchIndexPruningResult doPrune( + SearchRequest request, + GroupShardsIterator shardIterators, + ClusterState clusterState, + FieldDomainEvaluationContext evaluationContext + ) { + Objects.requireNonNull(evaluationContext, "evaluationContext must not be null"); + final int originalSize = size(shardIterators); + final int activeShardGroups = activeShardGroups(shardIterators); + final SearchIndexPruningConfig currentConfig = config.get(); + + if (shouldSkip(request, activeShardGroups, currentConfig)) { + return SearchIndexPruningResult.notPruned(shardIterators); + } + + final Set pruningFields = currentConfig.fields; + + final List constraints = constraintExtractor.extractMandatoryConstraints(request.source(), pruningFields); + + if (constraints.isEmpty()) { + return SearchIndexPruningResult.notPruned(shardIterators); + } + + Map>> domainsByIndexAndField = new HashMap<>(); + BitSet prunedShardGroupIndexes = new BitSet(originalSize); + int pruned = 0; + + for (int shardGroupIndex = 0; shardGroupIndex < shardIterators.size(); shardGroupIndex++) { + SearchShardIterator shardIterator = shardIterators.get(shardGroupIndex); + if (shardIterator.skip()) { + continue; + } + + if (shouldAlwaysKeep(shardIterator)) { + continue; + } + + final String indexName = shardIterator.shardId().getIndexName(); + + boolean prune = false; + for (QueryConstraint constraint : constraints) { + final Optional maybeDomain = domainsByIndexAndField.computeIfAbsent(indexName, ignored -> new HashMap<>()) + .computeIfAbsent(constraint.field(), field -> domainProvider.getDomain(clusterState, indexName, field)); + + if (maybeDomain.isEmpty()) { + continue; + } + + final FieldDomain domain = maybeDomain.get(); + + if (domain.finalized() == false) { + continue; + } + + if (fieldDomainEvaluators.canMatch(domain, constraint, evaluationContext) == false) { + prune = true; + break; + } + } + + if (prune) { + prunedShardGroupIndexes.set(shardGroupIndex); + pruned++; + } + } + + if (pruned == 0) { + return SearchIndexPruningResult.notPruned(shardIterators); + } + + if (pruned == activeShardGroups) { + /* + * Pruning is an optimization. If every currently active shard group would be skipped, fall back to + * the original iterators until zero-shard response semantics are covered explicitly for this + * pre-can-match stage. Existing skip flags are preserved because they may have been set by another + * search execution step. + */ + return SearchIndexPruningResult.notPruned(shardIterators); + } + + return SearchIndexPruningResult.pruned(shardIterators, prunedShardGroupIndexes); + } + + private static boolean shouldSkip(SearchRequest request, int activeShardGroups, SearchIndexPruningConfig config) { + if (config.enabled == false) { + return true; + } + + if (request == null || request.source() == null) { + return true; + } + + if (request.pointInTimeBuilder() != null) { + // PIT searches must preserve the shard set captured by the PIT creation snapshot. + return true; + } + + if (activeShardGroups < config.minShards) { + return true; + } + return config.fields.isEmpty(); + } + + private static boolean shouldAlwaysKeep(SearchShardIterator shardIterator) { + return shardIterator.getClusterAlias() != null; + } + + private static int size(GroupShardsIterator iterators) { + return iterators.size(); + } + + private static int activeShardGroups(GroupShardsIterator iterators) { + int activeShardGroups = 0; + for (int i = 0; i < iterators.size(); i++) { + if (iterators.get(i).skip() == false) { + activeShardGroups++; + } + } + return activeShardGroups; + } + + private static final class SearchIndexPruningConfig { + private final boolean enabled; + private final int minShards; + private final Set fields; + + private SearchIndexPruningConfig(boolean enabled, int minShards, Collection fields) { + this.enabled = enabled; + this.minShards = minShards; + this.fields = Set.copyOf(fields); + } + + private static SearchIndexPruningConfig fromClusterSettings(ClusterSettings clusterSettings) { + return new SearchIndexPruningConfig( + clusterSettings.get(SearchIndexPruningSettings.ENABLED), + clusterSettings.get(SearchIndexPruningSettings.MIN_SHARDS), + clusterSettings.get(SearchIndexPruningSettings.FIELDS) + ); + } + + private static SearchIndexPruningConfig fromSettings(Settings settings) { + return new SearchIndexPruningConfig( + SearchIndexPruningSettings.ENABLED.get(settings), + SearchIndexPruningSettings.MIN_SHARDS.get(settings), + SearchIndexPruningSettings.FIELDS.get(settings) + ); + } + } +} diff --git a/server/src/main/java/org/opensearch/action/search/TransportSearchAction.java b/server/src/main/java/org/opensearch/action/search/TransportSearchAction.java index b69c8578e5703..304e4d5a5f3f5 100644 --- a/server/src/main/java/org/opensearch/action/search/TransportSearchAction.java +++ b/server/src/main/java/org/opensearch/action/search/TransportSearchAction.java @@ -38,6 +38,8 @@ import org.opensearch.action.admin.cluster.shards.ClusterSearchShardsGroup; import org.opensearch.action.admin.cluster.shards.ClusterSearchShardsRequest; import org.opensearch.action.admin.cluster.shards.ClusterSearchShardsResponse; +import org.opensearch.action.search.pruning.FieldDomainEvaluationContext; +import org.opensearch.action.search.pruning.SearchIndexPruningResult; import org.opensearch.action.support.ActionFilters; import org.opensearch.action.support.HandledTransportAction; import org.opensearch.action.support.IndicesOptions; @@ -71,6 +73,7 @@ import org.opensearch.core.index.shard.ShardId; import org.opensearch.core.indices.breaker.CircuitBreakerService; import org.opensearch.core.tasks.TaskId; +import org.opensearch.index.fielddomain.ClusterStateFieldDomainProvider; import org.opensearch.index.query.Rewriteable; import org.opensearch.indices.IndicesService; import org.opensearch.search.SearchPhaseResult; @@ -189,6 +192,8 @@ public class TransportSearchAction extends HandledTransportAction buildPerIndexAliasFilter( @@ -1273,6 +1282,68 @@ AbstractSearchAsyncAction searchAsyncAction( ThreadPool threadPool, SearchResponse.Clusters clusters, SearchRequestContext searchRequestContext + ) { + maybeApplySearchIndexPruning(searchRequest, shardIterators, clusterState, timeProvider); + return createSearchAsyncAction( + task, + searchRequest, + executor, + shardIterators, + timeProvider, + connectionLookup, + clusterState, + aliasFilter, + concreteIndexBoosts, + indexRoutings, + listener, + preFilter, + threadPool, + clusters, + searchRequestContext + ); + } + + /** + * Applies index pruning results to the request-local shard iterators. + */ + private void maybeApplySearchIndexPruning( + SearchRequest searchRequest, + GroupShardsIterator shardIterators, + ClusterState clusterState, + SearchTimeProvider timeProvider + ) { + final SearchIndexPruningResult pruningResult = searchIndexPruningService.prune( + searchRequest, + shardIterators, + clusterState, + new FieldDomainEvaluationContext(timeProvider::getAbsoluteStartMillis) + ); + if (pruningResult.pruned() == false) { + return; + } + for (int i = 0; i < pruningResult.originalShardGroups(); i++) { + if (pruningResult.isPrunedShardGroup(i)) { + pruningResult.shardIterators().get(i).resetAndSkip(); + } + } + } + + private AbstractSearchAsyncAction createSearchAsyncAction( + SearchTask task, + SearchRequest searchRequest, + Executor executor, + GroupShardsIterator shardIterators, + SearchTimeProvider timeProvider, + BiFunction connectionLookup, + ClusterState clusterState, + Map aliasFilter, + Map concreteIndexBoosts, + Map> indexRoutings, + ActionListener listener, + boolean preFilter, + ThreadPool threadPool, + SearchResponse.Clusters clusters, + SearchRequestContext searchRequestContext ) { if (preFilter) { return new CanMatchPreFilterSearchPhase( @@ -1290,7 +1361,7 @@ AbstractSearchAsyncAction searchAsyncAction( clusterState, task, (iter) -> new WrappingSearchAsyncActionPhase( - searchAsyncAction( + createSearchAsyncAction( task, searchRequest, executor, diff --git a/server/src/main/java/org/opensearch/action/search/TransportSearchHelper.java b/server/src/main/java/org/opensearch/action/search/TransportSearchHelper.java index 779a0ac0c6590..4eeea9a40faec 100644 --- a/server/src/main/java/org/opensearch/action/search/TransportSearchHelper.java +++ b/server/src/main/java/org/opensearch/action/search/TransportSearchHelper.java @@ -113,7 +113,11 @@ static ParsedScrollId parseScrollId(String scrollId) { includeContextUUID = false; type = firstChunk; } - SearchContextIdForNode[] context = new SearchContextIdForNode[in.readVInt()]; + int count = in.readVInt(); + if (count < 0 || count > bytes.length) { + throw new IllegalArgumentException("Invalid scroll id"); + } + SearchContextIdForNode[] context = new SearchContextIdForNode[count]; for (int i = 0; i < context.length; ++i) { final String contextUUID = includeContextUUID ? in.readString() : ""; long id = in.readLong(); diff --git a/server/src/main/java/org/opensearch/action/search/pruning/DateRangeFieldDomainEvaluator.java b/server/src/main/java/org/opensearch/action/search/pruning/DateRangeFieldDomainEvaluator.java new file mode 100644 index 0000000000000..ccd325b782f30 --- /dev/null +++ b/server/src/main/java/org/opensearch/action/search/pruning/DateRangeFieldDomainEvaluator.java @@ -0,0 +1,212 @@ +/* + * 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.action.search.pruning; + +import org.opensearch.common.time.DateFormatter; +import org.opensearch.common.time.DateMathParser; +import org.opensearch.index.fielddomain.DateRangeFieldDomain; +import org.opensearch.index.fielddomain.FieldDomain; +import org.opensearch.index.mapper.DateFieldMapper; + +import java.time.ZoneId; +import java.util.Locale; +import java.util.Optional; + +/** + * Evaluates date range index bounds against generic range query constraints. + * + * Unsupported domains, unsupported constraints, parse failures, and invalid resolutions all return {@code true} + * so pruning remains conservative. + */ +public final class DateRangeFieldDomainEvaluator implements FieldDomainEvaluator { + /** + * Returns {@code false} only when the query date range is provably disjoint from the finalized index date range. + * Unfinalized domains are treated as potentially matching, even though the pruning service also filters them before + * calling evaluators. + */ + @Override + public boolean canMatch(FieldDomain domain, QueryConstraint constraint, FieldDomainEvaluationContext context) { + if (!(domain instanceof DateRangeFieldDomain dateRangeDomain) || !(constraint instanceof RangeQueryConstraint rangeConstraint)) { + return true; + } + if (dateRangeDomain.finalized() == false) { + return true; + } + + Optional indexRange = parseIndexRange(dateRangeDomain); + if (indexRange.isEmpty()) { + return true; + } + + Optional queryRange = parseQueryRange(dateRangeDomain, rangeConstraint, context); + if (queryRange.isEmpty()) { + return true; + } + + NormalizedRange index = indexRange.get(); + if (index.isEmpty()) { + return true; + } + + NormalizedRange query = queryRange.get(); + if (query.isEmpty()) { + return false; + } + + return index.intersects(query); + } + + private static Optional parseIndexRange(DateRangeFieldDomain domain) { + try { + long min = Long.parseLong(domain.min()); + long max = Long.parseLong(domain.max()); + if (min > max) { + return Optional.empty(); + } + return Optional.of(new NormalizedRange(min, max)); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + + private Optional parseQueryRange( + DateRangeFieldDomain domain, + RangeQueryConstraint constraint, + FieldDomainEvaluationContext context + ) { + DateFieldMapper.Resolution resolution = resolveResolution(domain); + if (resolution == null) { + return Optional.empty(); + } + + if (constraint.relation() != null) { + return Optional.empty(); + } + + DateMathParser parser; + try { + String format = constraint.format() == null ? domain.format() : constraint.format(); + parser = format == null + ? DateFieldMapper.getDefaultDateTimeFormatter().toDateMathParser() + : DateFormatter.forPattern(format).toDateMathParser(); + } catch (RuntimeException e) { + return Optional.empty(); + } + + ZoneId timeZone; + try { + timeZone = constraint.timeZone() == null ? null : ZoneId.of(constraint.timeZone()); + } catch (RuntimeException e) { + return Optional.empty(); + } + + long lowerInclusive = Long.MIN_VALUE; + if (constraint.hasLowerBound()) { + // Match RangeQueryBuilder's date-bound rounding: exclusive lower bounds round up before the +1 adjustment. + Optional parsed = parseDateValue( + constraint.lowerValue(), + constraint.includeLower() == false, + timeZone, + parser, + context, + resolution + ); + if (parsed.isEmpty()) { + return Optional.empty(); + } + lowerInclusive = parsed.get(); + if (constraint.includeLower() == false) { + if (lowerInclusive == Long.MAX_VALUE) { + return Optional.of(NormalizedRange.EMPTY); + } + lowerInclusive++; + } + } + + long upperInclusive = Long.MAX_VALUE; + if (constraint.hasUpperBound()) { + // Match RangeQueryBuilder's date-bound rounding: inclusive upper bounds round up to the end of the parsed value. + Optional parsed = parseDateValue( + constraint.upperValue(), + constraint.includeUpper(), + timeZone, + parser, + context, + resolution + ); + if (parsed.isEmpty()) { + return Optional.empty(); + } + upperInclusive = parsed.get(); + if (constraint.includeUpper() == false) { + if (upperInclusive == Long.MIN_VALUE) { + return Optional.of(NormalizedRange.EMPTY); + } + upperInclusive--; + } + } + + if (lowerInclusive > upperInclusive) { + return Optional.of(NormalizedRange.EMPTY); + } + + return Optional.of(new NormalizedRange(lowerInclusive, upperInclusive)); + } + + private static Optional parseDateValue( + Object value, + boolean roundUp, + ZoneId zone, + DateMathParser parser, + FieldDomainEvaluationContext context, + DateFieldMapper.Resolution resolution + ) { + try { + return Optional.of( + DateFieldMapper.DateFieldType.parseToLong(value, roundUp, zone, parser, context.nowInMillisSupplier(), resolution) + ); + } catch (RuntimeException e) { + return Optional.empty(); + } + } + + private static DateFieldMapper.Resolution resolveResolution(DateRangeFieldDomain domain) { + if (domain.resolution() == null || domain.resolution().isEmpty()) { + return null; + } + + String configured = domain.resolution().toLowerCase(Locale.ROOT); + for (DateFieldMapper.Resolution resolution : DateFieldMapper.Resolution.values()) { + if (resolution.name().toLowerCase(Locale.ROOT).equals(configured) || resolution.type().equals(configured)) { + return resolution; + } + } + return null; + } + + private static final class NormalizedRange { + private static final NormalizedRange EMPTY = new NormalizedRange(1L, 0L); + + private final long min; + private final long max; + + private NormalizedRange(long min, long max) { + this.min = min; + this.max = max; + } + + private boolean isEmpty() { + return min > max; + } + + private boolean intersects(NormalizedRange other) { + return max >= other.min && min <= other.max; + } + } +} diff --git a/server/src/main/java/org/opensearch/action/search/pruning/FieldDomainEvaluationContext.java b/server/src/main/java/org/opensearch/action/search/pruning/FieldDomainEvaluationContext.java new file mode 100644 index 0000000000000..80ee9e09aaf3a --- /dev/null +++ b/server/src/main/java/org/opensearch/action/search/pruning/FieldDomainEvaluationContext.java @@ -0,0 +1,42 @@ +/* + * 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.action.search.pruning; + +import java.util.Objects; +import java.util.function.LongSupplier; + +/** + * Request-scoped context used while evaluating field domains against query constraints. + */ +public final class FieldDomainEvaluationContext { + private final LongSupplier nowInMillisSupplier; + + /** + * Creates an evaluation context. + * + * @param nowInMillisSupplier supplier for the search request's absolute start time in epoch milliseconds + */ + public FieldDomainEvaluationContext(LongSupplier nowInMillisSupplier) { + this.nowInMillisSupplier = Objects.requireNonNull(nowInMillisSupplier, "nowInMillisSupplier must not be null"); + } + + /** + * Supplier for resolving date math expressions such as {@code now}. + */ + public LongSupplier nowInMillisSupplier() { + return nowInMillisSupplier; + } + + /** + * Resolves the request's absolute start time in epoch milliseconds. + */ + public long nowInMillis() { + return nowInMillisSupplier.getAsLong(); + } +} diff --git a/server/src/main/java/org/opensearch/action/search/pruning/FieldDomainEvaluator.java b/server/src/main/java/org/opensearch/action/search/pruning/FieldDomainEvaluator.java new file mode 100644 index 0000000000000..38cb2251358b8 --- /dev/null +++ b/server/src/main/java/org/opensearch/action/search/pruning/FieldDomainEvaluator.java @@ -0,0 +1,36 @@ +/* + * 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.action.search.pruning; + +import org.opensearch.index.fielddomain.FieldDomain; + +/** + * Type-specific component that compares an index-side {@link FieldDomain} with a query-side {@link QueryConstraint}. + * + * A field domain by itself is only metadata, and a query constraint by itself is only a requirement from the search + * request. The evaluator knows how to interpret both for a particular domain/constraint combination. For example, a + * date-range evaluator can parse date math in a range query, normalize it to the domain's resolution, and decide + * whether the query range intersects the index's date range. + * + * Evaluators are part of the pruning correctness boundary. They must be conservative: returning {@code false} means + * "this index cannot match"; returning {@code true} means "this index may match, or I cannot prove otherwise". + */ +public interface FieldDomainEvaluator { + /** + * Returns true when the index may contain matches for the query constraint. + * Returning false means the index is provably disjoint and may be pruned. Unsupported + * domain or constraint types must return true. + * + * @param domain index-level field domain + * @param constraint mandatory query constraint for the same field + * @param context request-scoped evaluation context + * @return {@code true} when the index may match, {@code false} only when it is safe to prune + */ + boolean canMatch(FieldDomain domain, QueryConstraint constraint, FieldDomainEvaluationContext context); +} diff --git a/server/src/main/java/org/opensearch/action/search/pruning/FieldDomainEvaluators.java b/server/src/main/java/org/opensearch/action/search/pruning/FieldDomainEvaluators.java new file mode 100644 index 0000000000000..3de9bc9c93887 --- /dev/null +++ b/server/src/main/java/org/opensearch/action/search/pruning/FieldDomainEvaluators.java @@ -0,0 +1,66 @@ +/* + * 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.action.search.pruning; + +import org.opensearch.index.fielddomain.FieldDomain; + +import java.util.List; +import java.util.Objects; + +/** + * Ordered collection of field-domain evaluators. + * + * The collection keeps pruning conservative by treating "no evaluator can prune" as "the index may match". + */ +public final class FieldDomainEvaluators { + + private static final FieldDomainEvaluators DEFAULT = new FieldDomainEvaluators(List.of(new DateRangeFieldDomainEvaluator())); + + private final List evaluators; + + /** + * Creates an evaluator collection. + * + * @param evaluators evaluators to run for each field-domain/constraint pair + */ + public FieldDomainEvaluators(List evaluators) { + Objects.requireNonNull(evaluators, "evaluators must not be null"); + this.evaluators = List.copyOf(evaluators); + } + + /** + * Returns the built-in evaluator collection. + */ + public static FieldDomainEvaluators defaultEvaluators() { + return DEFAULT; + } + + /** + * Returns whether an index with the supplied bounds may match the supplied query constraint. + * + * @return {@code false} if any evaluator proves the pair is disjoint; otherwise {@code true} + */ + public boolean canMatch(FieldDomain domain, QueryConstraint constraint, FieldDomainEvaluationContext context) { + Objects.requireNonNull(domain, "domain must not be null"); + Objects.requireNonNull(constraint, "constraint must not be null"); + Objects.requireNonNull(context, "context must not be null"); + + if (domain.finalized() == false || domain.field().equals(constraint.field()) == false) { + return true; + } + + for (FieldDomainEvaluator evaluator : evaluators) { + if (evaluator.canMatch(domain, constraint, context) == false) { + return false; + } + } + + return true; + } +} diff --git a/server/src/main/java/org/opensearch/action/search/pruning/MandatoryQueryConstraintExtractor.java b/server/src/main/java/org/opensearch/action/search/pruning/MandatoryQueryConstraintExtractor.java new file mode 100644 index 0000000000000..f26302bb91f82 --- /dev/null +++ b/server/src/main/java/org/opensearch/action/search/pruning/MandatoryQueryConstraintExtractor.java @@ -0,0 +1,98 @@ +/* + * 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.action.search.pruning; + +import org.apache.lucene.search.BooleanClause; +import org.opensearch.index.query.QueryBuilder; +import org.opensearch.index.query.QueryBuilderVisitor; +import org.opensearch.index.query.RangeQueryBuilder; +import org.opensearch.search.builder.SearchSourceBuilder; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * Conservative query-tree walker that extracts constraints from mandatory query positions. + * + * The extractor relies on {@link QueryBuilder#visit(QueryBuilderVisitor)} to traverse the query tree. It only follows + * children visited as {@link BooleanClause.Occur#MUST} or {@link BooleanClause.Occur#FILTER}; optional or negative + * clauses are ignored because they are not required for every matching document. + */ +public final class MandatoryQueryConstraintExtractor implements QueryConstraintExtractor { + /** + * Extracts mandatory constraints for the configured pruning fields. + */ + @Override + public List extractMandatoryConstraints(SearchSourceBuilder source, Set pruningFields) { + if (source == null || source.query() == null || pruningFields == null || pruningFields.isEmpty()) { + return List.of(); + } + + MandatoryQueryConstraintVisitor visitor = new MandatoryQueryConstraintVisitor(pruningFields); + source.query().visit(visitor); + return visitor.constraints(); + } + + private static final class MandatoryQueryConstraintVisitor implements QueryBuilderVisitor { + private final Set pruningFields; + private final List constraints = new ArrayList<>(); + + private MandatoryQueryConstraintVisitor(Set pruningFields) { + this.pruningFields = pruningFields; + } + + @Override + public void accept(QueryBuilder queryBuilder) { + if (queryBuilder instanceof RangeQueryBuilder) { + RangeQueryConstraint constraint = buildRangeConstraint((RangeQueryBuilder) queryBuilder, pruningFields); + if (constraint != null) { + constraints.add(constraint); + } + } + } + + @Override + public QueryBuilderVisitor getChildVisitor(BooleanClause.Occur occur) { + switch (occur) { + case MUST: + case FILTER: + return this; + default: + return QueryBuilderVisitor.NO_OP_VISITOR; + } + } + + private List constraints() { + return List.copyOf(constraints); + } + } + + private static RangeQueryConstraint buildRangeConstraint(RangeQueryBuilder range, Set pruningFields) { + String field = range.fieldName(); + if (field == null || pruningFields.contains(field) == false) { + return null; + } + + if (range.from() == null && range.to() == null) { + return null; + } + + return new RangeQueryConstraint( + field, + range.from(), + range.to(), + range.includeLower(), + range.includeUpper(), + range.format(), + range.timeZone(), + range.relation() + ); + } +} diff --git a/server/src/main/java/org/opensearch/action/search/pruning/QueryConstraint.java b/server/src/main/java/org/opensearch/action/search/pruning/QueryConstraint.java new file mode 100644 index 0000000000000..35dc1f5d081bd --- /dev/null +++ b/server/src/main/java/org/opensearch/action/search/pruning/QueryConstraint.java @@ -0,0 +1,19 @@ +/* + * 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.action.search.pruning; + +/** + * Mandatory query-side condition that can be evaluated against index-level field domains. + */ +public interface QueryConstraint { + /** + * Field name constrained by the query. + */ + String field(); +} diff --git a/server/src/main/java/org/opensearch/action/search/pruning/QueryConstraintExtractor.java b/server/src/main/java/org/opensearch/action/search/pruning/QueryConstraintExtractor.java new file mode 100644 index 0000000000000..6ff9d65de4130 --- /dev/null +++ b/server/src/main/java/org/opensearch/action/search/pruning/QueryConstraintExtractor.java @@ -0,0 +1,28 @@ +/* + * 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.action.search.pruning; + +import org.opensearch.search.builder.SearchSourceBuilder; + +import java.util.List; +import java.util.Set; + +/** + * Extracts mandatory query constraints that are safe to use for index pruning. + */ +public interface QueryConstraintExtractor { + /** + * Extracts constraints that every matching document must satisfy. + * + * @param source search source containing the query tree + * @param fields configured pruning fields + * @return mandatory constraints for configured fields + */ + List extractMandatoryConstraints(SearchSourceBuilder source, Set fields); +} diff --git a/server/src/main/java/org/opensearch/action/search/pruning/RangeQueryConstraint.java b/server/src/main/java/org/opensearch/action/search/pruning/RangeQueryConstraint.java new file mode 100644 index 0000000000000..74482240dba6c --- /dev/null +++ b/server/src/main/java/org/opensearch/action/search/pruning/RangeQueryConstraint.java @@ -0,0 +1,138 @@ +/* + * 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.action.search.pruning; + +import org.opensearch.common.geo.ShapeRelation; + +import java.util.Objects; + +/** + * Generic query constraint extracted from a mandatory range query. + * + * This class intentionally models range-query structure only. Its fields are an immutable snapshot of the normalized + * state exposed by {@link org.opensearch.index.query.RangeQueryBuilder}: lower/upper bounds, inclusivity, and optional + * range interpretation parameters such as format, time zone, and relation. Type-specific semantics such as date parsing, + * numeric coercion, or geo interpretation are handled by field-domain evaluators. + */ +public final class RangeQueryConstraint implements QueryConstraint { + private final String field; + private final Object lowerValue; + private final Object upperValue; + private final boolean includeLower; + private final boolean includeUpper; + private final String format; + private final String timeZone; + private final ShapeRelation relation; + + /** + * Creates a range query constraint with optional range-query interpretation parameters. + * + * @param field constrained field name + * @param lowerValue lower bound value, or {@code null} for unbounded + * @param upperValue upper bound value, or {@code null} for unbounded + * @param includeLower whether the lower bound is inclusive + * @param includeUpper whether the upper bound is inclusive + * @param format optional query-level format + * @param timeZone optional query-level time zone + * @param relation optional range relation + */ + public RangeQueryConstraint( + String field, + Object lowerValue, + Object upperValue, + boolean includeLower, + boolean includeUpper, + String format, + String timeZone, + ShapeRelation relation + ) { + this.field = Objects.requireNonNull(field, "field must not be null"); + if (field.isEmpty()) { + throw new IllegalArgumentException("field must not be empty"); + } + if (lowerValue == null && upperValue == null) { + throw new IllegalArgumentException("range constraint must have at least one bound"); + } + this.lowerValue = lowerValue; + this.upperValue = upperValue; + this.includeLower = includeLower; + this.includeUpper = includeUpper; + this.format = format; + this.timeZone = timeZone; + this.relation = relation; + } + + @Override + public String field() { + return field; + } + + /** + * Lower bound value as supplied by the query builder, or {@code null} when unbounded. + */ + public Object lowerValue() { + return lowerValue; + } + + /** + * Upper bound value as supplied by the query builder, or {@code null} when unbounded. + */ + public Object upperValue() { + return upperValue; + } + + /** + * Whether the lower bound is inclusive. + */ + public boolean includeLower() { + return includeLower; + } + + /** + * Whether the upper bound is inclusive. + */ + public boolean includeUpper() { + return includeUpper; + } + + /** + * Optional query-level format as supplied by the range query builder. + */ + public String format() { + return format; + } + + /** + * Optional query-level time zone as supplied by the range query builder. + */ + public String timeZone() { + return timeZone; + } + + /** + * Optional range relation as supplied by the range query builder. + */ + public ShapeRelation relation() { + return relation; + } + + /** + * Whether this constraint has a lower bound. + */ + public boolean hasLowerBound() { + return lowerValue != null; + } + + /** + * Whether this constraint has an upper bound. + */ + public boolean hasUpperBound() { + return upperValue != null; + } +} diff --git a/server/src/main/java/org/opensearch/action/search/pruning/SearchIndexPruningResult.java b/server/src/main/java/org/opensearch/action/search/pruning/SearchIndexPruningResult.java new file mode 100644 index 0000000000000..2a4da2f1bb607 --- /dev/null +++ b/server/src/main/java/org/opensearch/action/search/pruning/SearchIndexPruningResult.java @@ -0,0 +1,94 @@ +/* + * 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.action.search.pruning; + +import org.opensearch.action.search.SearchShardIterator; +import org.opensearch.cluster.routing.GroupShardsIterator; + +import java.util.BitSet; +import java.util.Objects; + +/** + * Immutable result of one index-pruning pass. + * + * The result records shard group indexes in the original {@link GroupShardsIterator}. It does not mutate iterators; + * the caller materializes pruning by marking selected {@link SearchShardIterator}s as skipped. + */ +public final class SearchIndexPruningResult { + private final GroupShardsIterator shardIterators; + private final BitSet prunedShardGroupIndexes; + + private SearchIndexPruningResult(GroupShardsIterator shardIterators, BitSet prunedShardGroupIndexes) { + this.shardIterators = Objects.requireNonNull(shardIterators, "shardIterators must not be null"); + this.prunedShardGroupIndexes = copyOf(prunedShardGroupIndexes); + } + + /** + * Creates a result representing no pruning. + */ + public static SearchIndexPruningResult notPruned(GroupShardsIterator shardIterators) { + return new SearchIndexPruningResult(shardIterators, new BitSet(shardIterators.size())); + } + + /** + * Creates a result containing pruned shard group indexes. + */ + public static SearchIndexPruningResult pruned(GroupShardsIterator shardIterators, BitSet prunedShardGroupIndexes) { + Objects.requireNonNull(shardIterators, "shardIterators must not be null"); + Objects.requireNonNull(prunedShardGroupIndexes, "prunedShardGroupIndexes must not be null"); + + int prunedShardGroups = prunedShardGroupIndexes.cardinality(); + if (prunedShardGroups < 1 || prunedShardGroups >= shardIterators.size()) { + throw new IllegalArgumentException("pruned shard group count must be between 1 and total shard groups - 1"); + } + if (prunedShardGroupIndexes.length() > shardIterators.size()) { + throw new IllegalArgumentException("pruned shard group indexes must be within the shard iterator bounds"); + } + return new SearchIndexPruningResult(shardIterators, prunedShardGroupIndexes); + } + + /** + * Original shard iterators supplied to the pruning pass. + */ + public GroupShardsIterator shardIterators() { + return shardIterators; + } + + /** + * Number of original shard groups. + */ + public int originalShardGroups() { + return shardIterators.size(); + } + + /** + * Whether the original shard group index was pruned. + */ + public boolean isPrunedShardGroup(int shardGroupIndex) { + return prunedShardGroupIndexes.get(shardGroupIndex); + } + + /** + * Number of pruned shard groups. + */ + public int prunedShardGroups() { + return prunedShardGroupIndexes.cardinality(); + } + + /** + * Whether at least one shard group was pruned. + */ + public boolean pruned() { + return prunedShardGroupIndexes.isEmpty() == false; + } + + private static BitSet copyOf(BitSet bitSet) { + return (BitSet) Objects.requireNonNull(bitSet, "bitSet must not be null").clone(); + } +} diff --git a/server/src/main/java/org/opensearch/action/search/pruning/SearchIndexPruningSettings.java b/server/src/main/java/org/opensearch/action/search/pruning/SearchIndexPruningSettings.java new file mode 100644 index 0000000000000..a8532fd066855 --- /dev/null +++ b/server/src/main/java/org/opensearch/action/search/pruning/SearchIndexPruningSettings.java @@ -0,0 +1,53 @@ +/* + * 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.action.search.pruning; + +import org.opensearch.common.settings.Setting; + +import java.util.Collections; +import java.util.List; +import java.util.function.Function; + +import static org.opensearch.common.settings.Setting.Property.Dynamic; +import static org.opensearch.common.settings.Setting.Property.NodeScope; + +/** + * Cluster settings controlling index-level search pruning. + */ +public final class SearchIndexPruningSettings { + private SearchIndexPruningSettings() {} + + /** + * Enables coordinator-side index pruning before can-match/query execution. + */ + public static final Setting ENABLED = Setting.boolSetting("search.index_pruning.enabled", false, Dynamic, NodeScope); + + /** + * Minimum number of shard groups required before pruning is attempted. + */ + public static final Setting MIN_SHARDS = Setting.intSetting("search.index_pruning.min_shards", 128, 1, Dynamic, NodeScope); + + /** + * Query fields eligible for pruning. + */ + public static final Setting> FIELDS = Setting.listSetting( + "search.index_pruning.fields", + Collections.emptyList(), + Function.identity(), + Dynamic, + NodeScope + ); + + /** + * Returns all pruning settings for registration with cluster settings. + */ + public static List> getSettings() { + return List.of(ENABLED, MIN_SHARDS, FIELDS); + } +} diff --git a/server/src/main/java/org/opensearch/action/search/pruning/package-info.java b/server/src/main/java/org/opensearch/action/search/pruning/package-info.java new file mode 100644 index 0000000000000..cae5c09d35ce9 --- /dev/null +++ b/server/src/main/java/org/opensearch/action/search/pruning/package-info.java @@ -0,0 +1,10 @@ +/* + * 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. + */ + +/** Index-level search shards pruning */ +package org.opensearch.action.search.pruning; diff --git a/server/src/main/java/org/opensearch/bootstrap/Bootstrap.java b/server/src/main/java/org/opensearch/bootstrap/Bootstrap.java index 70e365025fe07..6fbd834dd3a4d 100644 --- a/server/src/main/java/org/opensearch/bootstrap/Bootstrap.java +++ b/server/src/main/java/org/opensearch/bootstrap/Bootstrap.java @@ -116,6 +116,18 @@ public void run() { }); } + /** + * Installs a process-wide serial filter that rejects all Java deserialization by default. + * Plugins that legitimately require Java serialization (e.g., security plugin's user attribute caching) + * can opt in by calling {@code ObjectInputStream.setObjectInputFilter()} on their specific stream, + * which overrides the JVM-wide filter for that stream. + *

    + * Gated behind the {@code bootstrap.serial_filter} setting (disabled by default). + */ + static void initializeSerialFilter() { + BootstrapSettings.initializeSerialFilter(); + } + /** initialize native resources */ public static void initializeNatives(Path tmpFile, boolean mlockAll, boolean systemCallFilter, boolean ctrlHandler) { final Logger logger = LogManager.getLogger(Bootstrap.class); diff --git a/server/src/main/java/org/opensearch/bootstrap/BootstrapSettings.java b/server/src/main/java/org/opensearch/bootstrap/BootstrapSettings.java index 911bc92c433f1..a7e70136bc5d1 100644 --- a/server/src/main/java/org/opensearch/bootstrap/BootstrapSettings.java +++ b/server/src/main/java/org/opensearch/bootstrap/BootstrapSettings.java @@ -32,9 +32,12 @@ package org.opensearch.bootstrap; +import org.apache.logging.log4j.LogManager; import org.opensearch.common.settings.Setting; import org.opensearch.common.settings.Setting.Property; +import java.io.ObjectInputFilter; + /** * Settings used for bootstrapping OpenSearch * @@ -59,4 +62,22 @@ private BootstrapSettings() {} ); public static final Setting CTRLHANDLER_SETTING = Setting.boolSetting("bootstrap.ctrlhandler", true, Property.NodeScope); + public static final Setting SERIAL_FILTER_SETTING = Setting.boolSetting("bootstrap.serial_filter", false, Property.NodeScope); + + static final ObjectInputFilter REJECT_ALL_FILTER = filterInfo -> filterInfo.serialClass() == null + ? ObjectInputFilter.Status.UNDECIDED + : ObjectInputFilter.Status.REJECTED; + + /** + * Installs a process-wide ObjectInputFilter that rejects all Java deserialization by default. + * Code that needs deserialization can opt in by calling setObjectInputFilter on their stream. + */ + public static void initializeSerialFilter() { + try { + ObjectInputFilter.Config.setSerialFilter(REJECT_ALL_FILTER); + } catch (IllegalStateException e) { + LogManager.getLogger(BootstrapSettings.class).debug("Serial filter already initialized", e); + } + } + } diff --git a/server/src/main/java/org/opensearch/cluster/metadata/IndexMetadata.java b/server/src/main/java/org/opensearch/cluster/metadata/IndexMetadata.java index 3a30d0688f734..d951e4b2786a8 100644 --- a/server/src/main/java/org/opensearch/cluster/metadata/IndexMetadata.java +++ b/server/src/main/java/org/opensearch/cluster/metadata/IndexMetadata.java @@ -68,6 +68,7 @@ import org.opensearch.core.xcontent.XContentParser; import org.opensearch.gateway.MetadataStateFormat; import org.opensearch.index.IndexModule; +import org.opensearch.index.IndexSettings; import org.opensearch.index.mapper.MapperService; import org.opensearch.index.seqno.SequenceNumbers; import org.opensearch.indices.pollingingest.IngestionErrorStrategy; @@ -2490,7 +2491,8 @@ public IndexMetadata build() { ShardsLimitAllocationDecider.INDEX_TOTAL_REMOTE_CAPABLE_SHARDS_PER_NODE_SETTING.get(settings); final int indexTotalRemoteCapablePrimaryShardsPerNodeLimit = ShardsLimitAllocationDecider.INDEX_TOTAL_REMOTE_CAPABLE_PRIMARY_SHARDS_PER_NODE_SETTING.get(settings); - final boolean isAppendOnlyIndex = INDEX_APPEND_ONLY_ENABLED_SETTING.get(settings); + final boolean isAppendOnlyIndex = INDEX_APPEND_ONLY_ENABLED_SETTING.get(settings) + || IndexSettings.PLUGGABLE_DATAFORMAT_ENABLED_SETTING.get(settings); final String uuid = settings.get(SETTING_INDEX_UUID, INDEX_UUID_NA_VALUE); diff --git a/server/src/main/java/org/opensearch/cluster/metadata/MetadataCreateIndexService.java b/server/src/main/java/org/opensearch/cluster/metadata/MetadataCreateIndexService.java index 6e05f2318ca74..96d0fc056bc1e 100644 --- a/server/src/main/java/org/opensearch/cluster/metadata/MetadataCreateIndexService.java +++ b/server/src/main/java/org/opensearch/cluster/metadata/MetadataCreateIndexService.java @@ -1213,7 +1213,7 @@ static Settings aggregateIndexSettings( } if (INDEX_NUMBER_OF_REPLICAS_SETTING.exists(indexSettingsBuilder) == false || indexSettingsBuilder.get(SETTING_NUMBER_OF_REPLICAS) == null) { - indexSettingsBuilder.put(SETTING_NUMBER_OF_REPLICAS, DEFAULT_REPLICA_COUNT_SETTING.get(currentState.metadata().settings())); + indexSettingsBuilder.put(SETTING_NUMBER_OF_REPLICAS, clusterSettings.get(DEFAULT_REPLICA_COUNT_SETTING)); } if (settings.get(SETTING_AUTO_EXPAND_REPLICAS) != null && indexSettingsBuilder.get(SETTING_AUTO_EXPAND_REPLICAS) == null) { indexSettingsBuilder.put(SETTING_AUTO_EXPAND_REPLICAS, settings.get(SETTING_AUTO_EXPAND_REPLICAS)); @@ -1777,7 +1777,7 @@ List getIndexSettingsValidationErrors( // Apply aware replica balance validation only to non system indices int replicaCount = settings.getAsInt( IndexMetadata.SETTING_NUMBER_OF_REPLICAS, - DEFAULT_REPLICA_COUNT_SETTING.get(this.clusterService.state().metadata().settings()) + clusterService.getClusterSettings().get(DEFAULT_REPLICA_COUNT_SETTING) ); int searchReplicaCount = settings.getAsInt(SETTING_NUMBER_OF_SEARCH_REPLICAS, 0); AutoExpandReplicas autoExpandReplica = AutoExpandReplicas.SETTING.get(settings); diff --git a/server/src/main/java/org/opensearch/cluster/metadata/MetadataIndexTemplateService.java b/server/src/main/java/org/opensearch/cluster/metadata/MetadataIndexTemplateService.java index d15c9d7c9ef4d..8ec6834bb8a15 100644 --- a/server/src/main/java/org/opensearch/cluster/metadata/MetadataIndexTemplateService.java +++ b/server/src/main/java/org/opensearch/cluster/metadata/MetadataIndexTemplateService.java @@ -98,9 +98,9 @@ import java.util.stream.Collectors; import static org.opensearch.cluster.metadata.MetadataCreateDataStreamService.validateTimestampFieldMapping; -import static org.opensearch.cluster.metadata.MetadataCreateIndexService.validateIndexTotalPrimaryShardsPerNodeSetting; import static org.opensearch.cluster.metadata.MetadataCreateIndexService.validateRefreshIntervalSettings; import static org.opensearch.cluster.metadata.MetadataCreateIndexService.validateTranslogFlushIntervalSettingsForCompositeIndex; +import static org.opensearch.cluster.metadata.MetadataUpdateSettingsService.validateIndexTotalPrimaryShardsPerNodeSetting; import static org.opensearch.cluster.service.ClusterManagerTask.CREATE_COMPONENT_TEMPLATE; import static org.opensearch.cluster.service.ClusterManagerTask.CREATE_INDEX_TEMPLATE; import static org.opensearch.cluster.service.ClusterManagerTask.CREATE_INDEX_TEMPLATE_V2; @@ -1670,8 +1670,9 @@ private void validate(String name, @Nullable Settings settings, List ind validateTranslogFlushIntervalSettingsForCompositeIndex(settings, clusterService.getClusterSettings()); validateTranslogDurabilitySettingsInTemplate(settings, clusterService.getClusterSettings()); - // validate index total primary shards per node setting - validateIndexTotalPrimaryShardsPerNodeSetting(settings); + // validate index total primary shards per node setting against the cluster (a template has no + // index-local remote_store flag, so use the cluster-aware overload like the _settings update path). + validateIndexTotalPrimaryShardsPerNodeSetting(settings, clusterService); } if (indexPatterns.stream().anyMatch(Regex::isMatchAllPattern)) { diff --git a/server/src/main/java/org/opensearch/cluster/metadata/MetadataUpdateSettingsService.java b/server/src/main/java/org/opensearch/cluster/metadata/MetadataUpdateSettingsService.java index e57a2635b8a68..d5c0e566eafd5 100644 --- a/server/src/main/java/org/opensearch/cluster/metadata/MetadataUpdateSettingsService.java +++ b/server/src/main/java/org/opensearch/cluster/metadata/MetadataUpdateSettingsService.java @@ -67,6 +67,7 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.HashSet; import java.util.List; import java.util.Locale; @@ -573,13 +574,11 @@ public static void validateIndexTotalPrimaryShardsPerNodeSetting(Settings indexS return; } - // Check if remote store is enabled - boolean isRemoteStoreEnabled = clusterService.state() - .nodes() - .getNodes() - .values() - .stream() - .allMatch(DiscoveryNode::isRemoteStoreNode); + // Remote store is enabled only when there is at least one node and every node is a + // remote-store node (allMatch is vacuously true on an empty node set, which must not + // count as remote-store enabled). + Collection nodes = clusterService.state().nodes().getNodes().values(); + boolean isRemoteStoreEnabled = !nodes.isEmpty() && nodes.stream().allMatch(DiscoveryNode::isRemoteStoreNode); if (!isRemoteStoreEnabled) { throw new IllegalArgumentException( "Setting [" diff --git a/server/src/main/java/org/opensearch/cluster/routing/DelayedAllocationService.java b/server/src/main/java/org/opensearch/cluster/routing/DelayedAllocationService.java index 43368218e900c..73150a3da905f 100644 --- a/server/src/main/java/org/opensearch/cluster/routing/DelayedAllocationService.java +++ b/server/src/main/java/org/opensearch/cluster/routing/DelayedAllocationService.java @@ -44,6 +44,7 @@ import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.inject.Inject; import org.opensearch.common.lifecycle.AbstractLifecycleComponent; +import org.opensearch.common.settings.Settings; import org.opensearch.common.unit.TimeValue; import org.opensearch.common.util.concurrent.AbstractRunnable; import org.opensearch.threadpool.Scheduler; @@ -57,7 +58,7 @@ * if there are unassigned shards with delayed allocation (unassigned shards that have * the delay marker). These are shards that have become unassigned due to a node leaving * and which were assigned the delay marker based on the index delay setting - * {@link UnassignedInfo#INDEX_DELAYED_NODE_LEFT_TIMEOUT_SETTING} + * {@link UnassignedInfo#INDEX_DELAYED_NODE_LEFT_TIMEOUT_SETTING} or the cluster-level delayed node-left timeout * (see {@link AllocationService#disassociateDeadNodes(RoutingAllocation)}. * This class is responsible for choosing the next (closest) delay expiration of a * delayed shard to schedule a reroute to remove the delay marker. @@ -75,6 +76,7 @@ public class DelayedAllocationService extends AbstractLifecycleComponent impleme final ThreadPool threadPool; private final ClusterService clusterService; private final AllocationService allocationService; + private final Settings settings; AtomicReference delayedRerouteTask = new AtomicReference<>(); // package private to access from tests @@ -150,7 +152,8 @@ public DelayedAllocationService(ThreadPool threadPool, ClusterService clusterSer this.threadPool = threadPool; this.clusterService = clusterService; this.allocationService = allocationService; - if (DiscoveryNode.isClusterManagerNode(clusterService.getSettings())) { + this.settings = clusterService.getSettings(); + if (DiscoveryNode.isClusterManagerNode(settings)) { clusterService.addListener(this); } } @@ -197,7 +200,7 @@ private void removeIfSameTask(DelayedRerouteTask expectedTask) { */ private synchronized void scheduleIfNeeded(long currentNanoTime, ClusterState state) { assertClusterOrClusterManagerStateThread(); - long nextDelayNanos = UnassignedInfo.findNextDelayedAllocation(currentNanoTime, state); + long nextDelayNanos = UnassignedInfo.findNextDelayedAllocation(currentNanoTime, state, clusterSettings(state)); if (nextDelayNanos < 0) { logger.trace("no need to schedule reroute - no delayed unassigned shards"); removeTaskAndCancel(); @@ -235,6 +238,10 @@ private synchronized void scheduleIfNeeded(long currentNanoTime, ClusterState st } } + private Settings clusterSettings(ClusterState state) { + return Settings.builder().put(settings).put(state.metadata().settings()).build(); + } + // protected so that it can be overridden (and disabled) by unit tests protected void assertClusterOrClusterManagerStateThread() { assert ClusterService.assertClusterOrClusterManagerStateThread(); diff --git a/server/src/main/java/org/opensearch/cluster/routing/UnassignedInfo.java b/server/src/main/java/org/opensearch/cluster/routing/UnassignedInfo.java index 7bbf76e533d82..47a8d9d28bb6e 100644 --- a/server/src/main/java/org/opensearch/cluster/routing/UnassignedInfo.java +++ b/server/src/main/java/org/opensearch/cluster/routing/UnassignedInfo.java @@ -70,9 +70,18 @@ public final class UnassignedInfo implements ToXContentFragment, Writeable { public static final DateFormatter DATE_TIME_FORMATTER = DateFormatter.forPattern("date_optional_time").withZone(ZoneOffset.UTC); + public static final TimeValue DEFAULT_DELAYED_NODE_LEFT_TIMEOUT = TimeValue.timeValueMinutes(1); + + public static final Setting CLUSTER_DELAYED_NODE_LEFT_TIMEOUT_SETTING = Setting.positiveTimeSetting( + "cluster.routing.allocation.unassigned.node_left.delayed_timeout", + DEFAULT_DELAYED_NODE_LEFT_TIMEOUT, + Property.Dynamic, + Property.NodeScope + ); + public static final Setting INDEX_DELAYED_NODE_LEFT_TIMEOUT_SETTING = Setting.positiveTimeSetting( "index.unassigned.node_left.delayed_timeout", - TimeValue.timeValueMinutes(1), + DEFAULT_DELAYED_NODE_LEFT_TIMEOUT, Property.Dynamic, Property.IndexScope ); @@ -245,7 +254,7 @@ public String value() { private final Reason reason; private final long unassignedTimeMillis; // used for display and log messages, in milliseconds private final long unassignedTimeNanos; // in nanoseconds, used to calculate delay for delayed shard allocation - private final boolean delayed; // if allocation of this shard is delayed due to INDEX_DELAYED_NODE_LEFT_TIMEOUT_SETTING + private final boolean delayed; // if allocation of this shard is delayed due to delayed node-left timeout settings private final String message; private final Exception failure; private final int failedAllocations; @@ -278,7 +287,7 @@ public UnassignedInfo(Reason reason, String message) { * @param failure the shard level failure that caused this shard to be unassigned, if exists. * @param unassignedTimeNanos the time to use as the base for any delayed re-assignment calculation * @param unassignedTimeMillis the time of unassignment used to display to in our reporting. - * @param delayed if allocation of this shard is delayed due to INDEX_DELAYED_NODE_LEFT_TIMEOUT_SETTING. + * @param delayed if allocation of this shard is delayed due to delayed node-left timeout settings. * @param lastAllocationStatus the result of the last allocation attempt for this shard * @param failedNodeIds a set of nodeIds that failed to complete allocations for this shard */ @@ -344,7 +353,7 @@ public int getNumFailedAllocations() { } /** - * Returns true if allocation of this shard is delayed due to {@link #INDEX_DELAYED_NODE_LEFT_TIMEOUT_SETTING} + * Returns true if allocation of this shard is delayed due to delayed node-left timeout settings. */ public boolean isDelayed() { return delayed; @@ -422,14 +431,40 @@ public Set getFailedNodeIds() { } /** - * Calculates the delay left based on current time (in nanoseconds) and the delay defined by the index settings. + * Returns the node-left delayed allocation timeout from the index settings if explicitly configured, + * otherwise returns the cluster-level delayed allocation timeout. + */ + public static TimeValue getNodeLeftDelayedTimeout(final Settings indexSettings, final Settings clusterSettings) { + if (INDEX_DELAYED_NODE_LEFT_TIMEOUT_SETTING.exists(indexSettings)) { + return INDEX_DELAYED_NODE_LEFT_TIMEOUT_SETTING.get(indexSettings); + } + return CLUSTER_DELAYED_NODE_LEFT_TIMEOUT_SETTING.get(clusterSettings); + } + + /** + * Calculates the delay left based on current time (in nanoseconds) and the delay defined by the index settings, + * or the built-in cluster default if the index setting is not set. * Only relevant if shard is effectively delayed (see {@link #isDelayed()}) * Returns 0 if delay is negative * * @return calculated delay in nanoseconds + * @deprecated use {@link #getRemainingDelay(long, Settings, Settings)} with effective cluster settings. */ + @Deprecated public long getRemainingDelay(final long nanoTimeNow, final Settings indexSettings) { - long delayTimeoutNanos = INDEX_DELAYED_NODE_LEFT_TIMEOUT_SETTING.get(indexSettings).nanos(); + return getRemainingDelay(nanoTimeNow, indexSettings, Settings.EMPTY); + } + + /** + * Calculates the delay left based on current time (in nanoseconds) and the effective delay defined by + * the index settings or the cluster-level default. + * Only relevant if shard is effectively delayed (see {@link #isDelayed()}) + * Returns 0 if delay is negative + * + * @return calculated delay in nanoseconds + */ + public long getRemainingDelay(final long nanoTimeNow, final Settings indexSettings, final Settings clusterSettings) { + long delayTimeoutNanos = getNodeLeftDelayedTimeout(indexSettings, clusterSettings).nanos(); assert nanoTimeNow >= unassignedTimeNanos; return Math.max(0L, delayTimeoutNanos - (nanoTimeNow - unassignedTimeNanos)); } @@ -446,8 +481,21 @@ public static int getNumberOfDelayedUnassigned(ClusterState state) { * Finds the next (closest) delay expiration of an delayed shard in nanoseconds based on current time. * Returns 0 if delay is negative. * Returns -1 if no delayed shard is found. + * + * @deprecated use {@link #findNextDelayedAllocation(long, ClusterState, Settings)} with effective cluster settings. */ + @Deprecated public static long findNextDelayedAllocation(long currentNanoTime, ClusterState state) { + return findNextDelayedAllocation(currentNanoTime, state, state.metadata().settings()); + } + + /** + * Finds the next (closest) delay expiration of a delayed shard in nanoseconds based on current time + * and the supplied effective cluster settings. + * Returns 0 if delay is negative. + * Returns -1 if no delayed shard is found. + */ + public static long findNextDelayedAllocation(long currentNanoTime, ClusterState state, Settings clusterSettings) { Metadata metadata = state.metadata(); RoutingTable routingTable = state.routingTable(); long nextDelayNanos = Long.MAX_VALUE; @@ -456,7 +504,7 @@ public static long findNextDelayedAllocation(long currentNanoTime, ClusterState if (unassignedInfo.isDelayed()) { Settings indexSettings = metadata.index(shard.index()).getSettings(); // calculate next time to schedule - final long newComputedLeftDelayNanos = unassignedInfo.getRemainingDelay(currentNanoTime, indexSettings); + final long newComputedLeftDelayNanos = unassignedInfo.getRemainingDelay(currentNanoTime, indexSettings, clusterSettings); if (newComputedLeftDelayNanos < nextDelayNanos) { nextDelayNanos = newComputedLeftDelayNanos; } diff --git a/server/src/main/java/org/opensearch/cluster/routing/allocation/AllocationService.java b/server/src/main/java/org/opensearch/cluster/routing/allocation/AllocationService.java index efe51e36ec748..c1821d72e7a6e 100644 --- a/server/src/main/java/org/opensearch/cluster/routing/allocation/AllocationService.java +++ b/server/src/main/java/org/opensearch/cluster/routing/allocation/AllocationService.java @@ -81,7 +81,6 @@ import static java.util.Collections.emptyList; import static java.util.Collections.singletonList; -import static org.opensearch.cluster.routing.UnassignedInfo.INDEX_DELAYED_NODE_LEFT_TIMEOUT_SETTING; import static org.opensearch.cluster.routing.allocation.ExistingShardsAllocator.EXISTING_SHARDS_ALLOCATOR_BATCH_MODE; /** @@ -177,7 +176,8 @@ public ClusterState applyStartedShards(ClusterState clusterState, List(startedShards); @@ -266,7 +266,8 @@ public ClusterState applyFailedShards( tmpState, clusterInfoService.getClusterInfo(), snapshotsInfoService.snapshotShardSizes(), - currentNanoTime + currentNanoTime, + settings ); for (FailedShard failedShardEntry : failedShards) { @@ -341,7 +342,8 @@ public ClusterState disassociateDeadNodes(ClusterState clusterState, boolean rer clusterState, clusterInfoService.getClusterInfo(), snapshotsInfoService.snapshotShardSizes(), - currentNanoTime() + currentNanoTime(), + settings ); // first, clear from the shards any node id they used to belong to that is now dead @@ -368,7 +370,8 @@ public ClusterState adaptAutoExpandReplicas(ClusterState clusterState) { clusterState, clusterInfoService.getClusterInfo(), snapshotsInfoService.snapshotShardSizes(), - currentNanoTime() + currentNanoTime(), + settings ); final Map> autoExpandReplicaChanges = AutoExpandReplicas.getAutoExpandReplicaChanges( clusterState.metadata(), @@ -440,7 +443,8 @@ private void removeDelayMarkers(RoutingAllocation allocation) { if (unassignedInfo.isDelayed()) { final long newComputedLeftDelayNanos = unassignedInfo.getRemainingDelay( allocation.getCurrentNanoTime(), - metadata.getIndexSafe(shardRouting.index()).getSettings() + metadata.getIndexSafe(shardRouting.index()).getSettings(), + allocation.clusterSettings() ); if (newComputedLeftDelayNanos == 0) { unassignedIterator.updateUnassigned( @@ -524,7 +528,8 @@ public CommandsResult reroute(final ClusterState clusterState, AllocationCommand clusterState, clusterInfoService.getClusterInfo(), snapshotsInfoService.snapshotShardSizes(), - currentNanoTime() + currentNanoTime(), + settings ); // don't short circuit deciders, we want a full explanation allocation.debugDecision(true); @@ -561,7 +566,8 @@ public ClusterState reroute(ClusterState clusterState, String reason) { fixedClusterState, clusterInfoService.getClusterInfo(), snapshotsInfoService.snapshotShardSizes(), - currentNanoTime() + currentNanoTime(), + settings ); reroute(allocation); if (fixedClusterState == clusterState && allocation.routingNodesChanged() == false) { @@ -666,7 +672,8 @@ private void disassociateDeadNodes(RoutingAllocation allocation) { // now, go over all the shards routing on the node, and fail them for (ShardRouting shardRouting : node.copyShards()) { final IndexMetadata indexMetadata = allocation.metadata().getIndexSafe(shardRouting.index()); - boolean delayed = INDEX_DELAYED_NODE_LEFT_TIMEOUT_SETTING.get(indexMetadata.getSettings()).nanos() > 0; + boolean delayed = UnassignedInfo.getNodeLeftDelayedTimeout(indexMetadata.getSettings(), allocation.clusterSettings()) + .nanos() > 0; UnassignedInfo unassignedInfo = new UnassignedInfo( UnassignedInfo.Reason.NODE_LEFT, "node_left [" + node.nodeId() + "]", diff --git a/server/src/main/java/org/opensearch/cluster/routing/allocation/RoutingAllocation.java b/server/src/main/java/org/opensearch/cluster/routing/allocation/RoutingAllocation.java index fd789774f6f4f..cf0384a8443fc 100644 --- a/server/src/main/java/org/opensearch/cluster/routing/allocation/RoutingAllocation.java +++ b/server/src/main/java/org/opensearch/cluster/routing/allocation/RoutingAllocation.java @@ -44,6 +44,7 @@ import org.opensearch.cluster.routing.allocation.decider.AllocationDeciders; import org.opensearch.cluster.routing.allocation.decider.Decision; import org.opensearch.common.annotation.PublicApi; +import org.opensearch.common.settings.Settings; import org.opensearch.core.index.shard.ShardId; import org.opensearch.snapshots.RestoreService.RestoreInProgressUpdater; import org.opensearch.snapshots.SnapshotShardSizeInfo; @@ -73,6 +74,8 @@ public class RoutingAllocation { private final Metadata metadata; + private final Settings clusterSettings; + private final RoutingTable routingTable; private final DiscoveryNodes nodes; @@ -116,10 +119,31 @@ public RoutingAllocation( ClusterInfo clusterInfo, SnapshotShardSizeInfo shardSizeInfo, long currentNanoTime + ) { + this(deciders, routingNodes, clusterState, clusterInfo, shardSizeInfo, currentNanoTime, Settings.EMPTY); + } + + /** + * Creates a new {@link RoutingAllocation} + * @param deciders {@link AllocationDeciders} to used to make decisions for routing allocations + * @param routingNodes Routing nodes in the current cluster + * @param clusterState cluster state before rerouting + * @param currentNanoTime the nano time to use for all delay allocation calculation (typically {@link System#nanoTime()}) + * @param nodeSettings node level settings to use as defaults for cluster scoped settings + */ + public RoutingAllocation( + AllocationDeciders deciders, + RoutingNodes routingNodes, + ClusterState clusterState, + ClusterInfo clusterInfo, + SnapshotShardSizeInfo shardSizeInfo, + long currentNanoTime, + Settings nodeSettings ) { this.deciders = deciders; this.routingNodes = routingNodes; this.metadata = clusterState.metadata(); + this.clusterSettings = Settings.builder().put(nodeSettings).put(metadata.settings()).build(); this.routingTable = clusterState.routingTable(); this.nodes = clusterState.nodes(); this.customs = clusterState.customs(); @@ -168,6 +192,13 @@ public Metadata metadata() { return metadata; } + /** + * Returns cluster scoped settings, including node-level defaults overridden by dynamic cluster-state settings. + */ + public Settings clusterSettings() { + return clusterSettings; + } + /** * Get discovery nodes in current routing * @return discovery nodes diff --git a/server/src/main/java/org/opensearch/cluster/routing/allocation/allocator/BalancedShardsAllocator.java b/server/src/main/java/org/opensearch/cluster/routing/allocation/allocator/BalancedShardsAllocator.java index abddf1b17fce0..bdbbe726c5c6d 100644 --- a/server/src/main/java/org/opensearch/cluster/routing/allocation/allocator/BalancedShardsAllocator.java +++ b/server/src/main/java/org/opensearch/cluster/routing/allocation/allocator/BalancedShardsAllocator.java @@ -59,9 +59,11 @@ import org.opensearch.common.unit.TimeValue; import org.opensearch.core.action.ActionListener; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; @@ -99,6 +101,8 @@ public class BalancedShardsAllocator implements ShardsAllocator { "cluster.routing.allocation.balance.index", 0.55f, 0.0f, + Float.MAX_VALUE, + new IndexBalanceFactorValidator(), Property.Dynamic, Property.NodeScope ); @@ -106,6 +110,8 @@ public class BalancedShardsAllocator implements ShardsAllocator { "cluster.routing.allocation.balance.shard", 0.45f, 0.0f, + Float.MAX_VALUE, + new ShardBalanceFactorValidator(), Property.Dynamic, Property.NodeScope ); @@ -518,6 +524,67 @@ public boolean getPreferPrimaryBalance() { return preferPrimaryShardBalance; } + /** + * Validates that the index balance factor, combined with the shard balance factor, sums to a value greater than zero. + * + * @opensearch.internal + */ + static final class IndexBalanceFactorValidator implements Setting.Validator { + + @Override + public void validate(Float value) {} + + @Override + public void validate(final Float value, final Map, Object> settings) { + final float shardBalance = (Float) settings.get(SHARD_BALANCE_FACTOR_SETTING); + doValidateBalanceFactorSum(value, shardBalance); + } + + @Override + public Iterator> settings() { + final List> settings = Collections.singletonList(SHARD_BALANCE_FACTOR_SETTING); + return settings.iterator(); + } + } + + /** + * Validates that the shard balance factor, combined with the index balance factor, sums to a value greater than zero. + * + * @opensearch.internal + */ + static final class ShardBalanceFactorValidator implements Setting.Validator { + + @Override + public void validate(Float value) {} + + @Override + public void validate(final Float value, final Map, Object> settings) { + final float indexBalance = (Float) settings.get(INDEX_BALANCE_FACTOR_SETTING); + doValidateBalanceFactorSum(indexBalance, value); + } + + @Override + public Iterator> settings() { + final List> settings = Collections.singletonList(INDEX_BALANCE_FACTOR_SETTING); + return settings.iterator(); + } + } + + static void doValidateBalanceFactorSum(float indexBalance, float shardBalance) { + float sum = indexBalance + shardBalance; + if (sum <= 0.0f) { + throw new IllegalArgumentException( + "Balance factors [" + + INDEX_BALANCE_FACTOR_SETTING.getKey() + + "] and [" + + SHARD_BALANCE_FACTOR_SETTING.getKey() + + "] must sum to a value greater than zero but was [" + + sum + + "]" + ); + } + } + /** * This class is the primary weight function used to create balanced over nodes and shards in the cluster. * Currently this function has 3 properties: diff --git a/server/src/main/java/org/opensearch/common/lucene/uid/PerThreadIDVersionAndSeqNoLookup.java b/server/src/main/java/org/opensearch/common/lucene/uid/PerThreadIDVersionAndSeqNoLookup.java index 60d1f9e41ea2c..a8fb16d7ae6b2 100644 --- a/server/src/main/java/org/opensearch/common/lucene/uid/PerThreadIDVersionAndSeqNoLookup.java +++ b/server/src/main/java/org/opensearch/common/lucene/uid/PerThreadIDVersionAndSeqNoLookup.java @@ -78,33 +78,53 @@ final class PerThreadIDVersionAndSeqNoLookup { /** used for assertions to make sure class usage meets assumptions */ private final Object readerKey; + /** Controls which fields a lookup requires at construction. */ + enum LookupMode { + /** Requires {@code _version} doc values and validates the no-op-segment invariant. */ + FULL, + /** Resolves doc id only; tolerates a missing {@code _version} field and segments without {@code _id} terms. */ + DOC_ID_ONLY + } + + /** Initialize a {@link LookupMode#FULL} lookup for the segment. */ + PerThreadIDVersionAndSeqNoLookup(LeafReader reader, String uidField) throws IOException { + this(reader, uidField, LookupMode.FULL); + } + /** - * Initialize lookup for the provided segment + * Initialize a lookup for the segment. + * + * @param mode {@link LookupMode#DOC_ID_ONLY} skips the {@code _version} and no-op-segment checks + * and supports only {@link #getDocID}; {@link #lookupVersion}/{@link #lookupSeqNo} + * require {@link LookupMode#FULL}. */ - PerThreadIDVersionAndSeqNoLookup(LeafReader reader, String uidField) throws IOException { + PerThreadIDVersionAndSeqNoLookup(LeafReader reader, String uidField, LookupMode mode) throws IOException { this.uidField = uidField; final Terms terms = reader.terms(uidField); if (terms == null) { - // If a segment contains only no-ops, it does not have _uid but has both _soft_deletes and _tombstone fields. - final NumericDocValues softDeletesDV = reader.getNumericDocValues(Lucene.SOFT_DELETES_FIELD); - final NumericDocValues tombstoneDV = reader.getNumericDocValues(SeqNoFieldMapper.TOMBSTONE_NAME); - // this is a special case when we pruned away all IDs in a segment since all docs are deleted. - final boolean allDocsDeleted = (softDeletesDV != null && reader.numDocs() == 0); - if ((softDeletesDV == null || tombstoneDV == null) && allDocsDeleted == false) { - throw new IllegalArgumentException( - "reader does not have _uid terms but not a no-op segment; " - + "_soft_deletes [" - + softDeletesDV - + "], _tombstone [" - + tombstoneDV - + "]" - ); + if (mode == LookupMode.FULL) { + // If a segment contains only no-ops, it does not have _uid but has both _soft_deletes and _tombstone fields. + final NumericDocValues softDeletesDV = reader.getNumericDocValues(Lucene.SOFT_DELETES_FIELD); + final NumericDocValues tombstoneDV = reader.getNumericDocValues(SeqNoFieldMapper.TOMBSTONE_NAME); + // this is a special case when we pruned away all IDs in a segment since all docs are deleted. + final boolean allDocsDeleted = (softDeletesDV != null && reader.numDocs() == 0); + if ((softDeletesDV == null || tombstoneDV == null) && allDocsDeleted == false) { + throw new IllegalArgumentException( + "reader does not have _uid terms but not a no-op segment; " + + "_soft_deletes [" + + softDeletesDV + + "], _tombstone [" + + tombstoneDV + + "]" + ); + } } termsEnum = null; } else { termsEnum = terms.iterator(); } - if (reader.getNumericDocValues(VersionFieldMapper.NAME) == null) { + // _version doc values are required for version lookups but not for doc-id-only lookups. + if (mode == LookupMode.FULL && reader.getNumericDocValues(VersionFieldMapper.NAME) == null) { throw new IllegalArgumentException("reader misses the [" + VersionFieldMapper.NAME + "] field; _uid terms [" + terms + "]"); } Object readerKey = null; @@ -144,7 +164,7 @@ public DocIdAndVersion lookupVersion(BytesRef id, boolean loadSeqNo, LeafReaderC * returns the internal lucene doc id for the given id bytes. * {@link DocIdSetIterator#NO_MORE_DOCS} is returned if not found * */ - private int getDocID(BytesRef id, LeafReaderContext context) throws IOException { + int getDocID(BytesRef id, LeafReaderContext context) throws IOException { // termsEnum can possibly be null here if this leaf contains only no-ops. if (termsEnum != null && termsEnum.seekExact(id)) { final Bits liveDocs = context.reader().getLiveDocs(); diff --git a/server/src/main/java/org/opensearch/common/lucene/uid/VersionsAndSeqNoResolver.java b/server/src/main/java/org/opensearch/common/lucene/uid/VersionsAndSeqNoResolver.java index 11b1524dc2a15..d1af74fa237e7 100644 --- a/server/src/main/java/org/opensearch/common/lucene/uid/VersionsAndSeqNoResolver.java +++ b/server/src/main/java/org/opensearch/common/lucene/uid/VersionsAndSeqNoResolver.java @@ -38,10 +38,12 @@ import org.apache.lucene.index.LeafReaderContext; import org.apache.lucene.index.SegmentReader; import org.apache.lucene.index.Term; +import org.apache.lucene.search.DocIdSetIterator; import org.apache.lucene.util.CloseableThreadLocal; import org.opensearch.common.annotation.PublicApi; import org.opensearch.common.util.concurrent.ConcurrentCollections; import org.opensearch.index.codec.CriteriaBasedCodec; +import org.opensearch.index.seqno.SequenceNumbers; import java.io.IOException; import java.util.List; @@ -67,11 +69,22 @@ public final class VersionsAndSeqNoResolver { }; private static PerThreadIDVersionAndSeqNoLookup[] getLookupState(IndexReader reader, String uidField) throws IOException { + return getLookupState(reader, uidField, PerThreadIDVersionAndSeqNoLookup.LookupMode.FULL); + } + + private static PerThreadIDVersionAndSeqNoLookup[] getLookupState( + IndexReader reader, + String uidField, + PerThreadIDVersionAndSeqNoLookup.LookupMode mode + ) throws IOException { // We cache on the top level // This means cache entries have a shorter lifetime, maybe as low as 1s with the // default refresh interval and a steady indexing rate, but on the other hand it // proved to be cheaper than having to perform a CHM and a TL get for every segment. // See https://github.com/elastic/elasticsearch/pull/19856. + // Note: a given reader is only ever queried in one mode — InternalEngine readers go through + // the strict path (LookupMode.FULL); the composite secondary reader is only used by + // loadDocId (LookupMode.DOC_ID_ONLY) — so a cache entry's build mode always matches its caller. IndexReader.CacheHelper cacheHelper = reader.getReaderCacheHelper(); CloseableThreadLocal ctl = lookupStates.get(cacheHelper.getKey()); if (ctl == null) { @@ -91,7 +104,7 @@ private static PerThreadIDVersionAndSeqNoLookup[] getLookupState(IndexReader rea if (lookupState == null) { lookupState = new PerThreadIDVersionAndSeqNoLookup[reader.leaves().size()]; for (LeafReaderContext leaf : reader.leaves()) { - lookupState[leaf.ord] = new PerThreadIDVersionAndSeqNoLookup(leaf.reader(), uidField); + lookupState[leaf.ord] = new PerThreadIDVersionAndSeqNoLookup(leaf.reader(), uidField, mode); } ctl.set(lookupState); } @@ -206,4 +219,28 @@ public static DocIdAndSeqNo loadDocIdAndSeqNo(IndexReader reader, Term term) thr } return null; } + + /** + * Resolves the live doc id for a uid (no {@code _version}/{@code _seq_no} needed), returning the + * leaf and doc id, or null if not found. The returned {@link DocIdAndSeqNo} has + * {@code seqNo = }{@link SequenceNumbers#UNASSIGNED_SEQ_NO} — use only {@code docId} and {@code context}. + */ + public static DocIdAndSeqNo loadDocId(IndexReader reader, Term term) throws IOException { + final PerThreadIDVersionAndSeqNoLookup[] lookups = getLookupState( + reader, + term.field(), + PerThreadIDVersionAndSeqNoLookup.LookupMode.DOC_ID_ONLY + ); + final List leaves = reader.leaves(); + // iterate backwards to optimize for the frequently updated documents + // which are likely to be in the last segments + for (int i = leaves.size() - 1; i >= 0; i--) { + final LeafReaderContext leaf = leaves.get(i); + final int docId = lookups[leaf.ord].getDocID(term.bytes(), leaf); + if (docId != DocIdSetIterator.NO_MORE_DOCS) { + return new DocIdAndSeqNo(docId, SequenceNumbers.UNASSIGNED_SEQ_NO, leaf); + } + } + return null; + } } diff --git a/server/src/main/java/org/opensearch/common/settings/ClusterSettings.java b/server/src/main/java/org/opensearch/common/settings/ClusterSettings.java index 6f8547c1eecdb..66e011f82fe13 100644 --- a/server/src/main/java/org/opensearch/common/settings/ClusterSettings.java +++ b/server/src/main/java/org/opensearch/common/settings/ClusterSettings.java @@ -39,6 +39,7 @@ import org.opensearch.action.search.SearchRequestStats; import org.opensearch.action.search.StreamSearchTransportService; import org.opensearch.action.search.TransportSearchAction; +import org.opensearch.action.search.pruning.SearchIndexPruningSettings; import org.opensearch.action.support.AutoCreateIndex; import org.opensearch.action.support.DestructiveOperations; import org.opensearch.action.support.replication.TransportReplicationAction; @@ -63,6 +64,7 @@ import org.opensearch.cluster.metadata.IndexGraveyard; import org.opensearch.cluster.metadata.Metadata; import org.opensearch.cluster.routing.OperationRouting; +import org.opensearch.cluster.routing.UnassignedInfo; import org.opensearch.cluster.routing.allocation.AwarenessReplicaBalance; import org.opensearch.cluster.routing.allocation.DiskThresholdSettings; import org.opensearch.cluster.routing.allocation.ExistingShardsAllocator; @@ -300,6 +302,7 @@ public void apply(Settings value, Settings current, Settings previous) { DanglingIndicesState.AUTO_IMPORT_DANGLING_INDICES_SETTING, EnableAllocationDecider.CLUSTER_ROUTING_ALLOCATION_ENABLE_SETTING, EnableAllocationDecider.CLUSTER_ROUTING_REBALANCE_ENABLE_SETTING, + UnassignedInfo.CLUSTER_DELAYED_NODE_LEFT_TIMEOUT_SETTING, ExistingShardsAllocator.EXISTING_SHARDS_ALLOCATOR_BATCH_MODE, FilterAllocationDecider.CLUSTER_ROUTING_INCLUDE_GROUP_SETTING, FilterAllocationDecider.CLUSTER_ROUTING_EXCLUDE_GROUP_SETTING, @@ -643,6 +646,7 @@ public void apply(Settings value, Settings current, Settings previous) { BootstrapSettings.MEMORY_LOCK_SETTING, BootstrapSettings.SYSTEM_CALL_FILTER_SETTING, BootstrapSettings.CTRLHANDLER_SETTING, + BootstrapSettings.SERIAL_FILTER_SETTING, KeyStoreWrapper.SEED_SETTING, IndexingMemoryController.INDEX_BUFFER_SIZE_SETTING, IndexingMemoryController.MIN_INDEX_BUFFER_SIZE_SETTING, @@ -779,6 +783,7 @@ public void apply(Settings value, Settings current, Settings previous) { TieringUtils.JVM_USAGE_TIERING_THRESHOLD_PERCENT, TieringUtils.FILECACHE_ACTIVE_USAGE_TIERING_THRESHOLD_PERCENT, TieringUtils.PREPARE_TIERING_TIMEOUT, + TieringUtils.REPLICA_SYNC_TIMEOUT_SETTING, // Settings related to Remote Refresh Segment Pressure RemoteStorePressureSettings.REMOTE_REFRESH_SEGMENT_PRESSURE_ENABLED, @@ -837,6 +842,7 @@ public void apply(Settings value, Settings current, Settings previous) { NativeMemoryBasedAdmissionControllerSettings.SEARCH_NATIVE_MEMORY_USAGE_LIMIT, NativeMemoryBasedAdmissionControllerSettings.INDEXING_NATIVE_MEMORY_USAGE_LIMIT, NativeMemoryBasedAdmissionControllerSettings.CLUSTER_ADMIN_NATIVE_MEMORY_USAGE_LIMIT, + NativeMemoryBasedAdmissionControllerSettings.INDEXING_NATIVE_MEMORY_POOL_USAGE_LIMIT, // Concurrent segment search settings SearchService.CLUSTER_CONCURRENT_SEGMENT_SEARCH_SETTING, // deprecated @@ -928,7 +934,10 @@ public void apply(Settings value, Settings current, Settings previous) { StreamTransportService.STREAM_TRANSPORT_REQ_TIMEOUT_SETTING, StreamSearchTransportService.STREAM_SEARCH_ENABLED, TieredStoragePrefetchSettings.READ_AHEAD_BLOCK_COUNT, - TieredStoragePrefetchSettings.STORED_FIELDS_PREFETCH_ENABLED_SETTING + TieredStoragePrefetchSettings.STORED_FIELDS_PREFETCH_ENABLED_SETTING, + SearchIndexPruningSettings.ENABLED, + SearchIndexPruningSettings.MIN_SHARDS, + SearchIndexPruningSettings.FIELDS ) ) ); diff --git a/server/src/main/java/org/opensearch/common/util/FeatureFlags.java b/server/src/main/java/org/opensearch/common/util/FeatureFlags.java index 2a53dc05f6559..a37814f2f848d 100644 --- a/server/src/main/java/org/opensearch/common/util/FeatureFlags.java +++ b/server/src/main/java/org/opensearch/common/util/FeatureFlags.java @@ -127,6 +127,16 @@ public class FeatureFlags { public static final String STREAM_TRANSPORT = FEATURE_FLAG_PREFIX + "transport.stream.enabled"; public static final Setting STREAM_TRANSPORT_SETTING = Setting.boolSetting(STREAM_TRANSPORT, false, Property.NodeScope); + /** + * Gates the functionality of Liquid Cache for byte-level Parquet caching. + */ + public static final String LIQUID_CACHE_EXPERIMENTAL_FLAG = FEATURE_FLAG_PREFIX + "liquid_cache.enabled"; + public static final Setting LIQUID_CACHE_EXPERIMENTAL_SETTING = Setting.boolSetting( + LIQUID_CACHE_EXPERIMENTAL_FLAG, + false, + Property.NodeScope + ); + /** * Underlying implementation for feature flags. * All settable feature flags are tracked here in FeatureFlagsImpl.featureFlags. @@ -153,6 +163,7 @@ static class FeatureFlagsImpl { put(STREAM_TRANSPORT_SETTING, STREAM_TRANSPORT_SETTING.getDefault(Settings.EMPTY)); put(CONTEXT_AWARE_MIGRATION_EXPERIMENTAL_SETTING, CONTEXT_AWARE_MIGRATION_EXPERIMENTAL_SETTING.getDefault(Settings.EMPTY)); put(PLUGGABLE_DATAFORMAT_EXPERIMENTAL_SETTING, PLUGGABLE_DATAFORMAT_EXPERIMENTAL_SETTING.getDefault(Settings.EMPTY)); + put(LIQUID_CACHE_EXPERIMENTAL_SETTING, LIQUID_CACHE_EXPERIMENTAL_SETTING.getDefault(Settings.EMPTY)); } }; diff --git a/server/src/main/java/org/opensearch/gateway/ReplicaShardAllocator.java b/server/src/main/java/org/opensearch/gateway/ReplicaShardAllocator.java index 2844a378df571..34cff42a9a489 100644 --- a/server/src/main/java/org/opensearch/gateway/ReplicaShardAllocator.java +++ b/server/src/main/java/org/opensearch/gateway/ReplicaShardAllocator.java @@ -64,8 +64,6 @@ import java.util.Set; import java.util.stream.Collectors; -import static org.opensearch.cluster.routing.UnassignedInfo.INDEX_DELAYED_NODE_LEFT_TIMEOUT_SETTING; - /** * Allocates replica shards * @@ -338,8 +336,13 @@ protected AllocateUnassignedDecision getAllocationDecision( UnassignedInfo unassignedInfo = unassignedShard.unassignedInfo(); Metadata metadata = allocation.metadata(); IndexMetadata indexMetadata = metadata.index(unassignedShard.index()); - totalDelayMillis = INDEX_DELAYED_NODE_LEFT_TIMEOUT_SETTING.get(indexMetadata.getSettings()).getMillis(); - long remainingDelayNanos = unassignedInfo.getRemainingDelay(System.nanoTime(), indexMetadata.getSettings()); + totalDelayMillis = UnassignedInfo.getNodeLeftDelayedTimeout(indexMetadata.getSettings(), allocation.clusterSettings()) + .getMillis(); + long remainingDelayNanos = unassignedInfo.getRemainingDelay( + System.nanoTime(), + indexMetadata.getSettings(), + allocation.clusterSettings() + ); remainingDelayMillis = TimeValue.timeValueNanos(remainingDelayNanos).millis(); } return AllocateUnassignedDecision.delayed(remainingDelayMillis, totalDelayMillis, nodeDecisions); diff --git a/server/src/main/java/org/opensearch/index/IndexSettings.java b/server/src/main/java/org/opensearch/index/IndexSettings.java index 08d717ec20ef4..4f761d7d755a9 100644 --- a/server/src/main/java/org/opensearch/index/IndexSettings.java +++ b/server/src/main/java/org/opensearch/index/IndexSettings.java @@ -2051,15 +2051,17 @@ public MergePolicy getMergePolicy(boolean isTimeSeriesIndex) { IndexMergePolicy nodeMergePolicy = IndexMergePolicy.fromString(nodeScopedTimeSeriesIndexPolicy); switch (nodeMergePolicy) { case TIERED: - case DEFAULT_POLICY: mergePolicyProvider = tieredMergePolicyProvider; break; case LOG_BYTE_SIZE: mergePolicyProvider = logByteSizeMergePolicyProvider; break; + case DEFAULT_POLICY: + mergePolicyProvider = defaultMergePolicyProvider(); + break; } } else { - mergePolicyProvider = tieredMergePolicyProvider; + mergePolicyProvider = defaultMergePolicyProvider(); } break; } @@ -2071,6 +2073,14 @@ public MergePolicy getMergePolicy(boolean isTimeSeriesIndex) { return mergePolicyProvider.getMergePolicy(); } + /** + * Composite engine indexes default to {@link LogByteSizeMergePolicyProvider}; + * all other indexes default to {@link TieredMergePolicyProvider}. + */ + private MergePolicyProvider defaultMergePolicyProvider() { + return isPluggableDataFormatEnabled() ? logByteSizeMergePolicyProvider : tieredMergePolicyProvider; + } + public T getValue(Setting setting) { return scopedSettings.get(setting); } diff --git a/server/src/main/java/org/opensearch/index/LogByteSizeMergePolicyProvider.java b/server/src/main/java/org/opensearch/index/LogByteSizeMergePolicyProvider.java index 1b44f910ba51b..c69ac1fbd2e90 100644 --- a/server/src/main/java/org/opensearch/index/LogByteSizeMergePolicyProvider.java +++ b/server/src/main/java/org/opensearch/index/LogByteSizeMergePolicyProvider.java @@ -23,9 +23,8 @@ *

    * The LogByteSizeMergePolicy is an alternative merge policy primarily used here to optimize the merging of segments in scenarios * with index with timestamps. - * While the TieredMergePolicy is the default choice, the LogByteSizeMergePolicy can be configured + * For composite engine, we have LogByteSizeMergePolicy as the default choice while the TieredMergePolicy is the default choice for non-composite engine indexes, the LogByteSizeMergePolicy can be configured * as the default merge policy for time-index data using the index.datastream_merge.policy setting. - * *

    * Unlike the TieredMergePolicy, which prioritizes merging segments of equal sizes, the LogByteSizeMergePolicy * specializes in merging adjacent segments efficiently. diff --git a/server/src/main/java/org/opensearch/index/codec/composite/LuceneDocValuesConsumerFactory.java b/server/src/main/java/org/opensearch/index/codec/composite/LuceneDocValuesConsumerFactory.java index 6d79275606d24..90d7af6514853 100644 --- a/server/src/main/java/org/opensearch/index/codec/composite/LuceneDocValuesConsumerFactory.java +++ b/server/src/main/java/org/opensearch/index/codec/composite/LuceneDocValuesConsumerFactory.java @@ -11,6 +11,7 @@ import org.apache.lucene.codecs.DocValuesConsumer; import org.apache.lucene.codecs.lucene90.Lucene90DocValuesConsumerWrapper; import org.apache.lucene.index.SegmentWriteState; +import org.opensearch.index.codec.composite.composite912.Composite912DocValuesFormat; import java.io.IOException; @@ -58,7 +59,9 @@ public static DocValuesConsumer getDocValuesConsumerForCompositeCodec( dataCodec, dataExtension, metaCodec, - metaExtension + metaExtension, + Composite912DocValuesFormat.SKIP_INDEX_DOC_VALUES_CODEC, + Composite912DocValuesFormat.SKIP_INDEX_DOC_VALUES_EXTENSION ); return lucene90DocValuesConsumerWrapper.getLucene90DocValuesConsumer(); diff --git a/server/src/main/java/org/opensearch/index/codec/composite/LuceneDocValuesProducerFactory.java b/server/src/main/java/org/opensearch/index/codec/composite/LuceneDocValuesProducerFactory.java index 80555597fd48a..33ae40fe7fd28 100644 --- a/server/src/main/java/org/opensearch/index/codec/composite/LuceneDocValuesProducerFactory.java +++ b/server/src/main/java/org/opensearch/index/codec/composite/LuceneDocValuesProducerFactory.java @@ -12,6 +12,7 @@ import org.apache.lucene.codecs.lucene90.Lucene90DocValuesProducerWrapper; import org.apache.lucene.index.SegmentReadState; import org.opensearch.index.codec.composite.composite912.Composite912Codec; +import org.opensearch.index.codec.composite.composite912.Composite912DocValuesFormat; import java.io.IOException; @@ -45,7 +46,9 @@ public static DocValuesProducer getDocValuesProducerForCompositeCodec( dataCodec, dataExtension, metaCodec, - metaExtension + metaExtension, + Composite912DocValuesFormat.SKIP_INDEX_DOC_VALUES_CODEC, + Composite912DocValuesFormat.SKIP_INDEX_DOC_VALUES_EXTENSION ); return lucene90DocValuesProducerWrapper.getLucene90DocValuesProducer(); default: diff --git a/server/src/main/java/org/opensearch/index/codec/composite/composite912/Composite912DocValuesFormat.java b/server/src/main/java/org/opensearch/index/codec/composite/composite912/Composite912DocValuesFormat.java index 3a8e748e5a9e6..a62e8117202d7 100644 --- a/server/src/main/java/org/opensearch/index/codec/composite/composite912/Composite912DocValuesFormat.java +++ b/server/src/main/java/org/opensearch/index/codec/composite/composite912/Composite912DocValuesFormat.java @@ -61,6 +61,12 @@ public class Composite912DocValuesFormat extends DocValuesFormat { /** Filename extension for the composite index meta doc values */ public static final String META_DOC_VALUES_EXTENSION = "cidvm"; + /** Skip index doc values codec name for Composite Doc Values Format */ + public static final String SKIP_INDEX_DOC_VALUES_CODEC = "Composite912DocValuesSkipIndex"; + + /** Filename extension for the composite index skip index doc values */ + public static final String SKIP_INDEX_DOC_VALUES_EXTENSION = "cidvs"; + /** Initial version for the Composite90DocValuesFormat */ public static final int VERSION_START = 0; diff --git a/server/src/main/java/org/opensearch/index/engine/DataFormatAwareEngine.java b/server/src/main/java/org/opensearch/index/engine/DataFormatAwareEngine.java index b18c4242a5ff8..7ec6a578cfe5d 100644 --- a/server/src/main/java/org/opensearch/index/engine/DataFormatAwareEngine.java +++ b/server/src/main/java/org/opensearch/index/engine/DataFormatAwareEngine.java @@ -14,6 +14,7 @@ import org.apache.lucene.index.Term; import org.apache.lucene.search.ReferenceManager; import org.apache.lucene.store.AlreadyClosedException; +import org.apache.lucene.util.BytesRef; import org.opensearch.common.Booleans; import org.opensearch.common.Nullable; import org.opensearch.common.SetOnce; @@ -23,6 +24,8 @@ import org.opensearch.common.lease.Releasable; import org.opensearch.common.logging.Loggers; import org.opensearch.common.lucene.Lucene; +import org.opensearch.common.lucene.uid.Versions; +import org.opensearch.common.metrics.CounterMetric; import org.opensearch.common.queue.DefaultLockableHolder; import org.opensearch.common.queue.LockablePool; import org.opensearch.common.unit.TimeValue; @@ -55,6 +58,8 @@ import org.opensearch.index.engine.dataformat.merge.OneMerge; import org.opensearch.index.engine.exec.CatalogSnapshotLifecycleListener; import org.opensearch.index.engine.exec.CombinedCatalogSnapshotDeletionPolicy; +import org.opensearch.index.engine.exec.DocumentLookupSupport; +import org.opensearch.index.engine.exec.DocumentMetadataResolver; import org.opensearch.index.engine.exec.EngineReaderManager; import org.opensearch.index.engine.exec.FileDeleter; import org.opensearch.index.engine.exec.FilesListener; @@ -68,6 +73,7 @@ import org.opensearch.index.engine.exec.coord.CatalogSnapshot; import org.opensearch.index.engine.exec.coord.CatalogSnapshotManager; import org.opensearch.index.engine.exec.coord.DataformatAwareCatalogSnapshot; +import org.opensearch.index.get.DocumentLookupResult; import org.opensearch.index.mapper.DocumentMapperForType; import org.opensearch.index.mapper.IdFieldMapper; import org.opensearch.index.mapper.ParsedDocument; @@ -80,6 +86,7 @@ import org.opensearch.index.seqno.SeqNoStats; import org.opensearch.index.seqno.SequenceNumbers; import org.opensearch.index.shard.DocsStats; +import org.opensearch.index.shard.RemoteStoreRefreshListener; import org.opensearch.index.store.Store; import org.opensearch.index.translog.DefaultTranslogDeletionPolicy; import org.opensearch.index.translog.InternalTranslogManager; @@ -91,6 +98,7 @@ import org.opensearch.index.translog.TranslogOperationHelper; import org.opensearch.index.translog.listener.TranslogEventListener; import org.opensearch.indices.pollingingest.PollingIngestStats; +import org.opensearch.plugins.DocumentLookupProvider; import org.opensearch.search.suggest.completion.CompletionStats; import java.io.Closeable; @@ -146,6 +154,8 @@ public class DataFormatAwareEngine implements Indexer { private final IndexingExecutionEngine indexingExecutionEngine; private final IndexingStrategyPlanner indexingStrategyPlanner; + private final DocumentCountTracker documentCountTracker; + private final AtomicLong pendingRowCount = new AtomicLong(); private final LockablePool>> writerPool; private final AtomicLong writerGenerationCounter; @@ -163,10 +173,19 @@ public class DataFormatAwareEngine implements Indexer { private final LocalCheckpointTracker localCheckpointTracker; private final AtomicLong maxSeqNoOfUpdatesOrDeletes; + // Wall-clock time (ms) of the last version-map delete-tombstone prune; used to throttle maybePruneDeletes(). + protected volatile long lastDeleteVersionPruneTimeMSec; + // Throttling private final IndexingThrottler throttle; private final AtomicInteger throttleRequestCount = new AtomicInteger(); + @Nullable + private final DocumentLookupProvider documentLookupProvider; + private final DocumentMetadataResolver documentMetadataResolver; + // Shared get-by-id flow (parquet lookup + read version-conflict checks), common to all DataFormatAware* engines. + private final DocumentLookupSupport documentLookup; + // Timestamps and seq-no markers private final AtomicLong maxUnsafeAutoIdTimestamp = new AtomicLong(-1); private final AtomicLong maxSeenAutoIdTimestamp = new AtomicLong(-1); @@ -219,6 +238,9 @@ public class DataFormatAwareEngine implements Indexer { // Latch for the current refresh cycle — decremented by any thread that flushes a writer. // Refresh thread awaits this before proceeding with catalog commit. private volatile CountDownLatch activeFlushLatch; + private final LiveVersionMap versionMap; + private final CounterMetric numVersionLookups = new CounterMetric(); + private final CounterMetric numIndexVersionsLookups = new CounterMetric(); /** * System property to enable or disable pluggable dataformat merge operations. @@ -258,8 +280,15 @@ public DataFormatAwareEngine(EngineConfig engineConfig) { this.shardId = engineConfig.getShardId(); this.store = engineConfig.getStore(); this.throttle = new IndexingThrottler(); + this.documentLookupProvider = engineConfig.getDocumentLookupProvider(); + this.documentMetadataResolver = engineConfig.getDocumentMetadataResolver() != null + ? engineConfig.getDocumentMetadataResolver() + : DocumentMetadataResolver.NOOP; + this.documentLookup = new DocumentLookupSupport(shardId, this.documentLookupProvider, this.documentMetadataResolver); + this.versionMap = new LiveVersionMap(); List refreshListeners = new ArrayList<>(); + refreshListeners.add(versionMap); if (engineConfig.getInternalRefreshListener() != null) { refreshListeners.addAll(engineConfig.getInternalRefreshListener()); } @@ -313,6 +342,7 @@ public DataFormatAwareEngine(EngineConfig engineConfig) { // 4. Initialize local checkpoint tracker this.localCheckpointTracker = createLocalCheckpointTracker(LocalCheckpointTracker::new); + this.lastDeleteVersionPruneTimeMSec = engineConfig.getThreadPool().relativeTimeInMillis(); maxSeqNoOfUpdatesOrDeletes = new AtomicLong( SequenceNumbers.max(localCheckpointTracker.getMaxSeqNo(), translogManager.getMaxSeqNo()) ); @@ -405,18 +435,27 @@ public DataFormatAwareEngine(EngineConfig engineConfig) { } }, logger); this.refreshListeners.add(this.statsCache); + this.documentCountTracker = new DocumentCountTracker(shardId, () -> { + // First get active writes as active writes are only reduced after catalog snapshot refresh + // This prevents under-accounting + long docsIngested = pendingRowCount.get(); + try (var cs = catalogSnapshotManager.acquireSnapshot()) { + docsIngested += cs.get().getNumDocs(); + } + return docsIngested; + }, indexingExecutionEngine.maxIndexableDocs()); this.indexingStrategyPlanner = new IndexingStrategyPlanner( engineConfig.getIndexSettings(), engineConfig.getShardId(), - new LiveVersionMap(), + this.versionMap, maxUnsafeAutoIdTimestamp::get, () -> 0L, localCheckpointTracker::getProcessedCheckpoint, this::hasBeenProcessedBefore, op -> OpVsEngineDocStatus.OP_NEWER, - (a, b) -> null, + this::resolveDocVersion, this::updateAutoIdTimestamp, - (a, b) -> null + documentCountTracker::tryAcquireInFlightDocs ); // All critical engine components must be initialized before the engine is considered ready assert translogManager != null : "translog manager must be initialized"; @@ -444,6 +483,10 @@ public DataFormatAwareEngine(EngineConfig engineConfig) { return gen; } ); + + // Restore version map and checkpoint tracker after recovery. + restoreVersionMapAndCheckpointTracker(); + // Merge failure cleanup: cleans up unreferenced files and acts as a safety net // for refreshLock. The preMergeCommitHook acquires refreshLock on the merge thread; // if the merge fails before applyMergeChanges can release it, this callback ensures @@ -574,9 +617,13 @@ public Engine.IndexResult index(Engine.Index index) throws IOException { || index.origin() == Engine.Operation.Origin.LOCAL_RESET) : "DataFormatAwareEngine only supports PRIMARY, LOCAL_TRANSLOG_RECOVERY, or LOCAL_RESET origins but got: " + index.origin(); final boolean doThrottle = index.origin().isRecovery() == false; - try (ReleasableLock ignored = readLock.acquire()) { + int rows = 0; + try (ReleasableLock releasableLock = readLock.acquire()) { ensureOpen(); - try (Releasable indexThrottle = doThrottle ? throttle.acquireThrottle() : () -> {}) { + try ( + Releasable ignored = versionMap.acquireLock(index.uid().bytes()); + Releasable indexThrottle = doThrottle ? throttle.acquireThrottle() : () -> {} + ) { lastWriteNanos = index.startTime(); final IndexingStrategy plan; if (index.origin() == Engine.Operation.Origin.PRIMARY) { @@ -584,6 +631,7 @@ public Engine.IndexResult index(Engine.Index index) throws IOException { } else { plan = indexingStrategyPlanner.planOperationAsNonPrimary(index); } + rows = plan.reservedDocs; final Engine.IndexResult indexResult; if (plan.earlyResultOnPreFlightError.isPresent()) { assert index.origin() == Engine.Operation.Origin.PRIMARY : index.origin(); @@ -632,6 +680,10 @@ public Engine.IndexResult index(Engine.Index index) throws IOException { } } return indexResult; + } finally { + if (rows > 0) { + documentCountTracker.releaseInFlightDocs(rows); + } } } catch (RuntimeException | IOException e) { maybeFailEngine("index id[" + index.id() + "] origin[" + index.origin() + "]", e); @@ -672,6 +724,7 @@ private Engine.IndexResult indexIntoEngine(Engine.Index index, IndexingStrategy + "] must match operation seq no [" + index.seqNo() + "]"; + pendingRowCount.incrementAndGet(); } else { WriteResult.Failure f = (WriteResult.Failure) result; try { @@ -707,6 +760,10 @@ private Engine.IndexResult indexIntoEngine(Engine.Index index, IndexingStrategy final Translog.Location location; if (indexResult.getResultType() == Engine.Result.Type.SUCCESS) { location = translogManager.add(new Translog.Index(index, indexResult)); + versionMap.maybePutIndexUnderLock( + index.uid().bytes(), + new IndexVersionValue(location, indexResult.getVersion(), index.seqNo(), index.primaryTerm()) + ); } else if (indexResult.getSeqNo() != UNASSIGNED_SEQ_NO && indexResult.getFailure() != null && !(indexResult.getFailure() instanceof AppendOnlyIndexOperationRetryException)) { @@ -888,6 +945,9 @@ public void refresh(String source) throws EngineException { ensureOpen(); ensureNoTragicException(); refreshLock.lock(); + + // refresh only if new segments have been created or force param is true + notifyRefreshListenersBefore(); try (GatedCloseable catalogSnapshot = catalogSnapshotManager.acquireSnapshot()) { if (store.tryIncRef()) { try { @@ -897,6 +957,7 @@ public void refresh(String source) throws EngineException { final long flushAllStartNanos = System.nanoTime(); int writerCount = writers.size(); + long rowsToRelease = 0L; // Add all checked-out writers to the shared flushQueue with a latch // so the refresh thread can wait for ALL writers to be flushed @@ -936,7 +997,9 @@ public void refresh(String source) throws EngineException { hasFiles ); if (hasFiles) { - newSegments.add(segmentBuilder.build()); + Segment segment = segmentBuilder.build(); + newSegments.add(segment); + rowsToRelease += segment.dfGroupedSearchableFiles().values().stream().findFirst().get().numRows(); } refreshed |= hasFiles; } catch (Exception e) { @@ -970,6 +1033,7 @@ public void refresh(String source) throws EngineException { Segment pendingSeg; while ((pendingSeg = pendingSegments.poll()) != null) { newSegments.add(pendingSeg); + rowsToRelease += pendingSeg.dfGroupedSearchableFiles().values().stream().findFirst().get().numRows(); refreshed = true; } // Drain pending writers so they get closed after addIndexes incorporates their files @@ -997,8 +1061,6 @@ public void refresh(String source) throws EngineException { .noneMatch(ns -> existingSegments.stream().anyMatch(es -> es.generation() == ns.generation())) : "new segment generation collides with an existing segment generation"; - // refresh only if new segments have been created or force param is true - notifyRefreshListenersBefore(); if (refreshed) { final long engineRefreshStartNanos = System.nanoTime(); long nextGen = newSegments.size() > 1 ? writerGenerationCounter.incrementAndGet() : RefreshInput.NO_GENERATION; @@ -1023,21 +1085,27 @@ public void refresh(String source) throws EngineException { final long commitStartNanos = System.nanoTime(); catalogSnapshotManager.commitNewSnapshot(result.refreshedSegments()); + assert rowsToRelease > 0L : "Rows to release from active writes should be greater than 0 but was: " + + rowsToRelease + + " for shard: " + + shardId; + pendingRowCount.addAndGet(-rowsToRelease); final long commitElapsedMs = TimeValue.nsecToMSec(System.nanoTime() - commitStartNanos); logger.trace("refresh[{}]: catalogSnapshot commit took [{}ms]", source, commitElapsedMs); } else if ("flush".equals(source)) { catalogSnapshotManager.bumpGeneration(); } - notifyRefreshListenersAfter(refreshed); } finally { store.decRef(); } if (refreshed) { lastRefreshedCheckpointListener.updateRefreshedCheckpoint(localCheckpointBeforeRefresh); + maybePruneDeletes(); triggerPossibleMerges(); // trigger merges } } } finally { + notifyRefreshListenersAfter(refreshed); IOUtils.close(toClose); refreshLock.unlock(); } @@ -1100,6 +1168,17 @@ public void flush(boolean force, boolean waitIfOngoing) throws EngineException { // latestCatalogSnapshot between commit() and updateLastCommitInfo(). Reentrant. refreshLock.lock(); try { + // Capture the processed checkpoint BEFORE refreshing. The refresh persists the + // current writer buffer into the catalog snapshot; any operation processed + // concurrently DURING this flush lands in a NEW writer that is not part of this + // snapshot. Committing the live (post-refresh) processed checkpoint would + // over-claim those ops, so a subsequent recovery/relocation would start replay + // past them (seeding them as "already processed") and silently drop them. + // Capturing here keeps the committed local checkpoint <= what the snapshot + // durably contains, mirroring Lucene's InternalEngine.commitIndexWriter (which + // captures the checkpoint before IndexWriter.commit flushes). See + // DataFormatAwareEngineTests#testFlushMustNotCommitCheckpointAheadOfPersistedSnapshot. + final long committedLocalCheckpoint = localCheckpointTracker.getProcessedCheckpoint(); // Refresh first to flush buffered data to segments refresh("flush"); translogManager.rollTranslogGeneration(); @@ -1121,10 +1200,7 @@ public void flush(boolean force, boolean waitIfOngoing) throws EngineException { ); commitData.put(CatalogSnapshot.CATALOG_SNAPSHOT_ID, Long.toString(snapshot.getId())); commitData.put(Translog.TRANSLOG_UUID_KEY, translogManager.getTranslogUUID()); - commitData.put( - SequenceNumbers.LOCAL_CHECKPOINT_KEY, - Long.toString(localCheckpointTracker.getProcessedCheckpoint()) - ); + commitData.put(SequenceNumbers.LOCAL_CHECKPOINT_KEY, Long.toString(committedLocalCheckpoint)); commitData.put(SequenceNumbers.MAX_SEQ_NO, Long.toString(localCheckpointTracker.getMaxSeqNo())); commitData.put(MAX_UNSAFE_AUTO_ID_TIMESTAMP_COMMIT_ID, Long.toString(maxUnsafeAutoIdTimestamp.get())); commitData.put(Engine.HISTORY_UUID_KEY, historyUUID); @@ -1180,6 +1256,10 @@ public void flush(boolean force, boolean waitIfOngoing) throws EngineException { flushLock.unlock(); } } + + if (engineConfig.isEnableGcDeletes()) { + pruneDeletedTombstones(); + } } /** Flushes the engine with default parameters (non-forced, wait if ongoing). */ @@ -1299,6 +1379,7 @@ public boolean isThrottled() { */ @Override public void onSettingsChanged(TimeValue translogRetentionAge, ByteSizeValue translogRetentionSize, long softDeletesRetentionOps) { + maybePruneDeletes(); if (engineConfig.isAutoGeneratedIDsOptimizationEnabled() == false) { updateAutoIdTimestamp(Long.MAX_VALUE, true); } @@ -1421,7 +1502,24 @@ public boolean maybeRefresh(String source) { /** No-op — data-format engines do not maintain Lucene-style delete tombstones. */ @Override public void maybePruneDeletes() { - // No-op: data-format engines do not maintain Lucene-style delete tombstones + // Pruning walks the deletes map taking a per-uid lock, so it is throttled to once per 1/4 of gcDeletes, + // mirroring InternalEngine. The version map carries delete tombstones used by the get-by-id/version path. + if (engineConfig.isEnableGcDeletes() + && engineConfig.getThreadPool().relativeTimeInMillis() - lastDeleteVersionPruneTimeMSec > getGcDeletesInMillis() * 0.25) { + pruneDeletedTombstones(); + } + } + + /** + * Prunes delete tombstones from the version map: those older than one GC-delete cycle whose sequence number + * is at most the processed checkpoint (the single trimming strategy used by {@code InternalEngine}, correct + * on both primary and replica). Updates {@link #lastDeleteVersionPruneTimeMSec}. + */ + protected void pruneDeletedTombstones() { + final long timeMSec = engineConfig.getThreadPool().relativeTimeInMillis(); + final long maxTimestampToPrune = timeMSec - engineConfig.getIndexSettings().getGcDeletesInMillis(); + versionMap.pruneTombstones(maxTimestampToPrune, localCheckpointTracker.getProcessedCheckpoint()); + lastDeleteVersionPruneTimeMSec = timeMSec; } /** @@ -1466,7 +1564,7 @@ public long getMaxSeqNoOfUpdatesOrDeletes() { @Override public void advanceMaxSeqNoOfUpdatesOrDeletes(long maxSeqNoOfUpdatesOnPrimary) { - throw new UnsupportedOperationException("updates/deletes not supported"); + this.maxSeqNoOfUpdatesOrDeletes.updateAndGet(curr -> Math.max(curr, maxSeqNoOfUpdatesOnPrimary)); } @Override @@ -1654,7 +1752,7 @@ public TranslogManager translogManager() { @Override public Closeable acquireHistoryRetentionLock() { - return () -> {}; + return translogManager.acquireHistoryRetentionLock(); } /** @@ -1841,6 +1939,78 @@ public byte[] serializeSnapshotToRemoteMetadata(CatalogSnapshot catalogSnapshot) return catalogSnapshotManager.serializeToCommitFormat(catalogSnapshot); } + /** + * Delegates get-by-id to the installed {@link DocumentLookupProvider}, acquiring a + * per-format reader on the current snapshot and passing it to the plugin. + * Throws {@link UnsupportedOperationException} if no plugin is wired. + */ + @Override + public Engine.GetResult getById(Engine.Get get, BiFunction searcherFactory) + throws IOException { + if (documentLookup.isSupported() == false) { + throw new UnsupportedOperationException("getById not supported: no DocumentLookupProvider installed"); + } + try (ReleasableLock ignored = readLock.acquire()) { + ensureOpen(); + if (get.realtime()) { + VersionValue versionValue; + try (Releasable ignore = versionMap.acquireLock(get.uid().bytes())) { + versionValue = getVersionFromMap(get.uid().bytes()); + } + if (versionValue != null) { + if (versionValue.isDelete()) { + return Engine.GetResult.NOT_EXISTS; + } + if (get.versionType().isVersionConflictForReads(versionValue.version, get.version())) { + throw new VersionConflictEngineException( + shardId, + get.id(), + get.versionType().explainConflictForReads(versionValue.version, get.version()) + ); + } + if (get.getIfSeqNo() != SequenceNumbers.UNASSIGNED_SEQ_NO + && (get.getIfSeqNo() != versionValue.seqNo || get.getIfPrimaryTerm() != versionValue.term)) { + throw new VersionConflictEngineException( + shardId, + get.id(), + get.getIfSeqNo(), + get.getIfPrimaryTerm(), + versionValue.seqNo, + versionValue.term + ); + } + if (get.isReadFromTranslog() && versionValue.getLocation() != null) { + try { + Translog.Operation operation = translogManager.readOperation(versionValue.getLocation()); + if (operation != null) { + Translog.Index index = (Translog.Index) operation; + return new DocumentLookupResult( + get.id(), + index.version(), + true, + index.source(), + index.seqNo(), + index.primaryTerm(), + Map.of(), + Map.of() + ).toGetResult(); + } + } catch (IOException e) { + throw new EngineException(shardId, "failed to read operation from translog", e); + } + } + assert versionValue.seqNo >= 0 : versionValue; + } + } + + // Fall through: read from parquet + try (GatedCloseable readerRef = acquireReader()) { + DocumentLookupResult result = documentLookup.lookupFromReader(get, readerRef.get()); + return result.exists() ? result.toGetResult() : Engine.GetResult.NOT_EXISTS; + } + } // readLock + } + /** * DFA callers MUST use {@link #acquireSafeCatalogSnapshot()} — that API avoids the extra * {@code segments_N} disk read required to materialize a Lucene {@link IndexCommit}, and @@ -1936,9 +2106,20 @@ private void applyMergeChanges(MergeResult mergeResult, OneMerge oneMerge) { refreshLock.lock(); } try (GatedCloseable oldSnapshotRef = catalogSnapshotManager.acquireSnapshot()) { - notifyRefreshListenersBefore(); + // A merge only swaps segments in the catalog; it does not advance the checkpoint, so + // checkpoint-publishing listeners must not be notified. We invoke only the + // RemoteStoreRefreshListener so the merged segments get uploaded to the remote store. + for (ReferenceManager.RefreshListener refreshListener : refreshListeners) { + if (refreshListener instanceof RemoteStoreRefreshListener) { + refreshListener.beforeRefresh(); + } + } catalogSnapshotManager.applyMergeResults(mergeResult, oneMerge); - notifyRefreshListenersAfter(true); + for (ReferenceManager.RefreshListener refreshListener : refreshListeners) { + if (refreshListener instanceof RemoteStoreRefreshListener) { + refreshListener.afterRefresh(true); + } + } } catch (Exception ex) { try { logger.error(() -> new ParameterizedMessage("Merge failed while registering merged files in Snapshot"), ex); @@ -1968,6 +2149,9 @@ private void closeNoLock(String reason) { assert rwl.isWriteLockedByCurrentThread() || failEngineLock.isHeldByCurrentThread() : "Either the write lock must be held or the engine must be currently failing"; try { + this.versionMap.clear(); + // Stop accepting new merges immediately + mergeScheduler.shutdown(); // Discard any pending segments not yet picked up by refresh pendingSegments.clear(); // Close any writers queued for deferred close (their files won't reach the catalog) @@ -2018,6 +2202,126 @@ private void closeReaders() throws IOException { } } + protected VersionValue resolveDocVersion(final Engine.Operation op, boolean loadSeqNo) throws IOException { + assert incrementVersionLookup(); + VersionValue versionValue = getVersionFromMap(op.uid().bytes()); + if (versionValue == null) { + if (documentLookupProvider == null) { + return null; + } + assert incrementIndexVersionLookup(); + DocumentLookupResult lookupResult; + try (GatedCloseable readerRef = acquireReader()) { + Reader reader = readerRef.get(); + if (reader.catalogSnapshot().getSegments().isEmpty()) { + return null; + } + lookupResult = documentLookupProvider.getVersionMetadata(op.id(), reader, shardId.getIndex(), documentMetadataResolver); + } + if (lookupResult.version() != Versions.NOT_FOUND) { + versionValue = new IndexVersionValue(null, lookupResult.version(), lookupResult.seqNo(), lookupResult.primaryTerm()); + } + } else { + if (engineConfig.isEnableGcDeletes() + && versionValue.isDelete() + && (engineConfig.getThreadPool().relativeTimeInMillis() + - ((DeleteVersionValue) versionValue).time) > getGcDeletesInMillis()) { + versionValue = null; + } + } + return versionValue; + } + + long getGcDeletesInMillis() { + return engineConfig.getIndexSettings().getGcDeletesInMillis(); + } + + private boolean incrementVersionLookup() { // only used by asserts + numVersionLookups.inc(); + return true; + } + + private boolean incrementIndexVersionLookup() { + numIndexVersionsLookups.inc(); + return true; + } + + private static OpVsEngineDocStatus compareOpToVersionMapOnSeqNo(String id, long seqNo, long primaryTerm, VersionValue versionValue) { + Objects.requireNonNull(versionValue); + if (seqNo > versionValue.seqNo) { + return OpVsEngineDocStatus.OP_NEWER; + } else if (seqNo == versionValue.seqNo) { + assert versionValue.term == primaryTerm : "primary term not matched; id=" + + id + + " seq_no=" + + seqNo + + " op_term=" + + primaryTerm + + " existing_term=" + + versionValue.term; + return OpVsEngineDocStatus.OP_STALE_OR_EQUAL; + } else { + return OpVsEngineDocStatus.OP_STALE_OR_EQUAL; + } + } + + private void restoreVersionMapAndCheckpointTracker() { + try { + final long persistedCheckpoint = localCheckpointTracker.getPersistedCheckpoint(); + if (documentLookupProvider != null) { + try (GatedCloseable readerRef = acquireReader()) { + List docs = documentLookupProvider.getDocsAboveSeqNo( + persistedCheckpoint, + readerRef.get(), + shardId.getIndex(), + documentMetadataResolver + ); + for (DocumentLookupResult doc : docs) { + localCheckpointTracker.markSeqNoAsProcessed(doc.seqNo()); + localCheckpointTracker.markSeqNoAsPersisted(doc.seqNo()); + final BytesRef uid = new Term(IdFieldMapper.NAME, Uid.encodeId(doc.id())).bytes(); + try (Releasable ignored = versionMap.acquireLock(uid)) { + final VersionValue curr = versionMap.getUnderLock(uid); + if (curr == null + || compareOpToVersionMapOnSeqNo( + doc.id(), + doc.seqNo(), + doc.primaryTerm(), + curr + ) == OpVsEngineDocStatus.OP_NEWER) { + versionMap.putIndexUnderLock( + uid, + new IndexVersionValue(null, doc.version(), doc.seqNo(), doc.primaryTerm()) + ); + } + } + } + } + } + } catch (IOException e) { + throw new EngineCreationFailureException( + config().getShardId(), + "failed to restore version map and local checkpoint tracker", + e + ); + } + } + + private VersionValue getVersionFromMap(BytesRef id) { + if (versionMap.isUnsafe()) { + synchronized (versionMap) { + // we are switching from an unsafe map to a safe map. This might happen concurrently + // but we only need to do this once since the last operation per ID is to add to the version + // map so once we pass this point we can safely lookup from the version map. + if (versionMap.isUnsafe()) { + refresh("unsafe_version_map"); + } + versionMap.enforceSafeAccess(); + } + } + return versionMap.getUnderLock(id); + } + private void awaitPendingClose() { try { closedLatch.await(); diff --git a/server/src/main/java/org/opensearch/index/engine/DataFormatAwareNRTReplicationEngine.java b/server/src/main/java/org/opensearch/index/engine/DataFormatAwareNRTReplicationEngine.java index bcc62279dcc32..8fd3060ef7393 100644 --- a/server/src/main/java/org/opensearch/index/engine/DataFormatAwareNRTReplicationEngine.java +++ b/server/src/main/java/org/opensearch/index/engine/DataFormatAwareNRTReplicationEngine.java @@ -35,6 +35,8 @@ import org.opensearch.index.engine.exec.CatalogSnapshotDeletionPolicy; import org.opensearch.index.engine.exec.CatalogSnapshotLifecycleListener; import org.opensearch.index.engine.exec.CommitFileManager; +import org.opensearch.index.engine.exec.DocumentLookupSupport; +import org.opensearch.index.engine.exec.DocumentMetadataResolver; import org.opensearch.index.engine.exec.EngineReaderManager; import org.opensearch.index.engine.exec.FileDeleter; import org.opensearch.index.engine.exec.Indexer; @@ -45,6 +47,7 @@ import org.opensearch.index.engine.exec.commit.IndexStoreProvider; import org.opensearch.index.engine.exec.coord.CatalogSnapshot; import org.opensearch.index.engine.exec.coord.CatalogSnapshotManager; +import org.opensearch.index.get.DocumentLookupResult; import org.opensearch.index.mapper.DocumentMapperForType; import org.opensearch.index.mapper.IdFieldMapper; import org.opensearch.index.mapper.ParsedDocument; @@ -65,6 +68,7 @@ import org.opensearch.index.translog.TranslogOperationHelper; import org.opensearch.index.translog.WriteOnlyTranslogManager; import org.opensearch.indices.pollingingest.PollingIngestStats; +import org.opensearch.plugins.DocumentLookupProvider; import org.opensearch.search.suggest.completion.CompletionStats; import java.io.Closeable; @@ -89,6 +93,7 @@ import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.BiFunction; import java.util.function.Supplier; /** @@ -120,6 +125,7 @@ public class DataFormatAwareNRTReplicationEngine implements Indexer { private final CatalogSnapshotManager catalogSnapshotManager; private final Committer committer; private final CatalogSnapshotStatsCache statsCache; + private final DocumentLookupSupport documentLookup; private volatile long lastWriteNanos = System.nanoTime(); private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock(); private final ReleasableLock readLock = new ReleasableLock(rwl.readLock()); @@ -136,6 +142,10 @@ public DataFormatAwareNRTReplicationEngine(EngineConfig engineConfig) { this.engineConfig = engineConfig; this.shardId = engineConfig.getShardId(); this.store = engineConfig.getStore(); + DocumentMetadataResolver resolver = engineConfig.getDocumentMetadataResolver() != null + ? engineConfig.getDocumentMetadataResolver() + : DocumentMetadataResolver.NOOP; + this.documentLookup = new DocumentLookupSupport(shardId, engineConfig.getDocumentLookupProvider(), resolver); store.incRef(); Map> readerManagersRef = null; @@ -397,6 +407,23 @@ public GatedCloseable acquireReader() throws IOException { } } + /** + * Resolves {@code get} against the replicated segment snapshot via the installed + * {@link DocumentLookupProvider}, with read-time version-conflict checks. No live version map, + * so reads go straight to the snapshot. + */ + @Override + public Engine.GetResult getById(Engine.Get get, BiFunction searcherFactory) + throws IOException { + try (ReleasableLock ignored = readLock.acquire()) { + ensureOpen(); + try (GatedCloseable readerRef = acquireReader()) { + DocumentLookupResult result = documentLookup.getById(get, readerRef.get()); + return result.exists() ? result.toGetResult() : Engine.GetResult.NOT_EXISTS; + } + } + } + @Override public boolean refreshNeeded() { return false; diff --git a/server/src/main/java/org/opensearch/index/engine/DataFormatAwareReadOnlyEngine.java b/server/src/main/java/org/opensearch/index/engine/DataFormatAwareReadOnlyEngine.java index f5568a1c7f229..36e22da70c213 100644 --- a/server/src/main/java/org/opensearch/index/engine/DataFormatAwareReadOnlyEngine.java +++ b/server/src/main/java/org/opensearch/index/engine/DataFormatAwareReadOnlyEngine.java @@ -31,14 +31,18 @@ import org.opensearch.index.engine.dataformat.ReaderManagerConfig; import org.opensearch.index.engine.exec.CatalogSnapshotDeletionPolicy; import org.opensearch.index.engine.exec.CatalogSnapshotLifecycleListener; +import org.opensearch.index.engine.exec.DocumentLookupSupport; +import org.opensearch.index.engine.exec.DocumentMetadataResolver; import org.opensearch.index.engine.exec.EngineReaderManager; import org.opensearch.index.engine.exec.FileDeleter; +import org.opensearch.index.engine.exec.FilesListener; import org.opensearch.index.engine.exec.Indexer; import org.opensearch.index.engine.exec.commit.Committer; import org.opensearch.index.engine.exec.commit.CommitterConfig; import org.opensearch.index.engine.exec.commit.IndexStoreProvider; import org.opensearch.index.engine.exec.coord.CatalogSnapshot; import org.opensearch.index.engine.exec.coord.CatalogSnapshotManager; +import org.opensearch.index.get.DocumentLookupResult; import org.opensearch.index.mapper.DocumentMapperForType; import org.opensearch.index.mapper.SourceToParse; import org.opensearch.index.merge.MergeStats; @@ -52,6 +56,7 @@ import org.opensearch.index.translog.TranslogManager; import org.opensearch.index.translog.TranslogStats; import org.opensearch.indices.pollingingest.PollingIngestStats; +import org.opensearch.plugins.DocumentLookupProvider; import org.opensearch.search.suggest.completion.CompletionStats; import java.io.Closeable; @@ -68,6 +73,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.BiFunction; import java.util.function.Supplier; /** @@ -119,12 +125,25 @@ public class DataFormatAwareReadOnlyEngine implements Indexer { // Stats cache — populated once at construction (snapshot is permanent for this engine). private final CatalogSnapshotStatsCache statsCache; + private final DocumentLookupSupport documentLookup; + public DataFormatAwareReadOnlyEngine(EngineConfig engineConfig) { + this(engineConfig, null); + } + + public DataFormatAwareReadOnlyEngine(EngineConfig engineConfig, @Nullable DocumentLookupProvider documentLookupProvider) { this.logger = Loggers.getLogger(DataFormatAwareReadOnlyEngine.class, engineConfig.getShardId()); assert engineConfig.isReadOnlyReplica() == false : "DataFormatAwareReadOnlyEngine must only be created for primary shards; shard " + engineConfig.getShardId(); this.engineConfig = engineConfig; this.shardId = engineConfig.getShardId(); + DocumentLookupProvider provider = documentLookupProvider != null + ? documentLookupProvider + : engineConfig.getDocumentLookupProvider(); + DocumentMetadataResolver resolver = engineConfig.getDocumentMetadataResolver() != null + ? engineConfig.getDocumentMetadataResolver() + : DocumentMetadataResolver.NOOP; + this.documentLookup = new DocumentLookupSupport(shardId, provider, resolver); this.store = engineConfig.getStore(); store.incRef(); @@ -167,16 +186,22 @@ public DataFormatAwareReadOnlyEngine(EngineConfig engineConfig) { readerManagersRef = Map.copyOf(aggregated); this.readerManagers = readerManagersRef; - // Register reader managers as catalog snapshot lifecycle listeners so they are notified - // (via installSnapshot → afterRefresh) BEFORE latestCatalogSnapshot is swapped. This - // guarantees readers are updated before the snapshot becomes externally visible. - List snapshotListeners = new ArrayList<>(readerManagersRef.values()); + // Register reader managers as BOTH files listeners and snapshot lifecycle listeners. + // Files listeners make IndexFileDeleter fire onFilesAdded for the committed snapshot + // at open, eagerly warming the metadata cache before the first query; snapshot + // listeners build the reader before latestCatalogSnapshot is swapped. + Map filesListeners = new HashMap<>(); + List snapshotListeners = new ArrayList<>(); + for (Map.Entry> entry : readerManagersRef.entrySet()) { + filesListeners.put(entry.getKey().name(), entry.getValue()); + snapshotListeners.add(entry.getValue()); + } catalogSnapshotManagerRef = new CatalogSnapshotManager( committed, new NoOpCatalogSnapshotDeletionPolicy(), compositeDeleter, - Map.of(), + filesListeners, snapshotListeners, store.shardPath(), committer @@ -305,6 +330,13 @@ public Engine.NoOpResult noOp(Engine.NoOp noOp) throws IOException { throw new UnsupportedOperationException("DataFormatAwareReadOnlyEngine does not support no-ops"); } + @Override + public Engine.GetResult getById(Engine.Get get, BiFunction searcherFactory) + throws IOException { + DocumentLookupResult result = documentLookup.getById(get, reader); + return result.exists() ? result.toGetResult() : Engine.GetResult.NOT_EXISTS; + } + @Override public Engine.Index prepareIndex( DocumentMapperForType docMapper, diff --git a/server/src/main/java/org/opensearch/index/engine/DocumentCountTracker.java b/server/src/main/java/org/opensearch/index/engine/DocumentCountTracker.java new file mode 100644 index 0000000000000..342ee7ba8e0c0 --- /dev/null +++ b/server/src/main/java/org/opensearch/index/engine/DocumentCountTracker.java @@ -0,0 +1,100 @@ +/* + * 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.index.engine; + +import org.apache.lucene.index.IndexWriter; +import org.opensearch.common.CheckedSupplier; +import org.opensearch.core.index.shard.ShardId; +import org.opensearch.index.seqno.SequenceNumbers; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Tracks in-flight document counts to prevent exceeding the maximum document limit per shard. + *

    + * This class is shared between {@link InternalEngine} (Lucene-based) and + * {@link DataFormatAwareEngine} (pluggable data format). It guards against a race where + * multiple concurrent writes pass the document count check but haven't yet been committed — + * without tracking, the underlying storage could reject the write after a sequence number + * has already been assigned, creating gaps. + * + * @opensearch.internal + */ +class DocumentCountTracker { + + /** + * If multiple writes passed {@link DocumentCountTracker#tryAcquireInFlightDocs(Engine.Operation, int)} but they haven't adjusted + * {@link IndexWriter#getPendingNumDocs()} yet, then IndexWriter can fail with too many documents. In this case, we have to fail + * the engine because we already generated sequence numbers for write operations; otherwise we will have gaps in sequence numbers. + * To avoid this, we keep track the number of documents that are being added to IndexWriter, and account it in + * {@link DocumentCountTracker#tryAcquireInFlightDocs(Engine.Operation, int)}. Although we can double count some inFlight documents in IW and Engine, + * this shouldn't be an issue because it happens for a short window and we adjust the inFlightDocCount once an indexing is completed. + */ + private final AtomicLong inFlightDocCount = new AtomicLong(); + + private final CheckedSupplier indexedDocs; + private final long docsAllowed; + private final ShardId shardId; + + /** + * @param shardId the shard this tracker belongs to (for error messages) + * @param indexedDocs supplier returning the current committed + pending doc count + * @param docsAllowed maximum number of documents allowed in this shard + */ + DocumentCountTracker(ShardId shardId, CheckedSupplier indexedDocs, long docsAllowed) { + this.shardId = shardId; + this.indexedDocs = indexedDocs; + this.docsAllowed = docsAllowed; + } + + /** + * Attempts to reserve {@code addingDocs} document slots. If the total (in-flight + indexed) + * would exceed the limit, the reservation is rolled back and an exception is returned. + * + * @param operation the engine operation (must be PRIMARY with unassigned seq no) + * @param addingDocs number of documents being added + * @return {@code null} if acquired successfully, or an {@link IllegalArgumentException} if the limit would be exceeded + */ + Exception tryAcquireInFlightDocs(Engine.Operation operation, int addingDocs) { + assert operation.origin() == Engine.Operation.Origin.PRIMARY : operation; + assert operation.seqNo() == SequenceNumbers.UNASSIGNED_SEQ_NO : operation; + assert addingDocs > 0 : addingDocs; + long totalDocs; + try { + totalDocs = inFlightDocCount.addAndGet(addingDocs) + indexedDocs.get(); + } catch (IOException e) { + releaseInFlightDocs(addingDocs); + return e; + } + if (totalDocs > docsAllowed) { + releaseInFlightDocs(addingDocs); + return new IllegalArgumentException( + "Number of documents in shard " + shardId + " exceeds the limit of [" + docsAllowed + "] documents per shard" + ); + } else { + return null; + } + } + + /** + * Releases previously acquired in-flight document slots after indexing completes or fails. + * + * @param numDocs number of document slots to release (must be non-negative) + */ + void releaseInFlightDocs(int numDocs) { + assert numDocs >= 0 : numDocs; + final long newValue = inFlightDocCount.addAndGet(-numDocs); + assert newValue >= 0 : "inFlightDocCount must not be negative [" + newValue + "]"; + } + + long getInFlightDocCount() { + return inFlightDocCount.get(); + } +} diff --git a/server/src/main/java/org/opensearch/index/engine/EngineBackedIndexer.java b/server/src/main/java/org/opensearch/index/engine/EngineBackedIndexer.java index b968a7b6db098..e49a6177638ee 100644 --- a/server/src/main/java/org/opensearch/index/engine/EngineBackedIndexer.java +++ b/server/src/main/java/org/opensearch/index/engine/EngineBackedIndexer.java @@ -32,6 +32,7 @@ import java.io.Closeable; import java.io.IOException; import java.util.List; +import java.util.function.BiFunction; /** * An indexer implementation that uses an engine to perform indexing operations. @@ -468,4 +469,11 @@ public GatedCloseable acquireReader() throws IOException { public Engine getEngine() { return engine; } + + /** Engine-backed get-by-id: delegates to {@link Engine#get(Engine.Get, BiFunction)} with {@code searcherFactory}. */ + @Override + public Engine.GetResult getById(Engine.Get get, BiFunction searcherFactory) + throws IOException { + return engine.get(get, searcherFactory); + } } diff --git a/server/src/main/java/org/opensearch/index/engine/EngineConfig.java b/server/src/main/java/org/opensearch/index/engine/EngineConfig.java index 78e319bfafc3b..2ba5a881c31cb 100644 --- a/server/src/main/java/org/opensearch/index/engine/EngineConfig.java +++ b/server/src/main/java/org/opensearch/index/engine/EngineConfig.java @@ -56,6 +56,7 @@ import org.opensearch.index.codec.CodecService; import org.opensearch.index.codec.CodecSettings; import org.opensearch.index.engine.dataformat.DataFormatRegistry; +import org.opensearch.index.engine.exec.DocumentMetadataResolver; import org.opensearch.index.engine.exec.commit.CommitterFactory; import org.opensearch.index.mapper.DocumentMapperForType; import org.opensearch.index.mapper.MapperService; @@ -69,6 +70,7 @@ import org.opensearch.index.translog.TranslogDeletionPolicyFactory; import org.opensearch.index.translog.TranslogFactory; import org.opensearch.indices.IndexingMemoryController; +import org.opensearch.plugins.DocumentLookupProvider; import org.opensearch.threadpool.ThreadPool; import java.util.Collections; @@ -127,6 +129,10 @@ public final class EngineConfig { private final MapperService mapperService; private final CommitterFactory committerFactory; private final Map checksumStrategies; + @Nullable + private final DocumentLookupProvider documentLookupProvider; + @Nullable + private final DocumentMetadataResolver documentMetadataResolver; /** * A supplier of the outstanding retention leases. This is used during merged operations to determine which operations that have been @@ -321,6 +327,8 @@ private EngineConfig(Builder builder) { this.mapperService = builder.mapperService; this.committerFactory = builder.committerFactory; this.checksumStrategies = builder.checksumStrategies; + this.documentLookupProvider = builder.documentLookupProvider; + this.documentMetadataResolver = builder.documentMetadataResolver; } /** @@ -369,7 +377,9 @@ public Builder toBuilder() { .documentMapperForTypeSupplier(this.documentMapperForTypeSupplier) .indexReaderWarmer(this.indexReaderWarmer) .clusterApplierService(this.clusterApplierService) - .mergedSegmentTransferTracker(this.mergedSegmentTransferTracker); + .mergedSegmentTransferTracker(this.mergedSegmentTransferTracker) + .documentLookupProvider(this.documentLookupProvider) + .documentMetadataResolver(this.documentMetadataResolver); } /** @@ -664,6 +674,18 @@ public Map getChecksumStrategies() { return this.checksumStrategies; } + /** Optional {@link DocumentLookupProvider} for the pluggable get-by-id path, or {@code null}. */ + @Nullable + public DocumentLookupProvider getDocumentLookupProvider() { + return this.documentLookupProvider; + } + + /** Optional {@link DocumentMetadataResolver} passed per-call to the provider, or {@code null}. */ + @Nullable + public DocumentMetadataResolver getDocumentMetadataResolver() { + return this.documentMetadataResolver; + } + /** * Builder for EngineConfig class * @@ -706,6 +728,10 @@ public static class Builder { private MapperService mapperService; private CommitterFactory committerFactory; private Map checksumStrategies = Collections.emptyMap(); + @Nullable + private DocumentLookupProvider documentLookupProvider; + @Nullable + private DocumentMetadataResolver documentMetadataResolver; public Builder shardId(ShardId shardId) { this.shardId = shardId; @@ -882,6 +908,16 @@ public Builder checksumStrategies(Map checksumSt return this; } + public Builder documentLookupProvider(@Nullable DocumentLookupProvider documentLookupProvider) { + this.documentLookupProvider = documentLookupProvider; + return this; + } + + public Builder documentMetadataResolver(@Nullable DocumentMetadataResolver documentMetadataResolver) { + this.documentMetadataResolver = documentMetadataResolver; + return this; + } + public EngineConfig build() { return new EngineConfig(this); } diff --git a/server/src/main/java/org/opensearch/index/engine/EngineConfigFactory.java b/server/src/main/java/org/opensearch/index/engine/EngineConfigFactory.java index b9d5be2ed5f2c..e9e0501f55186 100644 --- a/server/src/main/java/org/opensearch/index/engine/EngineConfigFactory.java +++ b/server/src/main/java/org/opensearch/index/engine/EngineConfigFactory.java @@ -29,6 +29,7 @@ import org.opensearch.index.codec.CodecServiceConfig; import org.opensearch.index.codec.CodecServiceFactory; import org.opensearch.index.engine.dataformat.DataFormatRegistry; +import org.opensearch.index.engine.exec.DocumentMetadataResolver; import org.opensearch.index.engine.exec.commit.CommitterFactory; import org.opensearch.index.mapper.DocumentMapperForType; import org.opensearch.index.mapper.MapperService; @@ -39,6 +40,7 @@ import org.opensearch.index.translog.TranslogConfig; import org.opensearch.index.translog.TranslogDeletionPolicyFactory; import org.opensearch.index.translog.TranslogFactory; +import org.opensearch.plugins.DocumentLookupProvider; import org.opensearch.plugins.EnginePlugin; import org.opensearch.plugins.PluginsService; import org.opensearch.threadpool.ThreadPool; @@ -64,6 +66,10 @@ public class EngineConfigFactory { private final TranslogDeletionPolicyFactory translogDeletionPolicyFactory; private final List additionalCodecs; private final CommitterFactory committerFactory; + @Nullable + private final DocumentLookupProvider documentLookupProvider; + @Nullable + private final DocumentMetadataResolver documentMetadataResolver; /** default ctor primarily used for tests without plugins */ public EngineConfigFactory(IndexSettings idxSettings) { @@ -77,8 +83,31 @@ public EngineConfigFactory(PluginsService pluginsService, IndexSettings idxSetti this(pluginsService.filterPlugins(EnginePlugin.class), idxSettings); } - /* private constructor to construct the factory from specific EnginePlugins and IndexSettings */ + /** + * Factory wiring the optional {@link DocumentLookupProvider} and {@link DocumentMetadataResolver} + * (pluggable get-by-id path) into the produced {@link EngineConfig}. + */ + public EngineConfigFactory( + PluginsService pluginsService, + IndexSettings idxSettings, + @Nullable DocumentLookupProvider documentLookupProvider, + @Nullable DocumentMetadataResolver documentMetadataResolver + ) { + this(pluginsService.filterPlugins(EnginePlugin.class), idxSettings, documentLookupProvider, documentMetadataResolver); + } + + /* package-private constructor from specific EnginePlugins and IndexSettings without document-lookup wiring */ EngineConfigFactory(Collection enginePlugins, IndexSettings idxSettings) { + this(enginePlugins, idxSettings, null, null); + } + + /* private constructor to construct the factory from specific EnginePlugins and IndexSettings */ + EngineConfigFactory( + Collection enginePlugins, + IndexSettings idxSettings, + @Nullable DocumentLookupProvider documentLookupProvider, + @Nullable DocumentMetadataResolver documentMetadataResolver + ) { final List codecRegistries = new ArrayList<>(); Optional codecService = Optional.empty(); String codecServiceOverridingPlugin = null; @@ -149,6 +178,8 @@ public EngineConfigFactory(PluginsService pluginsService, IndexSettings idxSetti this.translogDeletionPolicyFactory = translogDeletionPolicyFactory.orElse((idxs, rtls) -> null); this.additionalCodecs = Collections.unmodifiableList(codecRegistries); this.committerFactory = committerFactories.isEmpty() ? null : committerFactories.getFirst(); + this.documentLookupProvider = documentLookupProvider; + this.documentMetadataResolver = documentMetadataResolver; } /** @@ -229,6 +260,8 @@ public EngineConfig newEngineConfig( .mapperService(mapperService) .committerFactory(committerFactory) .checksumStrategies(checksumStrategies) + .documentLookupProvider(documentLookupProvider) + .documentMetadataResolver(documentMetadataResolver) .build(); } diff --git a/server/src/main/java/org/opensearch/index/engine/InternalEngine.java b/server/src/main/java/org/opensearch/index/engine/InternalEngine.java index 7c53476fa1a6c..b5752abfa0c02 100644 --- a/server/src/main/java/org/opensearch/index/engine/InternalEngine.java +++ b/server/src/main/java/org/opensearch/index/engine/InternalEngine.java @@ -204,21 +204,11 @@ public class InternalEngine extends Engine { private final AtomicBoolean trackTranslogLocation = new AtomicBoolean(false); private final KeyedLock noOpKeyedLock = new KeyedLock<>(); - /** - * If multiple writes passed {@link InternalEngine#tryAcquireInFlightDocs(Operation, int)} but they haven't adjusted - * {@link IndexWriter#getPendingNumDocs()} yet, then IndexWriter can fail with too many documents. In this case, we have to fail - * the engine because we already generated sequence numbers for write operations; otherwise we will have gaps in sequence numbers. - * To avoid this, we keep track the number of documents that are being added to IndexWriter, and account it in - * {@link InternalEngine#tryAcquireInFlightDocs(Operation, int)}. Although we can double count some inFlight documents in IW and Engine, - * this shouldn't be an issue because it happens for a short window and we adjust the inFlightDocCount once an indexing is completed. - */ - private final AtomicLong inFlightDocCount = new AtomicLong(); - - private final int maxDocs; private final IndexWriterFactory nativeIndexWriterFactory; private final IndexingStrategyPlanner indexingStrategyPlanner; private final DeletionStrategyPlanner deletionStrategyPlanner; + private final DocumentCountTracker documentCountTracker; public InternalEngine(EngineConfig engineConfig) { this(engineConfig, IndexWriter.MAX_DOCS, LocalCheckpointTracker::new, TranslogEventListener.NOOP_TRANSLOG_EVENT_LISTENER); @@ -240,7 +230,6 @@ public TranslogManager translogManager() { TranslogEventListener translogEventListener ) { super(engineConfig); - this.maxDocs = maxDocs; if (engineConfig.isAutoGeneratedIDsOptimizationEnabled() == false) { updateAutoIdTimestamp(Long.MAX_VALUE, true); } @@ -351,6 +340,11 @@ public void onFailure(String reason, Exception ex) { } completionStatsCache = new CompletionStatsCache(() -> acquireSearcher("completion_stats")); this.externalReaderManager.addListener(completionStatsCache); + this.documentCountTracker = new DocumentCountTracker( + engineConfig.getShardId(), + documentIndexWriter::getPendingNumDocs, + maxDocs + ); this.indexingStrategyPlanner = new IndexingStrategyPlanner( engineConfig.getIndexSettings(), engineConfig.getShardId(), @@ -362,7 +356,7 @@ public void onFailure(String reason, Exception ex) { this::compareOpToLuceneDocBasedOnSeqNo, this::resolveDocVersion, this::updateAutoIdTimestamp, - this::tryAcquireInFlightDocs + documentCountTracker::tryAcquireInFlightDocs ); this.deletionStrategyPlanner = new DeletionStrategyPlanner( engineConfig.getIndexSettings(), @@ -370,7 +364,7 @@ public void onFailure(String reason, Exception ex) { this::hasBeenProcessedBefore, this::compareOpToLuceneDocBasedOnSeqNo, this::resolveDocVersion, - this::tryAcquireInFlightDocs, + documentCountTracker::tryAcquireInFlightDocs, this::incrementVersionLookup ); success = true; @@ -990,7 +984,7 @@ public IndexResult index(Index index) throws IOException { indexResult.freeze(); return indexResult; } finally { - releaseInFlightDocs(reservedDocs); + documentCountTracker.releaseInFlightDocs(reservedDocs); } } catch (RuntimeException | IOException e) { try { @@ -1227,35 +1221,14 @@ public DeleteResult delete(Delete delete) throws IOException { } throw e; } finally { - releaseInFlightDocs(reservedDocs); + documentCountTracker.releaseInFlightDocs(reservedDocs); } maybePruneDeletes(); return deleteResult; } - private Exception tryAcquireInFlightDocs(Operation operation, int addingDocs) { - assert operation.origin() == Operation.Origin.PRIMARY : operation; - assert operation.seqNo() == SequenceNumbers.UNASSIGNED_SEQ_NO : operation; - assert addingDocs > 0 : addingDocs; - long totalDocs = inFlightDocCount.addAndGet(addingDocs) + documentIndexWriter.getPendingNumDocs(); - if (totalDocs > maxDocs) { - releaseInFlightDocs(addingDocs); - return new IllegalArgumentException( - "Number of documents in shard " + shardId + " exceeds the limit of [" + maxDocs + "] documents per shard" - ); - } else { - return null; - } - } - - private void releaseInFlightDocs(int numDocs) { - assert numDocs >= 0 : numDocs; - final long newValue = inFlightDocCount.addAndGet(-numDocs); - assert newValue >= 0 : "inFlightDocCount must not be negative [" + newValue + "]"; - } - long getInFlightDocCount() { - return inFlightDocCount.get(); + return documentCountTracker.getInFlightDocCount(); } protected DeletionStrategy deletionStrategyForOperation(final Delete delete) throws IOException { diff --git a/server/src/main/java/org/opensearch/index/engine/dataformat/DataFormatRegistry.java b/server/src/main/java/org/opensearch/index/engine/dataformat/DataFormatRegistry.java index 527bd3e6ffef2..e3b73d751cf3f 100644 --- a/server/src/main/java/org/opensearch/index/engine/dataformat/DataFormatRegistry.java +++ b/server/src/main/java/org/opensearch/index/engine/dataformat/DataFormatRegistry.java @@ -13,10 +13,12 @@ import org.opensearch.common.CheckedFunction; import org.opensearch.common.annotation.ExperimentalApi; import org.opensearch.index.IndexSettings; +import org.opensearch.index.engine.exec.DocumentMetadataResolver; import org.opensearch.index.engine.exec.EngineReaderManager; import org.opensearch.index.engine.exec.commit.Committer; import org.opensearch.index.mapper.MappedFieldType; import org.opensearch.index.store.FormatChecksumStrategy; +import org.opensearch.plugins.DocumentLookupProvider; import org.opensearch.plugins.PluginsService; import org.opensearch.plugins.SearchBackEndPlugin; @@ -48,6 +50,12 @@ public class DataFormatRegistry { private final Map dataFormats; + /** The single registered document lookup provider (engine-backed get-by-id execution), or {@code null} if none. */ + private final DocumentLookupProvider documentLookupProvider; + + /** The single registered document metadata resolver (id-to-row-location), or {@link DocumentMetadataResolver#NOOP} if none. */ + private final DocumentMetadataResolver documentMetadataResolver; + private static final Logger logger = LogManager.getLogger(DataFormatRegistry.class); /** @@ -84,6 +92,38 @@ public DataFormatRegistry(PluginsService pluginsService) { this.dataFormatPluginRegistry = Map.copyOf(dataFormatPlugiRegistry); this.dataFormats = Map.copyOf(dataFormats); this.readerManagerBuilders = Map.copyOf(readerManagerBuilders); + + List lookupProviders = pluginsService.filterPlugins(DocumentLookupProvider.class); + if (lookupProviders.size() > 1) { + throw new IllegalStateException("multiple DocumentLookupProvider implementations registered: " + lookupProviders); + } + this.documentLookupProvider = lookupProviders.isEmpty() ? null : lookupProviders.getFirst(); + + List resolvers = pluginsService.filterPlugins(DocumentMetadataResolver.class); + if (resolvers.size() > 1) { + throw new IllegalStateException("multiple DocumentMetadataResolver implementations registered: " + resolvers); + } + this.documentMetadataResolver = resolvers.isEmpty() ? DocumentMetadataResolver.NOOP : resolvers.getFirst(); + } + + /** + * Returns the single registered {@link DocumentLookupProvider} that backs the engine's + * get-by-id / version-resolution path, or {@code null} when no provider is registered. + * + * @return the document lookup provider, or null + */ + public DocumentLookupProvider getDocumentLookupProvider() { + return documentLookupProvider; + } + + /** + * Returns the single registered {@link DocumentMetadataResolver} that maps an {@code _id} + * to its row location, or {@link DocumentMetadataResolver#NOOP} when none is registered. + * + * @return the document metadata resolver (never null) + */ + public DocumentMetadataResolver getDocumentMetadataResolver() { + return documentMetadataResolver; } /** diff --git a/server/src/main/java/org/opensearch/index/engine/dataformat/IndexingExecutionEngine.java b/server/src/main/java/org/opensearch/index/engine/dataformat/IndexingExecutionEngine.java index 699a75952b132..bf511c0c7e32f 100644 --- a/server/src/main/java/org/opensearch/index/engine/dataformat/IndexingExecutionEngine.java +++ b/server/src/main/java/org/opensearch/index/engine/dataformat/IndexingExecutionEngine.java @@ -138,4 +138,13 @@ default Map> buildReaderManager(ReaderManager default Exception getTragicException() { return null; } + + /** + * Returns the maximum number of documents this engine can index per shard. + * Used by {@link org.opensearch.index.engine.DocumentCountTracker} to enforce + * the document count limit. Defaults to {@link Long#MAX_VALUE} (unlimited). + */ + default long maxIndexableDocs() { + return Long.MAX_VALUE; + } } diff --git a/server/src/main/java/org/opensearch/index/engine/dataformat/merge/MergeHandler.java b/server/src/main/java/org/opensearch/index/engine/dataformat/merge/MergeHandler.java index 52b54b8eb86be..69a6982de3505 100644 --- a/server/src/main/java/org/opensearch/index/engine/dataformat/merge/MergeHandler.java +++ b/server/src/main/java/org/opensearch/index/engine/dataformat/merge/MergeHandler.java @@ -107,7 +107,22 @@ public Collection findForceMerges(int maxSegmentCount) { List segmentList = catalogSnapshotRef.get().getSegments(); List> mergeCandidates = mergePolicy.findForceMergeCandidates(segmentList, maxSegmentCount); for (List mergeGroup : mergeCandidates) { - oneMerges.add(new OneMerge(mergeGroup)); + boolean hasConflict = false; + synchronized (this) { + for (Segment seg : mergeGroup) { + if (currentlyMergingSegments.contains(seg)) { + hasConflict = true; + break; + } + } + if (!hasConflict) { + OneMerge oneMerge = new OneMerge(mergeGroup); + if (registerMerge(oneMerge, false)) { + oneMerges.add(oneMerge); + } + } + } + } } catch (Exception e) { logger.warn("Failed to acquire snapshots", e); @@ -142,21 +157,28 @@ public synchronized void findAndRegisterMerges() { * @param merge the merge to register */ public synchronized void registerMerge(OneMerge merge) { + registerMerge(merge, true); + } + + private synchronized boolean registerMerge(OneMerge merge, boolean addToPending) { try (GatedCloseable catalogSnapshotRef = snapshotSupplier.get()) { List catalogSegments = catalogSnapshotRef.get().getSegments(); for (Segment mergeSegment : merge.getSegmentsToMerge()) { if (!catalogSegments.contains(mergeSegment)) { - return; + return false; } } } catch (Exception e) { logger.warn("Failed to acquire snapshots", e); throw new RuntimeException(e); } - pendingMerges.add(merge); + if (addToPending) { // Skips this for force merges. Avoid 2 workers executing the merge. + pendingMerges.add(merge); + } currentlyMergingSegments.addAll(merge.getSegmentsToMerge()); mergeListener.addMergingSegment(merge.getSegmentsToMerge()); logger.debug(() -> new ParameterizedMessage("Registered merge [{}], pendingMerges: [{}]", merge, pendingMerges)); + return true; } /** diff --git a/server/src/main/java/org/opensearch/index/engine/dataformat/merge/MergeScheduler.java b/server/src/main/java/org/opensearch/index/engine/dataformat/merge/MergeScheduler.java index 373547e43a970..627ac113efb88 100644 --- a/server/src/main/java/org/opensearch/index/engine/dataformat/merge/MergeScheduler.java +++ b/server/src/main/java/org/opensearch/index/engine/dataformat/merge/MergeScheduler.java @@ -154,12 +154,22 @@ public void forceMerge(int maxNumSegment) throws IOException { assert Thread.currentThread().getName().contains(ThreadPool.Names.FORCE_MERGE) : "forceMerge must be called on FORCE_MERGE thread but was: " + Thread.currentThread().getName(); forceMergeLock.acquireUninterruptibly(); + activeMerges.incrementAndGet(); try { + if (isShutdown.get()) { + logger.debug("MergeScheduler is shutdown, skipping force merge"); + return; + } Collection oneMerges = mergeHandler.findForceMerges(maxNumSegment); for (OneMerge oneMerge : oneMerges) { + if (isShutdown.get()) { + logger.debug("MergeScheduler shutdown during force merge, aborting remaining merges"); + break; + } runMerge(oneMerge); } } finally { + decrementAndFireDrainListeners(); forceMergeLock.release(); } } @@ -334,19 +344,7 @@ private void submitMergeTask(OneMerge oneMerge) { // runMerge already invoked onMergeFailureCleanup; swallow to prevent // uncaught exception on the merge thread pool. } finally { - activeMerges.decrementAndGet(); - // Fire all drain listeners if all merges completed and none pending - if (isFrozen() && activeMerges.get() == 0 && !mergeHandler.hasPendingMerges() && !onDrainedListeners.isEmpty()) { - List listeners = List.copyOf(onDrainedListeners); - onDrainedListeners.clear(); - for (Runnable listener : listeners) { - try { - listener.run(); - } catch (Exception ex) { - logger.warn("Exception in onDrained listener", ex); - } - } - } + decrementAndFireDrainListeners(); // A completed merge may free up capacity for new merges, so check again. executeMerge(); } @@ -386,4 +384,24 @@ private void runMerge(OneMerge oneMerge) throws IOException { mergeStatsTracker.afterMerge(tookMS, totalNumDocs, totalSizeInBytes); } } + + /** + * Decrements the active merge count and fires all registered drain listeners if the scheduler + * is frozen and no merges (active or pending) remain. Called from both the background merge + * ({@link #submitMergeTask}) and force merge ({@link #forceMerge}) completion paths. + */ + private void decrementAndFireDrainListeners() { + activeMerges.decrementAndGet(); + if (isFrozen() && activeMerges.get() == 0 && !mergeHandler.hasPendingMerges() && !onDrainedListeners.isEmpty()) { + List listeners = List.copyOf(onDrainedListeners); + onDrainedListeners.clear(); + for (Runnable listener : listeners) { + try { + listener.run(); + } catch (Exception ex) { + logger.warn("Exception in onDrained listener", ex); + } + } + } + } } diff --git a/server/src/main/java/org/opensearch/index/engine/exec/DocumentLookupSupport.java b/server/src/main/java/org/opensearch/index/engine/exec/DocumentLookupSupport.java new file mode 100644 index 0000000000000..101bf7256ad76 --- /dev/null +++ b/server/src/main/java/org/opensearch/index/engine/exec/DocumentLookupSupport.java @@ -0,0 +1,103 @@ +/* + * 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.index.engine.exec; + +import org.opensearch.common.Nullable; +import org.opensearch.core.index.shard.ShardId; +import org.opensearch.index.engine.Engine; +import org.opensearch.index.engine.VersionConflictEngineException; +import org.opensearch.index.get.DocumentLookupResult; +import org.opensearch.index.seqno.SequenceNumbers; +import org.opensearch.plugins.DocumentLookupProvider; + +import java.io.IOException; + +/** + * Shared get-by-id helper for the {@code DataFormatAware*} engines. Centralizes the pluggable lookup + * (via the installed {@link DocumentLookupProvider}) and the read-time version-conflict checks so + * the engines don't duplicate them. Immutable and thread-safe; one per shard. + * + * @opensearch.internal + */ +public final class DocumentLookupSupport { + + private final ShardId shardId; + @Nullable + private final DocumentLookupProvider provider; + private final DocumentMetadataResolver resolver; + + public DocumentLookupSupport(ShardId shardId, @Nullable DocumentLookupProvider provider, DocumentMetadataResolver resolver) { + this.shardId = shardId; + this.provider = provider; + this.resolver = resolver; + } + + /** Whether a {@link DocumentLookupProvider} is installed (i.e. get-by-id is supported). */ + public boolean isSupported() { + return provider != null; + } + + /** + * Resolves {@code get} against {@code reader} through the installed provider. + * Returns {@link DocumentLookupResult#notFound} when the reader snapshot has no segments. + * + * @throws UnsupportedOperationException if no {@link DocumentLookupProvider} is installed + */ + public DocumentLookupResult lookupFromReader(Engine.Get get, IndexReaderProvider.Reader reader) throws IOException { + if (provider == null) { + throw new UnsupportedOperationException("getById not supported: no DocumentLookupProvider installed"); + } + if (reader.catalogSnapshot().getSegments().isEmpty()) { + return DocumentLookupResult.notFound(get.id()); + } + return provider.getById(get, reader, shardId.getIndex(), resolver); + } + + /** + * Applies read-time version-conflict checks against a resolved {@code result}, mirroring the + * get semantics in {@code InternalEngine}. A no-op when the document does not exist. + * + * @throws VersionConflictEngineException if the requested version or {@code if_seq_no} / + * {@code if_primary_term} preconditions conflict with the resolved document + */ + public void applyReadVersionConflicts(Engine.Get get, DocumentLookupResult result) { + if (result.exists() == false) { + return; + } + if (get.versionType().isVersionConflictForReads(result.version(), get.version())) { + throw new VersionConflictEngineException( + shardId, + get.id(), + get.versionType().explainConflictForReads(result.version(), get.version()) + ); + } + if (get.getIfSeqNo() != SequenceNumbers.UNASSIGNED_SEQ_NO + && (get.getIfSeqNo() != result.seqNo() || get.getIfPrimaryTerm() != result.primaryTerm())) { + throw new VersionConflictEngineException( + shardId, + get.id(), + get.getIfSeqNo(), + get.getIfPrimaryTerm(), + result.seqNo(), + result.primaryTerm() + ); + } + } + + /** + * Convenience: {@link #lookupFromReader} followed by {@link #applyReadVersionConflicts}. + * Used by the read-only and NRT replica engines, which read directly from the segment + * snapshot without a live version map. + */ + public DocumentLookupResult getById(Engine.Get get, IndexReaderProvider.Reader reader) throws IOException { + DocumentLookupResult result = lookupFromReader(get, reader); + applyReadVersionConflicts(get, result); + return result; + } +} diff --git a/server/src/main/java/org/opensearch/index/engine/exec/DocumentMetadataResolver.java b/server/src/main/java/org/opensearch/index/engine/exec/DocumentMetadataResolver.java new file mode 100644 index 0000000000000..60b5d605c4691 --- /dev/null +++ b/server/src/main/java/org/opensearch/index/engine/exec/DocumentMetadataResolver.java @@ -0,0 +1,55 @@ +/* + * 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.index.engine.exec; + +import org.opensearch.common.annotation.ExperimentalApi; + +import java.io.IOException; + +/** + * Resolves a document's physical row location ({@code rowId}, {@code writerGeneration}) from the + * index's secondary structure, used to fetch the row from the primary store. Returns {@code null} + * ("not found") when there is no id present. + * + * @opensearch.experimental + */ +@ExperimentalApi +public interface DocumentMetadataResolver { + + /** Sentinel for {@link DocumentMetadata} location fields that were not populated. */ + long UNSET = -1L; + + /** No-op resolver used when no backend provides one. */ + DocumentMetadataResolver NOOP = new DocumentMetadataResolver() { + @Override + public DocumentMetadata resolveMetadata(IndexReaderProvider.Reader reader, String id) { + return null; + } + }; + + /** + * Physical row location for a document. Version metadata lives in the primary store, not here. + * + * @param id the document id + * @param rowId the row offset within the data file + * @param writerGeneration the writer generation identifying the data file + */ + @ExperimentalApi + record DocumentMetadata(String id, long rowId, long writerGeneration) { + } + + /** + * Resolve row location for a document id. + * + * @param reader the point-in-time reader snapshot + * @param id the document id to resolve + * @return the {@link DocumentMetadata}, or {@code null} if not found + */ + DocumentMetadata resolveMetadata(IndexReaderProvider.Reader reader, String id) throws IOException; +} diff --git a/server/src/main/java/org/opensearch/index/engine/exec/Indexer.java b/server/src/main/java/org/opensearch/index/engine/exec/Indexer.java index 90908b4f20351..4a1209be0faf4 100644 --- a/server/src/main/java/org/opensearch/index/engine/exec/Indexer.java +++ b/server/src/main/java/org/opensearch/index/engine/exec/Indexer.java @@ -12,6 +12,7 @@ import org.opensearch.common.Nullable; import org.opensearch.common.annotation.ExperimentalApi; import org.opensearch.common.concurrent.GatedCloseable; +import org.opensearch.index.engine.Engine; import org.opensearch.index.engine.EngineConfig; import org.opensearch.index.engine.EngineException; import org.opensearch.index.engine.LifecycleAware; @@ -22,6 +23,7 @@ import java.io.Closeable; import java.io.IOException; +import java.util.function.BiFunction; /** * Unified interface for indexing operations in OpenSearch. @@ -199,6 +201,13 @@ Translog.Snapshot newChangesSnapshot(String source, long fromSeqNo, long toSeqNo */ GatedCloseable acquireLastCommittedSnapshot(boolean flushFirst) throws EngineException, IOException; + /** + * Resolves a document by id. Engine-backed indexers delegate to + * {@link Engine#get(Engine.Get, java.util.function.BiFunction)} with {@code searcherFactory}; + * row-store indexers acquire their own reader and ignore the factory. + */ + Engine.GetResult getById(Engine.Get get, BiFunction searcherFactory) throws IOException; + /** * Returns {@code true} if there are merges queued but not yet started. *

    diff --git a/server/src/main/java/org/opensearch/index/engine/exec/coord/CatalogSnapshot.java b/server/src/main/java/org/opensearch/index/engine/exec/coord/CatalogSnapshot.java index 5c41de84116c2..c4f62478945e2 100644 --- a/server/src/main/java/org/opensearch/index/engine/exec/coord/CatalogSnapshot.java +++ b/server/src/main/java/org/opensearch/index/engine/exec/coord/CatalogSnapshot.java @@ -243,6 +243,24 @@ public Map> getFilesByFormat() { return cached; } + /** + * Finds the {@link WriterFileSet} for the given data format whose {@code writerGeneration} + * matches, or {@code null} if no segment carries a non-empty file set for that format/generation. + * + * @param format the data format name (e.g. {@code "parquet"}) + * @param writerGeneration the writer generation to match + * @return the matching file set, or {@code null} + */ + public WriterFileSet findFileSet(String format, long writerGeneration) { + for (Segment seg : getSegments()) { + WriterFileSet candidate = seg.dfGroupedSearchableFiles().get(format); + if (candidate == null || candidate.files().isEmpty()) continue; + if (candidate.writerGeneration() != writerGeneration) continue; + return candidate; + } + return null; + } + /** * Sets user-defined metadata for this catalog snapshot. * diff --git a/server/src/main/java/org/opensearch/index/engine/exec/coord/CatalogSnapshotManager.java b/server/src/main/java/org/opensearch/index/engine/exec/coord/CatalogSnapshotManager.java index 4fbafba64f54c..b1416e240b39f 100644 --- a/server/src/main/java/org/opensearch/index/engine/exec/coord/CatalogSnapshotManager.java +++ b/server/src/main/java/org/opensearch/index/engine/exec/coord/CatalogSnapshotManager.java @@ -33,6 +33,7 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -157,18 +158,38 @@ public synchronized Segment applyMergeResults(MergeResult mergeResult, OneMerge Set segmentsToRemove = new HashSet<>(oneMerge.getSegmentsToMerge()); // All source segments must exist in the current snapshot - assert segmentList.containsAll(segmentsToRemove) : "merge source segments must all exist in the current catalog snapshot"; + if (!segmentList.containsAll(segmentsToRemove)) { + throw new IllegalStateException( + "Merge source segments must all exist in the current catalog snapshot. Missing: " + + segmentsToRemove.stream() + .filter(s -> !segmentList.contains(s)) + .map(s -> "gen=" + s.generation()) + .collect(java.util.stream.Collectors.joining(", ")) + ); + } // Merged segment generation must not collide with any segment that will be retained - assert segmentList.stream() - .filter(s -> segmentsToRemove.contains(s) == false) - .noneMatch(s -> s.generation() == segmentToAdd.generation()) : "merged segment generation [" - + segmentToAdd.generation() - + "] collides with a retained segment generation"; + if (segmentList.stream().filter(s -> !segmentsToRemove.contains(s)).anyMatch(s -> s.generation() == segmentToAdd.generation())) { + throw new IllegalStateException( + "Merged segment generation [" + segmentToAdd.generation() + "] collides with a retained segment generation" + ); + } // Row count conservation: merged output must have the same total rows as the inputs - assert assertRowCountConservation(segmentsToRemove, segmentToAdd) - : "merged segment row count must equal sum of source segment row counts"; + if (!assertRowCountConservation(segmentsToRemove, segmentToAdd)) { + long inputRows = segmentsToRemove.stream() + .flatMap(s -> s.dfGroupedSearchableFiles().values().stream()) + .mapToLong(WriterFileSet::numRows) + .sum(); + long outputRows = segmentToAdd.dfGroupedSearchableFiles().values().stream().mapToLong(WriterFileSet::numRows).sum(); + throw new IllegalStateException( + "Merged segment row count mismatch: input segments have " + + inputRows + + " total rows but merged output has " + + outputRows + + " rows" + ); + } boolean inserted = false; int newSegIdx = 0; @@ -276,7 +297,7 @@ assert assertSegmentGenerationFileConsistency(refreshedSegments) // one of the writers dropped or duplicated rows during a single refresh — // exactly the class of bug that silently produced different match/LIKE // counts at query time. - assert assertPerSegmentCrossFormatRowCountParity(refreshedSegments) : "per-segment row count must be equal across all formats"; + verifyPerSegmentCrossFormatRowCountParity(refreshedSegments); installSnapshot(newSnapshot); } @@ -673,7 +694,7 @@ private boolean assertRowCountConservation(Set sourceSegments, Segment * single refresh — which produces silent correctness issues like different counts * from {@code match} vs {@code LIKE} over the same field. */ - private boolean assertPerSegmentCrossFormatRowCountParity(List segments) { + private void verifyPerSegmentCrossFormatRowCountParity(List segments) { for (Segment seg : segments) { long expected = -1L; String referenceFormat = null; @@ -683,18 +704,19 @@ private boolean assertPerSegmentCrossFormatRowCountParity(List segments expected = rows; referenceFormat = entry.getKey(); } else if (rows != expected) { - logger.error( - "Per-segment row count mismatch at generation {}: format [{}] has {} rows but format [{}] has {} rows", - seg.generation(), - referenceFormat, - expected, - entry.getKey(), - rows + throw new IllegalStateException( + String.format( + Locale.ROOT, + "Per-segment row count mismatch at generation %s: format [%s] has %s rows but format [%s] has %s rows", + seg.generation(), + referenceFormat, + expected, + entry.getKey(), + rows + ) ); - return false; } } } - return true; } } diff --git a/server/src/main/java/org/opensearch/index/fielddomain/ClusterStateFieldDomainProvider.java b/server/src/main/java/org/opensearch/index/fielddomain/ClusterStateFieldDomainProvider.java new file mode 100644 index 0000000000000..87754fed034a7 --- /dev/null +++ b/server/src/main/java/org/opensearch/index/fielddomain/ClusterStateFieldDomainProvider.java @@ -0,0 +1,60 @@ +/* + * 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.index.fielddomain; + +import org.opensearch.cluster.ClusterState; +import org.opensearch.cluster.metadata.IndexMetadata; +import org.opensearch.common.annotation.ExperimentalApi; + +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * Reads field-domain metadata from {@link IndexMetadata} custom data in cluster state. + * + * @opensearch.experimental + */ +@ExperimentalApi +public final class ClusterStateFieldDomainProvider implements FieldDomainProvider { + private final IndexFieldDomainMetadata metadata; + + /** + * Creates a provider using the built-in field-domain metadata parser. + */ + public ClusterStateFieldDomainProvider() { + this(IndexFieldDomainMetadata.getInstance()); + } + + ClusterStateFieldDomainProvider(IndexFieldDomainMetadata metadata) { + this.metadata = Objects.requireNonNull(metadata, "metadata must not be null"); + } + + /** + * Resolves domains from {@code index_field_domains} custom metadata for a concrete index. + */ + @Override + public Optional getDomain(ClusterState clusterState, String indexName, String field) { + if (clusterState == null || indexName == null || field == null || field.isEmpty()) { + return Optional.empty(); + } + + IndexMetadata indexMetadata = clusterState.metadata().index(indexName); + if (indexMetadata == null) { + return Optional.empty(); + } + + Map custom = indexMetadata.getCustomData(IndexFieldDomainMetadata.CUSTOM_KEY); + if (custom == null || custom.isEmpty()) { + return Optional.empty(); + } + + return metadata.fromCustomData(custom, field); + } +} diff --git a/server/src/main/java/org/opensearch/index/fielddomain/DateRangeFieldDomain.java b/server/src/main/java/org/opensearch/index/fielddomain/DateRangeFieldDomain.java new file mode 100644 index 0000000000000..5d84b92ff280c --- /dev/null +++ b/server/src/main/java/org/opensearch/index/fielddomain/DateRangeFieldDomain.java @@ -0,0 +1,165 @@ +/* + * 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.index.fielddomain; + +import org.opensearch.common.annotation.ExperimentalApi; +import org.opensearch.index.mapper.DateFieldMapper; + +import java.util.Locale; +import java.util.Objects; + +/** + * Field domain implementation for date-like range metadata. + * + * The stored min/max values are serialized as strings so the cluster-state custom metadata remains a simple + * {@code Map}. The evaluator interprets them using the configured resolution. + * + * @opensearch.experimental + */ +@ExperimentalApi +public final class DateRangeFieldDomain implements FieldDomain { + /** + * Metadata type for date range field domains. + */ + public static final String TYPE = "date_range"; + + private static final String DEFAULT_RESOLUTION = DateFieldMapper.Resolution.MILLISECONDS.name().toLowerCase(Locale.ROOT); + + private final String field; + private final String min; + private final String max; + private final boolean finalized; + private final String source; + private final String format; + private final String resolution; + + /** + * Creates millisecond-resolution date range bounds. + * + * @param field field name this domain describes + * @param min inclusive lower index bound in epoch milliseconds + * @param max inclusive upper index bound in epoch milliseconds + * @param finalized whether this domain is trusted as complete for consumers that require finalized metadata + * @param source optional producer identifier + */ + public DateRangeFieldDomain(String field, long min, long max, boolean finalized, String source) { + this(field, Long.toString(min), Long.toString(max), finalized, source, null, DEFAULT_RESOLUTION); + } + + /** + * Creates date range bounds using serialized bound values. + * + * @param field field name this domain describes + * @param min inclusive lower index bound + * @param max inclusive upper index bound + * @param finalized whether this domain is trusted as complete for consumers that require finalized metadata + * @param source optional producer identifier + * @param format optional date format used by the bound producer + * @param resolution date resolution, for example milliseconds or nanoseconds + */ + public DateRangeFieldDomain(String field, String min, String max, boolean finalized, String source, String format, String resolution) { + this.field = requireNonEmpty(field, "field"); + this.min = requireNonEmpty(min, "min"); + this.max = requireNonEmpty(max, "max"); + validateBounds(this.min, this.max); + this.finalized = finalized; + this.source = source; + this.format = format; + this.resolution = normalizeResolution(resolution); + } + + @Override + public String field() { + return field; + } + + /** + * Returns {@link #TYPE}. + */ + @Override + public String type() { + return TYPE; + } + + /** + * Inclusive lower index bound, serialized as metadata. + */ + public String min() { + return min; + } + + /** + * Inclusive upper index bound, serialized as metadata. + */ + public String max() { + return max; + } + + @Override + public boolean finalized() { + return finalized; + } + + /** + * Optional identifier for the component that produced these bounds. + */ + public String source() { + return source; + } + + /** + * Optional date format used to interpret query and index bounds. + */ + public String format() { + return format; + } + + /** + * Optional date resolution used to interpret numeric bounds. + */ + public String resolution() { + return resolution; + } + + private static String requireNonEmpty(String value, String name) { + Objects.requireNonNull(value, name + " must not be null"); + if (value.isEmpty()) { + throw new IllegalArgumentException(name + " must not be empty"); + } + return value; + } + + private static void validateBounds(String min, String max) { + long parsedMin; + long parsedMax; + try { + parsedMin = Long.parseLong(min); + parsedMax = Long.parseLong(max); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("date range field domain bounds must be numeric long values", e); + } + if (parsedMin > parsedMax) { + throw new IllegalArgumentException("date range field domain min must be less than or equal to max"); + } + } + + private static String normalizeResolution(String resolution) { + if (resolution == null || resolution.isBlank()) { + throw new IllegalArgumentException("resolution must not be null or empty"); + } + + String configured = resolution.toLowerCase(Locale.ROOT); + for (DateFieldMapper.Resolution dateResolution : DateFieldMapper.Resolution.values()) { + if (dateResolution.name().toLowerCase(Locale.ROOT).equals(configured) || dateResolution.type().equals(configured)) { + return dateResolution.name().toLowerCase(Locale.ROOT); + } + } + throw new IllegalArgumentException("unsupported date range field domain resolution [" + resolution + "]"); + } +} diff --git a/server/src/main/java/org/opensearch/index/fielddomain/DateRangeFieldDomainParser.java b/server/src/main/java/org/opensearch/index/fielddomain/DateRangeFieldDomainParser.java new file mode 100644 index 0000000000000..f68cd18263abb --- /dev/null +++ b/server/src/main/java/org/opensearch/index/fielddomain/DateRangeFieldDomainParser.java @@ -0,0 +1,108 @@ +/* + * 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.index.fielddomain; + +import org.opensearch.common.annotation.ExperimentalApi; + +import java.util.Map; +import java.util.Optional; + +/** + * Reader and writer for {@link DateRangeFieldDomain} custom metadata. + * + * @opensearch.experimental + */ +@ExperimentalApi +public final class DateRangeFieldDomainParser implements FieldDomainParser { + private static final String KEY_MIN = "min"; + private static final String KEY_MAX = "max"; + private static final String KEY_FINALIZED = "finalized"; + private static final String KEY_SOURCE = "source"; + private static final String KEY_FORMAT = "format"; + private static final String KEY_RESOLUTION = "resolution"; + + /** + * Returns {@link DateRangeFieldDomain#TYPE}. + */ + @Override + public String type() { + return DateRangeFieldDomain.TYPE; + } + + /** + * Reads one field's date range bounds from a flat custom metadata map. + */ + @Override + public Optional fromCustomData(String field, Map customData, String prefix) { + String min = customData.get(prefix + KEY_MIN); + String max = customData.get(prefix + KEY_MAX); + String finalized = customData.get(prefix + KEY_FINALIZED); + String source = customData.get(prefix + KEY_SOURCE); + String format = customData.get(prefix + KEY_FORMAT); + String resolution = customData.get(prefix + KEY_RESOLUTION); + + if (min == null || max == null || finalized == null) { + return Optional.empty(); + } + + Optional parsedFinalized = parseBoolean(finalized); + if (parsedFinalized.isEmpty()) { + return Optional.empty(); + } + + try { + return Optional.of(new DateRangeFieldDomain(field, min, max, parsedFinalized.get(), source, format, resolution)); + } catch (RuntimeException e) { + return Optional.empty(); + } + } + + /** + * Writes date range bounds into a flat custom metadata map. + */ + @Override + public void writeToCustomData(DateRangeFieldDomain domain, Map targetCustomData, String prefix) { + targetCustomData.put(prefix + KEY_MIN, domain.min()); + targetCustomData.put(prefix + KEY_MAX, domain.max()); + targetCustomData.put(prefix + KEY_FINALIZED, Boolean.toString(domain.finalized())); + + if (domain.source() != null) { + targetCustomData.put(prefix + KEY_SOURCE, domain.source()); + } + if (domain.format() != null) { + targetCustomData.put(prefix + KEY_FORMAT, domain.format()); + } + if (domain.resolution() != null) { + targetCustomData.put(prefix + KEY_RESOLUTION, domain.resolution()); + } + } + + /** + * Removes all date range keys for a field prefix. + */ + @Override + public void removeFieldKeys(Map target, String prefix) { + target.remove(prefix + KEY_MIN); + target.remove(prefix + KEY_MAX); + target.remove(prefix + KEY_FINALIZED); + target.remove(prefix + KEY_SOURCE); + target.remove(prefix + KEY_FORMAT); + target.remove(prefix + KEY_RESOLUTION); + } + + private static Optional parseBoolean(String value) { + if ("true".equals(value)) { + return Optional.of(Boolean.TRUE); + } + if ("false".equals(value)) { + return Optional.of(Boolean.FALSE); + } + return Optional.empty(); + } +} diff --git a/server/src/main/java/org/opensearch/index/fielddomain/FieldDomain.java b/server/src/main/java/org/opensearch/index/fielddomain/FieldDomain.java new file mode 100644 index 0000000000000..844d9ecddc327 --- /dev/null +++ b/server/src/main/java/org/opensearch/index/fielddomain/FieldDomain.java @@ -0,0 +1,43 @@ +/* + * 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.index.fielddomain; + +import org.opensearch.common.annotation.ExperimentalApi; + +/** + * Index-level metadata describing the value domain of one field in one concrete index. + * + * A field domain is an index-side summary of values that documents in the index may contain for a field. For example, + * a date field domain can describe the minimum and maximum timestamp values present in the index, while a future term + * domain could describe a trusted set of known keyword values. + * + * The metadata is intentionally generic. Different OpenSearch features may use it for different purposes, such as + * routing decisions, request planning, or search optimizations. Implementations are type-specific, and consumers are + * responsible for interpreting only the domain types they understand. Incomplete, stale, unsupported, or untrusted + * domains must be treated conservatively by consumers. + * + * @opensearch.experimental + */ +@ExperimentalApi +public interface FieldDomain { + /** + * Field name this domain describes. + */ + String field(); + + /** + * Metadata type used to select the parser/evaluator implementation. + */ + String type(); + + /** + * Whether this domain is trusted as complete for consumers that require closed index-level value metadata. + */ + boolean finalized(); +} diff --git a/server/src/main/java/org/opensearch/index/fielddomain/FieldDomainParser.java b/server/src/main/java/org/opensearch/index/fielddomain/FieldDomainParser.java new file mode 100644 index 0000000000000..fe7cf8a2340ab --- /dev/null +++ b/server/src/main/java/org/opensearch/index/fielddomain/FieldDomainParser.java @@ -0,0 +1,54 @@ +/* + * 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.index.fielddomain; + +import org.opensearch.common.annotation.ExperimentalApi; + +import java.util.Map; +import java.util.Optional; + +/** + * Reader and writer for one {@link FieldDomain} metadata type. + * + * Implementations convert between typed field-domain objects and the flat {@code Map} stored in + * {@link org.opensearch.cluster.metadata.IndexMetadata} custom data. + * + * @opensearch.experimental + */ +@ExperimentalApi +public interface FieldDomainParser { + /** + * Metadata type handled by this parser. + */ + String type(); + + /** + * Reads a field domain from custom metadata. + * + * @param field field name being parsed + * @param customData full custom metadata map for {@code index_field_domains} + * @param prefix field-specific metadata prefix + * @return field domain when the metadata is complete and valid; otherwise empty + */ + Optional fromCustomData(String field, Map customData, String prefix); + + /** + * Writes a field domain into a target custom metadata map. + * + * @param domain domain to write + * @param targetCustomData target metadata map + * @param prefix field-specific metadata prefix + */ + void writeToCustomData(T domain, Map targetCustomData, String prefix); + + /** + * Removes keys owned by this parser for one field from a metadata map. + */ + void removeFieldKeys(Map target, String prefix); +} diff --git a/server/src/main/java/org/opensearch/index/fielddomain/FieldDomainParserRegistry.java b/server/src/main/java/org/opensearch/index/fielddomain/FieldDomainParserRegistry.java new file mode 100644 index 0000000000000..09e99f347079e --- /dev/null +++ b/server/src/main/java/org/opensearch/index/fielddomain/FieldDomainParserRegistry.java @@ -0,0 +1,145 @@ +/* + * 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.index.fielddomain; + +import org.opensearch.common.annotation.ExperimentalApi; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * Registry that maps field-domain metadata type names to parsers. + * + * The default registry currently contains only built-in parsers. Plugin registration for additional parser and evaluator + * implementations is not wired yet. + * + * @opensearch.experimental + */ +@ExperimentalApi +public final class FieldDomainParserRegistry { + + private static final FieldDomainParserRegistry DEFAULT = new FieldDomainParserRegistry( + List.of(entry(DateRangeFieldDomain.class, new DateRangeFieldDomainParser())) + ); + + private final Map> entries; + + /** + * Creates a registry from the supplied parser entries. + * + * @param entries parser entries keyed by their {@link FieldDomainParser#type()} + */ + public FieldDomainParserRegistry(List> entries) { + Objects.requireNonNull(entries, "entries must not be null"); + + Map> typeToParser = new LinkedHashMap<>(); + for (Entry entry : entries) { + Objects.requireNonNull(entry, "entry must not be null"); + Entry previous = typeToParser.put(entry.type(), entry); + if (previous != null) { + throw new IllegalArgumentException("field domain parser [" + entry.type() + "] is already registered"); + } + } + this.entries = Map.copyOf(typeToParser); + } + + /** + * Creates a typed parser entry for registration. + */ + public static Entry entry(Class domainClass, FieldDomainParser parser) { + return new Entry<>(domainClass, parser); + } + + /** + * Returns the built-in parser registry. + */ + public static FieldDomainParserRegistry defaultRegistry() { + return DEFAULT; + } + + /** + * Returns whether a parser is registered for a metadata type. + */ + public boolean contains(String type) { + return entries.containsKey(type); + } + + /** + * Reads field-domain metadata with the parser registered for the supplied type. + */ + public Optional fromCustomData(String type, String field, Map customData, String prefix) { + Entry entry = entries.get(type); + if (entry == null) { + return Optional.empty(); + } + return entry.fromCustomData(field, customData, prefix); + } + + /** + * Writes a field domain with the parser registered for its metadata type. + */ + public void writeToCustomData(FieldDomain domain, Map targetCustomData, String prefix) { + Objects.requireNonNull(domain, "domain must not be null"); + + Entry entry = entries.get(domain.type()); + if (entry == null) { + throw new IllegalArgumentException("unsupported field domain type [" + domain.type() + "]"); + } + entry.writeToCustomData(domain, targetCustomData, prefix); + } + + /** + * Removes all known parser-owned keys for a field prefix. + */ + public void removeFieldKeys(Map target, String prefix) { + for (Entry entry : entries.values()) { + entry.removeFieldKeys(target, prefix); + } + } + + /** + * Typed parser registration entry. + * + * @opensearch.experimental + */ + @ExperimentalApi + public static final class Entry { + private final Class domainClass; + private final FieldDomainParser parser; + + private Entry(Class domainClass, FieldDomainParser parser) { + this.domainClass = Objects.requireNonNull(domainClass, "domainClass must not be null"); + this.parser = Objects.requireNonNull(parser, "parser must not be null"); + } + + private String type() { + return parser.type(); + } + + private Optional fromCustomData(String field, Map customData, String prefix) { + return parser.fromCustomData(field, customData, prefix).map(FieldDomain.class::cast); + } + + private void writeToCustomData(FieldDomain domain, Map targetCustomData, String prefix) { + if (domainClass.isInstance(domain) == false) { + throw new IllegalArgumentException( + "field domain class [" + domain.getClass().getName() + "] is not supported by parser [" + type() + "]" + ); + } + parser.writeToCustomData(domainClass.cast(domain), targetCustomData, prefix); + } + + private void removeFieldKeys(Map target, String prefix) { + parser.removeFieldKeys(target, prefix); + } + } +} diff --git a/server/src/main/java/org/opensearch/index/fielddomain/FieldDomainProvider.java b/server/src/main/java/org/opensearch/index/fielddomain/FieldDomainProvider.java new file mode 100644 index 0000000000000..50d9b53f43d32 --- /dev/null +++ b/server/src/main/java/org/opensearch/index/fielddomain/FieldDomainProvider.java @@ -0,0 +1,36 @@ +/* + * 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.index.fielddomain; + +import org.opensearch.cluster.ClusterState; +import org.opensearch.common.annotation.ExperimentalApi; + +import java.util.Optional; + +/** + * Resolves index-level field domains for a concrete index from a metadata source. + * + * @opensearch.experimental + */ +@ExperimentalApi +public interface FieldDomainProvider { + + /** + * Returns field domains for the given concrete index and field. + * + * The {@code indexName} is resolved against the supplied {@link ClusterState}. Callers that need UUID-level identity + * guarantees must ensure the name refers to the intended concrete index in that same cluster state. + * + * @param clusterState cluster state visible to the coordinator + * @param indexName concrete index name + * @param field field name + * @return parsed domain when present and supported; otherwise empty + */ + Optional getDomain(ClusterState clusterState, String indexName, String field); +} diff --git a/server/src/main/java/org/opensearch/index/fielddomain/IndexFieldDomainMetadata.java b/server/src/main/java/org/opensearch/index/fielddomain/IndexFieldDomainMetadata.java new file mode 100644 index 0000000000000..e3031af71e8a6 --- /dev/null +++ b/server/src/main/java/org/opensearch/index/fielddomain/IndexFieldDomainMetadata.java @@ -0,0 +1,124 @@ +/* + * 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.index.fielddomain; + +import org.opensearch.cluster.metadata.IndexMetadata; +import org.opensearch.common.annotation.ExperimentalApi; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * Codec for the {@code index_field_domains} index custom metadata. + * + * The metadata is stored as a flat {@code Map} under {@link IndexMetadata#getCustomData(String)}. + * This class is the single place that translates between that wire shape and typed {@link FieldDomain} objects. + * + * @opensearch.experimental + */ +@ExperimentalApi +public final class IndexFieldDomainMetadata { + /** + * Custom metadata key used on {@link IndexMetadata}. + */ + public static final String CUSTOM_KEY = "index_field_domains"; + + private static final IndexFieldDomainMetadata INSTANCE = new IndexFieldDomainMetadata(); + + private static final String FIELDS_PREFIX = "fields."; + private static final String KEY_TYPE = "type"; + + private final FieldDomainParserRegistry parserRegistry; + + private IndexFieldDomainMetadata() { + this(FieldDomainParserRegistry.defaultRegistry()); + } + + IndexFieldDomainMetadata(FieldDomainParserRegistry parserRegistry) { + this.parserRegistry = Objects.requireNonNull(parserRegistry, "parserRegistry must not be null"); + } + + /** + * Returns the built-in metadata codec. + */ + public static IndexFieldDomainMetadata getInstance() { + return INSTANCE; + } + + /** + * Parses field domains for a single field from index custom metadata. + * + * This method is intentionally lenient for search-time consumers: missing, malformed, or unsupported metadata for + * the requested field returns {@link Optional#empty()} so callers can fall back to conservative behavior. + * + * @param customData custom metadata map stored under {@link #CUSTOM_KEY} + * @param field field name to parse + * @return parsed domain when metadata is present, complete, and supported; otherwise empty + */ + public Optional fromCustomData(Map customData, String field) { + if (customData == null || customData.isEmpty() || field == null || field.isEmpty()) { + return Optional.empty(); + } + + String prefix = fieldPrefix(field); + String type = customData.get(prefix + KEY_TYPE); + if (type == null || type.isEmpty()) { + return Optional.empty(); + } + + return parserRegistry.fromCustomData(type, field, customData, prefix); + } + + /** + * Encodes one field-domain object into a custom metadata map containing only that field. + */ + public Map toCustomData(FieldDomain domain) { + Objects.requireNonNull(domain, "domain must not be null"); + + Map customData = new HashMap<>(); + String prefix = fieldPrefix(domain.field()); + customData.put(prefix + KEY_TYPE, domain.type()); + parserRegistry.writeToCustomData(domain, customData, prefix); + return Map.copyOf(customData); + } + + /** + * Returns updated index metadata with the supplied field domain inserted under {@link #CUSTOM_KEY}. + * + * Existing metadata for other fields is preserved. Existing metadata for the same field and known parser keys is + * replaced so stale values do not survive type changes. + */ + public IndexMetadata putFieldDomain(IndexMetadata metadata, FieldDomain domain) { + Objects.requireNonNull(metadata, "metadata must not be null"); + Objects.requireNonNull(domain, "domain must not be null"); + + Map existing = metadata.getCustomData(CUSTOM_KEY); + Map updated = existing == null ? new HashMap<>() : new HashMap<>(existing); + + String prefix = fieldPrefix(domain.field()); + removeKnownFieldKeys(updated, prefix); + updated.putAll(toCustomData(domain)); + + return IndexMetadata.builder(metadata).putCustom(CUSTOM_KEY, updated).build(); + } + + private void removeKnownFieldKeys(Map target, String prefix) { + target.remove(prefix + KEY_TYPE); + parserRegistry.removeFieldKeys(target, prefix); + } + + private static String fieldPrefix(String field) { + if (field == null || field.isEmpty()) { + throw new IllegalArgumentException("field must not be null or empty"); + } + return FIELDS_PREFIX + field + "."; + } +} diff --git a/server/src/main/java/org/opensearch/index/fielddomain/package-info.java b/server/src/main/java/org/opensearch/index/fielddomain/package-info.java new file mode 100644 index 0000000000000..1fdac7cd01f89 --- /dev/null +++ b/server/src/main/java/org/opensearch/index/fielddomain/package-info.java @@ -0,0 +1,10 @@ +/* + * 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. + */ + +/** Index-level field-domain metadata and codecs. */ +package org.opensearch.index.fielddomain; diff --git a/server/src/main/java/org/opensearch/index/get/DocumentLookupResult.java b/server/src/main/java/org/opensearch/index/get/DocumentLookupResult.java new file mode 100644 index 0000000000000..aedda9afe46a9 --- /dev/null +++ b/server/src/main/java/org/opensearch/index/get/DocumentLookupResult.java @@ -0,0 +1,141 @@ +/* + * 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.index.get; + +import org.opensearch.common.Nullable; +import org.opensearch.common.annotation.ExperimentalApi; +import org.opensearch.common.document.DocumentField; +import org.opensearch.common.lucene.uid.Versions; +import org.opensearch.common.lucene.uid.VersionsAndSeqNoResolver.DocIdAndVersion; +import org.opensearch.core.common.bytes.BytesReference; +import org.opensearch.index.engine.Engine; + +import java.util.Map; +import java.util.Objects; + +import static org.opensearch.index.seqno.SequenceNumbers.UNASSIGNED_PRIMARY_TERM; +import static org.opensearch.index.seqno.SequenceNumbers.UNASSIGNED_SEQ_NO; + +/** + * Minimal result of get-by-id lookup. Decouples callers from + * {@link Engine.GetResult}. + * + * @opensearch.experimental + */ +@ExperimentalApi +public record DocumentLookupResult(String id, long version, boolean exists, @Nullable BytesReference source, long seqNo, long primaryTerm, + Map documentFields, Map metadataFields) { + + public static DocumentLookupResult notFound(String id) { + return new DocumentLookupResult( + id, + Versions.NOT_FOUND, + false, + null, + UNASSIGNED_SEQ_NO, + UNASSIGNED_PRIMARY_TERM, + Map.of(), + Map.of() + ); + } + + public DocumentLookupResult( + String id, + long version, + boolean exists, + @Nullable BytesReference source, + long seqNo, + long primaryTerm, + Map documentFields, + Map metadataFields + ) { + this.id = id; + this.version = version; + this.exists = exists; + this.source = source; + this.seqNo = seqNo; + this.primaryTerm = primaryTerm; + this.documentFields = documentFields == null ? Map.of() : documentFields; + this.metadataFields = metadataFields == null ? Map.of() : metadataFields; + } + + /** + * Wraps this lookup as a {@link PreMaterialized} {@link Engine.GetResult} so the get-by-id path + * shares the {@code IndexShard.get(Engine.Get)} return type. The synthesized + * {@link DocIdAndVersion} carries version/seqNo/primaryTerm only; its reader/docId are unset + * and must not be dereferenced. + */ + public Engine.GetResult toGetResult() { + return new PreMaterialized(this); + } + + /** + * {@link Engine.GetResult} carrying a {@link DocumentLookupResult}. {@link ShardGetService} + * detects it via {@code instanceof} and materializes source/fields from the lookup instead of + * the engine stored-fields path. Has no searcher/docId — those accessors throw. + */ + public static final class PreMaterialized extends Engine.GetResult { + private final DocumentLookupResult lookup; + + private PreMaterialized(DocumentLookupResult lookup) { + super(null, new DocIdAndVersion(-1, lookup.version, lookup.seqNo, lookup.primaryTerm, null, 0), false); + this.lookup = lookup; + } + + public DocumentLookupResult lookup() { + return lookup; + } + + @Override + public Engine.Searcher searcher() { + throw new UnsupportedOperationException("PreMaterialized has no searcher"); + } + + @Override + public DocIdAndVersion docIdAndVersion() { + throw new UnsupportedOperationException("PreMaterialized has no docId"); + } + + @Override + public boolean exists() { + return lookup.exists; + } + + @Override + public long version() { + return lookup.version; + } + + @Override + public void close() { + // no searcher to release + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o instanceof DocumentLookupResult == false) return false; + DocumentLookupResult other = (DocumentLookupResult) o; + return version == other.version + && exists == other.exists + && seqNo == other.seqNo + && primaryTerm == other.primaryTerm + && Objects.equals(id, other.id) + && Objects.equals(source, other.source) + && Objects.equals(documentFields, other.documentFields) + && Objects.equals(metadataFields, other.metadataFields); + } + + @Override + public int hashCode() { + return Objects.hash(id, version, exists, source, seqNo, primaryTerm, documentFields, metadataFields); + } + +} diff --git a/server/src/main/java/org/opensearch/index/get/ShardGetService.java b/server/src/main/java/org/opensearch/index/get/ShardGetService.java index c4d765c4e0358..6f8bbc92eb026 100644 --- a/server/src/main/java/org/opensearch/index/get/ShardGetService.java +++ b/server/src/main/java/org/opensearch/index/get/ShardGetService.java @@ -236,11 +236,64 @@ private GetResult innerGet( if (get == null || get.exists() == false) { return new GetResult(shardId.getIndexName(), id, UNASSIGNED_SEQ_NO, UNASSIGNED_PRIMARY_TERM, -1, false, null, null, null); } + if (get instanceof DocumentLookupResult.PreMaterialized) { + return buildFromLookup(((DocumentLookupResult.PreMaterialized) get).lookup(), fetchSourceContext); + } // break between having loaded it from translog (so we only have _source), and having a document to load return innerGetLoadFromStoredFields(id, gFields, fetchSourceContext, get, mapperService); } } + /** + * Builds a {@link GetResult} from a pre-materialized {@link DocumentLookupResult}, applying the + * request-level {@link FetchSourceContext} filters. Used by the pluggable get-by-id path. + */ + private GetResult buildFromLookup(DocumentLookupResult lookup, FetchSourceContext fetchSourceContext) { + BytesReference source = lookup.source(); + if (fetchSourceContext.fetchSource() == false) { + source = null; + } else { + source = applySourceFilter(source, fetchSourceContext); + } + return new GetResult( + shardId.getIndexName(), + lookup.id(), + lookup.seqNo(), + lookup.primaryTerm(), + lookup.version(), + lookup.exists(), + source, + lookup.documentFields(), + lookup.metadataFields() + ); + } + + /** + * Applies request-level {@code includes}/{@code excludes} source filtering via + * {@link XContentMapValues}. Returns the source unchanged when no filters + * are set, and {@code null} when the input is {@code null}. + */ + private static BytesReference applySourceFilter(BytesReference source, FetchSourceContext fetchSourceContext) { + if (source == null) { + return null; + } + if (fetchSourceContext.includes().length > 0 || fetchSourceContext.excludes().length > 0) { + Tuple> typeMapTuple = XContentHelper.convertToMap(source, true); + XContentType sourceContentType = typeMapTuple.v1(); + Map sourceAsMap = XContentMapValues.filter( + typeMapTuple.v2(), + fetchSourceContext.includes(), + fetchSourceContext.excludes() + ); + try { + return BytesReference.bytes(MediaTypeRegistry.contentBuilder(sourceContentType).map(sourceAsMap)); + } catch (IOException e) { + throw new OpenSearchException("Failed to apply source includes/excludes filter", e); + } + } + return source; + } + private GetResult innerGetLoadFromStoredFields( String id, String[] storedFields, @@ -369,28 +422,10 @@ private GetResult innerGetLoadFromStoredFields( } } - if (source != null) { - // apply request-level source filtering - if (fetchSourceContext.fetchSource() == false) { - source = null; - } else if (fetchSourceContext.includes().length > 0 || fetchSourceContext.excludes().length > 0) { - Map sourceAsMap; - // TODO: The source might be parsed and available in the sourceLookup but that one uses unordered maps so different. - // Do we care? - Tuple> typeMapTuple = XContentHelper.convertToMap(source, true); - XContentType sourceContentType = typeMapTuple.v1(); - sourceAsMap = typeMapTuple.v2(); - sourceAsMap = XContentMapValues.filter(sourceAsMap, fetchSourceContext.includes(), fetchSourceContext.excludes()); - try { - source = BytesReference.bytes(MediaTypeRegistry.contentBuilder(sourceContentType).map(sourceAsMap)); - } catch (IOException e) { - throw new OpenSearchException("Failed to get id [" + id + "] with includes/excludes set", e); - } - } - } - - if (!fetchSourceContext.fetchSource()) { + if (fetchSourceContext.fetchSource() == false) { source = null; + } else { + source = applySourceFilter(source, fetchSourceContext); } if (source != null && get.isFromTranslog()) { @@ -402,19 +437,8 @@ private GetResult innerGetLoadFromStoredFields( } } - if (source != null && (fetchSourceContext.includes().length > 0 || fetchSourceContext.excludes().length > 0)) { - Map sourceAsMap; - // TODO: The source might parsed and available in the sourceLookup but that one uses unordered maps so different. Do we care? - Tuple> typeMapTuple = XContentHelper.convertToMap(source, true); - XContentType sourceContentType = typeMapTuple.v1(); - sourceAsMap = typeMapTuple.v2(); - sourceAsMap = XContentMapValues.filter(sourceAsMap, fetchSourceContext.includes(), fetchSourceContext.excludes()); - try { - source = BytesReference.bytes(MediaTypeRegistry.contentBuilder(sourceContentType).map(sourceAsMap)); - } catch (IOException e) { - throw new OpenSearchException("Failed to get id [" + id + "] with includes/excludes set", e); - } - } + // Re-apply includes/excludes after translog reapply (which may have re-added excluded fields). + source = applySourceFilter(source, fetchSourceContext); return new GetResult( shardId.getIndexName(), diff --git a/server/src/main/java/org/opensearch/index/mapper/extrasource/AbstractPackedArray.java b/server/src/main/java/org/opensearch/index/mapper/extrasource/AbstractPackedArray.java new file mode 100644 index 0000000000000..3ee78f726a618 --- /dev/null +++ b/server/src/main/java/org/opensearch/index/mapper/extrasource/AbstractPackedArray.java @@ -0,0 +1,139 @@ +/* + * 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.index.mapper.extrasource; + +import org.opensearch.core.common.bytes.BytesArray; +import org.opensearch.core.common.bytes.BytesReference; +import org.opensearch.core.common.io.stream.StreamOutput; + +import java.io.IOException; +import java.util.Objects; + +/** + * Shared storage and little-endian decoding for packed primitive arrays. + * + *

    Element access is intentionally left to the concrete primitive types instead of + * being modeled with generics. Java generics would require boxed values such as + * {@code Integer}, {@code Long}, {@code Float}, and {@code Double}; keeping + * type-specific accessors avoids that overhead on indexing paths.

    + * + *

    The repeated {@code get(int)} and array materialization loops in subclasses are + * deliberate. Extracting them into a generic or callback-based helper would add boxing + * or per-element indirection in hot indexing paths.

    + */ +abstract class AbstractPackedArray { + + protected static final class ResolvedBytes { + final byte[] bytes; + final int offset; + + private ResolvedBytes(byte[] bytes, int offset) { + this.bytes = bytes; + this.offset = offset; + } + } + + protected final int dimension; + + private final BytesReference packed; + private volatile ResolvedBytes resolvedBytes; + + AbstractPackedArray(BytesReference packed, int dimension, byte[] bytes, int bytesOffset, int bytesPerElement, String valueType) { + this.packed = Objects.requireNonNull(packed, "packed must not be null"); + this.dimension = dimension; + if (bytes != null) { + Objects.checkFromIndexSize(bytesOffset, packed.length(), bytes.length); + } + this.resolvedBytes = bytes == null ? null : new ResolvedBytes(bytes, bytesOffset); + validate(packed.length(), dimension, bytesPerElement, valueType); + } + + public final int dimension() { + return dimension; + } + + public final boolean isPackedLE() { + return true; + } + + public final BytesReference packedBytes() { + return packed; + } + + public final void writePayloadTo(StreamOutput out) throws IOException { + out.writeVInt(dimension()); + out.writeBytesReference(packed); + } + + protected final ResolvedBytes ensureBytes() { + ResolvedBytes view = resolvedBytes; + if (view != null) { + return view; + } + + // This cache is intentionally lock-free. The expected indexing path consumes each + // value from one thread; if a value is shared concurrently, duplicate first-time + // resolution is harmless because all resolved views contain the same bytes. + byte[] arr; + int off; + if (packed instanceof BytesArray ba) { + arr = ba.array(); + off = ba.offset(); + } else { + arr = BytesReference.toBytes(packed); + off = 0; + } + + view = new ResolvedBytes(arr, off); + resolvedBytes = view; + return view; + } + + protected final void checkIndex(int i) { + if (i < 0 || i >= dimension) { + throw new IndexOutOfBoundsException("i=" + i + " dim=" + dimension); + } + } + + protected static int decodeIntLEAt(final byte[] a, final int p) { + return (a[p] & 0xFF) | ((a[p + 1] & 0xFF) << 8) | ((a[p + 2] & 0xFF) << 16) | ((a[p + 3] & 0xFF) << 24); + } + + protected static long decodeLongLEAt(final byte[] a, final int p) { + // Decode bytes directly; mask each signed byte as unsigned before shifting. + return ((long) a[p] & 0xFFL) | (((long) a[p + 1] & 0xFFL) << 8) | (((long) a[p + 2] & 0xFFL) << 16) | (((long) a[p + 3] & 0xFFL) + << 24) | (((long) a[p + 4] & 0xFFL) << 32) | (((long) a[p + 5] & 0xFFL) << 40) | (((long) a[p + 6] & 0xFFL) << 48) + | (((long) a[p + 7] & 0xFFL) << 56); + } + + protected static float decodeFloatLEAt(final byte[] a, final int p) { + return Float.intBitsToFloat(decodeIntLEAt(a, p)); + } + + protected static double decodeDoubleLEAt(final byte[] a, final int p) { + return Double.longBitsToDouble(decodeLongLEAt(a, p)); + } + + private static void validate(int byteLen, int dim, int bytesPerElement, String valueType) { + if (dim < 0) { + throw new IllegalArgumentException("dimension must be >= 0 (got " + dim + ")"); + } + final int expected; + try { + expected = Math.multiplyExact(dim, bytesPerElement); + } catch (ArithmeticException e) { + throw new IllegalArgumentException("dimension too large: " + dim, e); + } + if (byteLen != expected) { + throw new IllegalArgumentException( + "Bad packed " + valueType + " length=" + byteLen + " expected=" + expected + " (dim=" + dim + ")" + ); + } + } +} diff --git a/server/src/main/java/org/opensearch/index/mapper/extrasource/AbstractPrimitiveArray.java b/server/src/main/java/org/opensearch/index/mapper/extrasource/AbstractPrimitiveArray.java new file mode 100644 index 0000000000000..b176bdb70fd74 --- /dev/null +++ b/server/src/main/java/org/opensearch/index/mapper/extrasource/AbstractPrimitiveArray.java @@ -0,0 +1,44 @@ +/* + * 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.index.mapper.extrasource; + +import org.opensearch.core.common.bytes.BytesReference; + +/** + * Shared behavior for arrays backed by Java primitive arrays. + * + *

    The concrete classes keep their own primitive array type instead of using a + * generic base class. Java generics would require boxed values such as {@code Integer}, + * {@code Long}, {@code Float}, and {@code Double}; keeping primitive arrays and + * type-specific accessors avoids that overhead on indexing paths.

    + * + *

    The small amount of repeated primitive-specific code is deliberate. Sharing it + * through generic helpers would require boxed values or indirect callbacks where callers + * currently use primitive arrays directly.

    + */ +abstract class AbstractPrimitiveArray { + + private final int dimension; + + AbstractPrimitiveArray(int dimension) { + this.dimension = dimension; + } + + public final int dimension() { + return dimension; + } + + public final boolean isPackedLE() { + return false; + } + + public final BytesReference packedBytes() { + throw new IllegalStateException("Not packed"); + } +} diff --git a/server/src/main/java/org/opensearch/index/mapper/extrasource/DoubleArrayValue.java b/server/src/main/java/org/opensearch/index/mapper/extrasource/DoubleArrayValue.java new file mode 100644 index 0000000000000..be0099953349b --- /dev/null +++ b/server/src/main/java/org/opensearch/index/mapper/extrasource/DoubleArrayValue.java @@ -0,0 +1,108 @@ +/* + * 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.index.mapper.extrasource; + +import org.opensearch.core.common.bytes.BytesReference; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; + +import java.io.IOException; + +/** + * A double array value used in {@link ExtraFieldValue}. + * + *

    Supports both primitive (decoded) and packed (little-endian) representations.

    + */ +public non-sealed interface DoubleArrayValue extends ExtraFieldValue { + + @Override + default Type type() { + return Type.DOUBLE_ARRAY; + } + + /** + * Creates a {@link DoubleArrayValue} from packed little-endian bytes. + * + * @param packed the packed double bytes (dimension * 8 bytes) + * @param dimension the number of double elements + * @return a double-array value backed by the provided bytes + * @throws IllegalArgumentException if the byte length does not match {@code dimension * 8} + */ + static DoubleArrayValue fromPackedBytes(BytesReference packed, int dimension) { + return PackedDoubleArray.fromPackedBytes(packed, dimension); + } + + /** + * Creates a {@link DoubleArrayValue} from a packed little-endian byte array. + * + * @param packed the packed double bytes (dimension * 8 bytes) + * @param dimension the number of double elements + * @return a double-array value backed by the provided array + * @throws IllegalArgumentException if the byte length does not match {@code dimension * 8} + */ + static DoubleArrayValue fromPackedArray(byte[] packed, int dimension) { + return PackedDoubleArray.fromPackedArray(packed, dimension); + } + + /** + * Creates a {@link DoubleArrayValue} from a double array. + * + * @param values the double values + * @return a double-array value backed by the provided array + */ + static DoubleArrayValue fromDoubleArray(double[] values) { + return new PrimitiveDoubleArray(values); + } + + /** Number of double elements. */ + int dimension(); + + /** True if backed by little-endian packed bytes. */ + boolean isPackedLE(); + + /** + * Packed bytes for the double vector (LE, 8 * dimension bytes). + * Only valid when isPackedLE() == true. + */ + BytesReference packedBytes(); + + /** Random access (packed reads 8 bytes at i*8; non-packed returns v[i]). */ + double get(int i); + + /** + * Convenience; allocates a double array for packed values. + * Zero-copy decoding is used when the packed value is backed by a usable byte array. + * For other BytesReference implementations, decoding may lazily materialize one cached + * byte array. + */ + double[] asDoubleArray(); + + @Override + default int size() { + return dimension(); + } + + @Override + default void writeBodyTo(StreamOutput out) throws IOException { + out.writeBoolean(isPackedLE()); + writePayloadTo(out); + } + + void writePayloadTo(StreamOutput out) throws IOException; + + static DoubleArrayValue readBodyFrom(StreamInput in) throws IOException { + final boolean packedLE = in.readBoolean(); + if (packedLE) { + final int dim = in.readVInt(); + return PackedDoubleArray.readBodyFrom(in, dim); + } else { + return PrimitiveDoubleArray.readBodyFrom(in); + } + } +} diff --git a/server/src/main/java/org/opensearch/index/mapper/extrasource/ExtraFieldValue.java b/server/src/main/java/org/opensearch/index/mapper/extrasource/ExtraFieldValue.java index 9c084fba306ba..dc0306ce5e208 100644 --- a/server/src/main/java/org/opensearch/index/mapper/extrasource/ExtraFieldValue.java +++ b/server/src/main/java/org/opensearch/index/mapper/extrasource/ExtraFieldValue.java @@ -21,16 +21,17 @@ * directly to the indexing layer.

    */ @ExperimentalApi() -public sealed interface ExtraFieldValue permits FloatArrayValue, BytesValue { - +public sealed interface ExtraFieldValue permits BytesValue, FloatArrayValue, DoubleArrayValue, IntArrayValue, LongArrayValue { /** * Supported extra field value types. */ @ExperimentalApi enum Type { BYTES((byte) 0, BytesValue::readBodyFrom), - FLOAT_ARRAY((byte) 1, FloatArrayValue::readBodyFrom); - // TODO add double, int and long + FLOAT_ARRAY((byte) 1, FloatArrayValue::readBodyFrom), + DOUBLE_ARRAY((byte) 2, DoubleArrayValue::readBodyFrom), + INT_ARRAY((byte) 3, IntArrayValue::readBodyFrom), + LONG_ARRAY((byte) 4, LongArrayValue::readBodyFrom); private final byte id; private final Reader reader; diff --git a/server/src/main/java/org/opensearch/index/mapper/extrasource/FloatArrayValue.java b/server/src/main/java/org/opensearch/index/mapper/extrasource/FloatArrayValue.java index 024b7e9315b1c..0f8913e342ca8 100644 --- a/server/src/main/java/org/opensearch/index/mapper/extrasource/FloatArrayValue.java +++ b/server/src/main/java/org/opensearch/index/mapper/extrasource/FloatArrayValue.java @@ -76,8 +76,10 @@ static FloatArrayValue fromFloatArray(float[] values) { float get(int i); /** - * Convenience; may allocate/copy for packed (allocating float[] is unavoidable). - * Packed implementation must NOT perform an extra byte[] compaction copy. + * Convenience; allocates a float array for packed values. + * Zero-copy decoding is used when the packed value is backed by a usable byte array. + * For other BytesReference implementations, decoding may lazily materialize one cached + * byte array. */ float[] asFloatArray(); diff --git a/server/src/main/java/org/opensearch/index/mapper/extrasource/IntArrayValue.java b/server/src/main/java/org/opensearch/index/mapper/extrasource/IntArrayValue.java new file mode 100644 index 0000000000000..48bf79293d2fa --- /dev/null +++ b/server/src/main/java/org/opensearch/index/mapper/extrasource/IntArrayValue.java @@ -0,0 +1,108 @@ +/* + * 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.index.mapper.extrasource; + +import org.opensearch.core.common.bytes.BytesReference; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; + +import java.io.IOException; + +/** + * An int array value used in {@link ExtraFieldValue}. + * + *

    Supports both primitive (decoded) and packed (little-endian) representations.

    + */ +public non-sealed interface IntArrayValue extends ExtraFieldValue { + + @Override + default Type type() { + return Type.INT_ARRAY; + } + + /** + * Creates an {@link IntArrayValue} from packed little-endian bytes. + * + * @param packed the packed int bytes (dimension * 4 bytes) + * @param dimension the number of int elements + * @return an int-array value backed by the provided bytes + * @throws IllegalArgumentException if the byte length does not match {@code dimension * 4} + */ + static IntArrayValue fromPackedBytes(BytesReference packed, int dimension) { + return PackedIntArray.fromPackedBytes(packed, dimension); + } + + /** + * Creates an {@link IntArrayValue} from a packed little-endian byte array. + * + * @param packed the packed int bytes (dimension * 4 bytes) + * @param dimension the number of int elements + * @return an int-array value backed by the provided array + * @throws IllegalArgumentException if the byte length does not match {@code dimension * 4} + */ + static IntArrayValue fromPackedArray(byte[] packed, int dimension) { + return PackedIntArray.fromPackedArray(packed, dimension); + } + + /** + * Creates an {@link IntArrayValue} from an int array. + * + * @param values the int values + * @return an int-array value backed by the provided array + */ + static IntArrayValue fromIntArray(int[] values) { + return new PrimitiveIntArray(values); + } + + /** Number of int elements. */ + int dimension(); + + /** True if backed by little-endian packed bytes. */ + boolean isPackedLE(); + + /** + * Packed bytes for the int vector (LE, 4 * dimension bytes). + * Only valid when isPackedLE() == true. + */ + BytesReference packedBytes(); + + /** Random access (packed reads 4 bytes at i*4; non-packed returns v[i]). */ + int get(int i); + + /** + * Convenience; allocates an int array for packed values. + * Zero-copy decoding is used when the packed value is backed by a usable byte array. + * For other BytesReference implementations, decoding may lazily materialize one cached + * byte array. + */ + int[] asIntArray(); + + @Override + default int size() { + return dimension(); + } + + @Override + default void writeBodyTo(StreamOutput out) throws IOException { + out.writeBoolean(isPackedLE()); + writePayloadTo(out); + } + + void writePayloadTo(StreamOutput out) throws IOException; + + static IntArrayValue readBodyFrom(StreamInput in) throws IOException { + final boolean packedLE = in.readBoolean(); + if (packedLE) { + final int dim = in.readVInt(); + return PackedIntArray.readBodyFrom(in, dim); + } else { + return PrimitiveIntArray.readBodyFrom(in); + } + } +} diff --git a/server/src/main/java/org/opensearch/index/mapper/extrasource/LongArrayValue.java b/server/src/main/java/org/opensearch/index/mapper/extrasource/LongArrayValue.java new file mode 100644 index 0000000000000..c5146a53caf86 --- /dev/null +++ b/server/src/main/java/org/opensearch/index/mapper/extrasource/LongArrayValue.java @@ -0,0 +1,108 @@ +/* + * 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.index.mapper.extrasource; + +import org.opensearch.core.common.bytes.BytesReference; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; + +import java.io.IOException; + +/** + * A long array value used in {@link ExtraFieldValue}. + * + *

    Supports both primitive (decoded) and packed (little-endian) representations.

    + */ +public non-sealed interface LongArrayValue extends ExtraFieldValue { + + @Override + default Type type() { + return Type.LONG_ARRAY; + } + + /** + * Creates a {@link LongArrayValue} from packed little-endian bytes. + * + * @param packed the packed long bytes (dimension * 8 bytes) + * @param dimension the number of long elements + * @return a long-array value backed by the provided bytes + * @throws IllegalArgumentException if the byte length does not match {@code dimension * 8} + */ + static LongArrayValue fromPackedBytes(BytesReference packed, int dimension) { + return PackedLongArray.fromPackedBytes(packed, dimension); + } + + /** + * Creates a {@link LongArrayValue} from a packed little-endian byte array. + * + * @param packed the packed long bytes (dimension * 8 bytes) + * @param dimension the number of long elements + * @return a long-array value backed by the provided array + * @throws IllegalArgumentException if the byte length does not match {@code dimension * 8} + */ + static LongArrayValue fromPackedArray(byte[] packed, int dimension) { + return PackedLongArray.fromPackedArray(packed, dimension); + } + + /** + * Creates a {@link LongArrayValue} from a long array. + * + * @param values the long values + * @return a long-array value backed by the provided array + */ + static LongArrayValue fromLongArray(long[] values) { + return new PrimitiveLongArray(values); + } + + /** Number of long elements. */ + int dimension(); + + /** True if backed by little-endian packed bytes. */ + boolean isPackedLE(); + + /** + * Packed bytes for the long vector (LE, 8 * dimension bytes). + * Only valid when isPackedLE() == true. + */ + BytesReference packedBytes(); + + /** Random access (packed reads 8 bytes at i*8; non-packed returns v[i]). */ + long get(int i); + + /** + * Convenience; allocates a long array for packed values. + * Zero-copy decoding is used when the packed value is backed by a usable byte array. + * For other BytesReference implementations, decoding may lazily materialize one cached + * byte array. + */ + long[] asLongArray(); + + @Override + default int size() { + return dimension(); + } + + @Override + default void writeBodyTo(StreamOutput out) throws IOException { + out.writeBoolean(isPackedLE()); + writePayloadTo(out); + } + + void writePayloadTo(StreamOutput out) throws IOException; + + static LongArrayValue readBodyFrom(StreamInput in) throws IOException { + final boolean packedLE = in.readBoolean(); + if (packedLE) { + final int dim = in.readVInt(); + return PackedLongArray.readBodyFrom(in, dim); + } else { + return PrimitiveLongArray.readBodyFrom(in); + } + } +} diff --git a/server/src/main/java/org/opensearch/index/mapper/extrasource/PackedDoubleArray.java b/server/src/main/java/org/opensearch/index/mapper/extrasource/PackedDoubleArray.java new file mode 100644 index 0000000000000..0ebce002b0991 --- /dev/null +++ b/server/src/main/java/org/opensearch/index/mapper/extrasource/PackedDoubleArray.java @@ -0,0 +1,79 @@ +/* + * 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.index.mapper.extrasource; + +import org.opensearch.core.common.bytes.BytesArray; +import org.opensearch.core.common.bytes.BytesReference; +import org.opensearch.core.common.io.stream.StreamInput; + +import java.io.IOException; +import java.util.Objects; + +/** + * Packed little-endian doubles. + * + *

    Canonical storage is a {@link BytesReference} for efficient transport.

    + */ +final class PackedDoubleArray extends AbstractPackedArray implements DoubleArrayValue { + + private volatile double[] cached; + + static PackedDoubleArray fromPackedBytes(BytesReference packed, int dimension) { + Objects.requireNonNull(packed, "packed must not be null"); + return new PackedDoubleArray(packed, dimension, null, 0); + } + + static PackedDoubleArray fromPackedArray(byte[] packedArray, int dimension) { + Objects.requireNonNull(packedArray, "packedArray must not be null"); + return fromPackedArray(packedArray, 0, packedArray.length, dimension); + } + + static PackedDoubleArray fromPackedArray(byte[] packedArray, int offset, int length, int dimension) { + Objects.requireNonNull(packedArray, "packedArray must not be null"); + BytesReference packed = new BytesArray(packedArray, offset, length); + return new PackedDoubleArray(packed, dimension, packedArray, offset); + } + + private PackedDoubleArray(BytesReference packed, int dimension, byte[] bytes, int bytesOffset) { + super(packed, dimension, bytes, bytesOffset, Double.BYTES, "double"); + } + + static PackedDoubleArray readBodyFrom(StreamInput in, int dim) throws IOException { + return fromPackedBytes(in.readBytesReference(), dim); + } + + @Override + public double get(int i) { + checkIndex(i); + double[] v = cached; + if (v != null) { + return v[i]; + } + ResolvedBytes resolved = ensureBytes(); + return decodeDoubleLEAt(resolved.bytes, resolved.offset + i * Double.BYTES); + } + + @Override + public double[] asDoubleArray() { + double[] v = cached; + if (v != null) return v; + + ResolvedBytes resolved = ensureBytes(); + + v = new double[dimension]; + int p = resolved.offset; + for (int i = 0; i < dimension; i++) { + v[i] = decodeDoubleLEAt(resolved.bytes, p); + p += Double.BYTES; + } + + cached = v; + return v; + } +} diff --git a/server/src/main/java/org/opensearch/index/mapper/extrasource/PackedFloatArray.java b/server/src/main/java/org/opensearch/index/mapper/extrasource/PackedFloatArray.java index fc5e8daa49dce..7586392e52c77 100644 --- a/server/src/main/java/org/opensearch/index/mapper/extrasource/PackedFloatArray.java +++ b/server/src/main/java/org/opensearch/index/mapper/extrasource/PackedFloatArray.java @@ -11,7 +11,6 @@ import org.opensearch.core.common.bytes.BytesArray; import org.opensearch.core.common.bytes.BytesReference; import org.opensearch.core.common.io.stream.StreamInput; -import org.opensearch.core.common.io.stream.StreamOutput; import java.io.IOException; import java.util.Objects; @@ -32,14 +31,7 @@ * transiently materialize/allocate more than once, but results are equivalent.

    */ -final class PackedFloatArray implements FloatArrayValue { - - private final BytesReference packed; - private final int dimension; - - // Lazily materialized backing for fast decoding - private volatile byte[] bytes; - private volatile int bytesOffset; +final class PackedFloatArray extends AbstractPackedArray implements FloatArrayValue { private volatile float[] cached; @@ -49,6 +41,7 @@ public static PackedFloatArray fromPackedBytes(BytesReference packed, int dimens } public static PackedFloatArray fromPackedArray(byte[] packedArray, int dimension) { + Objects.requireNonNull(packedArray, "packedArray must not be null"); return fromPackedArray(packedArray, 0, packedArray.length, dimension); } @@ -59,77 +52,22 @@ public static PackedFloatArray fromPackedArray(byte[] packedArray, int offset, i } private PackedFloatArray(BytesReference packed, int dimension, byte[] bytes, int bytesOffset) { - this.packed = packed; - this.dimension = dimension; - this.bytes = bytes; - this.bytesOffset = bytesOffset; - validate(packed.length(), dimension); - } - - @Override - public int dimension() { - return dimension; - } - - @Override - public boolean isPackedLE() { - return true; - } - - @Override - public BytesReference packedBytes() { - return packed; - } - - @Override - public void writePayloadTo(StreamOutput out) throws IOException { - out.writeVInt(dimension()); - out.writeBytesReference(packed); + super(packed, dimension, bytes, bytesOffset, Float.BYTES, "float"); } static PackedFloatArray readBodyFrom(StreamInput in, int dim) throws IOException { return fromPackedBytes(in.readBytesReference(), dim); } - private void ensureBytes() { - if (bytes != null) { - return; - } - - byte[] arr; - int off; - - // Avoid compaction/copy for the common array-backed case - // Otherwise, compact once if needed (composite/paged/etc). - if (packed instanceof BytesArray ba) { - arr = ba.array(); - off = ba.offset(); - } else { - arr = BytesReference.toBytes(packed); - off = 0; - } - - bytesOffset = off; - bytes = arr; - } - - private float decodeAt(final int p) { - final byte[] a = bytes; - final int bits = (a[p] & 0xFF) | ((a[p + 1] & 0xFF) << 8) | ((a[p + 2] & 0xFF) << 16) | ((a[p + 3] & 0xFF) << 24); - return Float.intBitsToFloat(bits); - } - @Override public float get(int i) { - if (i < 0 || i >= dimension) { - throw new IndexOutOfBoundsException("i=" + i + " dim=" + dimension); - } + checkIndex(i); float[] v = cached; if (v != null) { return v[i]; } - ensureBytes(); - return decodeAt(bytesOffset + (i << 2)); + ResolvedBytes resolved = ensureBytes(); + return decodeFloatLEAt(resolved.bytes, resolved.offset + i * Float.BYTES); } @Override @@ -137,31 +75,16 @@ public float[] asFloatArray() { float[] v = cached; if (v != null) return v; - ensureBytes(); + ResolvedBytes resolved = ensureBytes(); v = new float[dimension]; - int p = bytesOffset; + int p = resolved.offset; for (int i = 0; i < dimension; i++) { - v[i] = decodeAt(p); - p += 4; + v[i] = decodeFloatLEAt(resolved.bytes, p); + p += Float.BYTES; } cached = v; return v; } - - private static void validate(int byteLen, int dim) { - if (dim < 0) { - throw new IllegalArgumentException("dimension must be >= 0 (got " + dim + ")"); - } - final int expected; - try { - expected = Math.multiplyExact(dim, 4); - } catch (ArithmeticException e) { - throw new IllegalArgumentException("dimension too large: " + dim, e); - } - if (byteLen != expected) { - throw new IllegalArgumentException("Bad packed float length=" + byteLen + " expected=" + expected + " (dim=" + dim + ")"); - } - } } diff --git a/server/src/main/java/org/opensearch/index/mapper/extrasource/PackedIntArray.java b/server/src/main/java/org/opensearch/index/mapper/extrasource/PackedIntArray.java new file mode 100644 index 0000000000000..9ef123d8aabb7 --- /dev/null +++ b/server/src/main/java/org/opensearch/index/mapper/extrasource/PackedIntArray.java @@ -0,0 +1,79 @@ +/* + * 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.index.mapper.extrasource; + +import org.opensearch.core.common.bytes.BytesArray; +import org.opensearch.core.common.bytes.BytesReference; +import org.opensearch.core.common.io.stream.StreamInput; + +import java.io.IOException; +import java.util.Objects; + +/** + * Packed little-endian ints. + * + *

    Canonical storage is a {@link BytesReference} for efficient transport.

    + */ +final class PackedIntArray extends AbstractPackedArray implements IntArrayValue { + + private volatile int[] cached; + + static PackedIntArray fromPackedBytes(BytesReference packed, int dimension) { + Objects.requireNonNull(packed, "packed must not be null"); + return new PackedIntArray(packed, dimension, null, 0); + } + + static PackedIntArray fromPackedArray(byte[] packedArray, int dimension) { + Objects.requireNonNull(packedArray, "packedArray must not be null"); + return fromPackedArray(packedArray, 0, packedArray.length, dimension); + } + + static PackedIntArray fromPackedArray(byte[] packedArray, int offset, int length, int dimension) { + Objects.requireNonNull(packedArray, "packedArray must not be null"); + BytesReference packed = new BytesArray(packedArray, offset, length); + return new PackedIntArray(packed, dimension, packedArray, offset); + } + + private PackedIntArray(BytesReference packed, int dimension, byte[] bytes, int bytesOffset) { + super(packed, dimension, bytes, bytesOffset, Integer.BYTES, "int"); + } + + static PackedIntArray readBodyFrom(StreamInput in, int dim) throws IOException { + return fromPackedBytes(in.readBytesReference(), dim); + } + + @Override + public int get(int i) { + checkIndex(i); + int[] v = cached; + if (v != null) { + return v[i]; + } + ResolvedBytes resolved = ensureBytes(); + return decodeIntLEAt(resolved.bytes, resolved.offset + i * Integer.BYTES); + } + + @Override + public int[] asIntArray() { + int[] v = cached; + if (v != null) return v; + + ResolvedBytes resolved = ensureBytes(); + + v = new int[dimension]; + int p = resolved.offset; + for (int i = 0; i < dimension; i++) { + v[i] = decodeIntLEAt(resolved.bytes, p); + p += Integer.BYTES; + } + + cached = v; + return v; + } +} diff --git a/server/src/main/java/org/opensearch/index/mapper/extrasource/PackedLongArray.java b/server/src/main/java/org/opensearch/index/mapper/extrasource/PackedLongArray.java new file mode 100644 index 0000000000000..4ddd5f6d2bd7a --- /dev/null +++ b/server/src/main/java/org/opensearch/index/mapper/extrasource/PackedLongArray.java @@ -0,0 +1,79 @@ +/* + * 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.index.mapper.extrasource; + +import org.opensearch.core.common.bytes.BytesArray; +import org.opensearch.core.common.bytes.BytesReference; +import org.opensearch.core.common.io.stream.StreamInput; + +import java.io.IOException; +import java.util.Objects; + +/** + * Packed little-endian longs. + * + *

    Canonical storage is a {@link BytesReference} for efficient transport.

    + */ +final class PackedLongArray extends AbstractPackedArray implements LongArrayValue { + + private volatile long[] cached; + + static PackedLongArray fromPackedBytes(BytesReference packed, int dimension) { + Objects.requireNonNull(packed, "packed must not be null"); + return new PackedLongArray(packed, dimension, null, 0); + } + + static PackedLongArray fromPackedArray(byte[] packedArray, int dimension) { + Objects.requireNonNull(packedArray, "packedArray must not be null"); + return fromPackedArray(packedArray, 0, packedArray.length, dimension); + } + + static PackedLongArray fromPackedArray(byte[] packedArray, int offset, int length, int dimension) { + Objects.requireNonNull(packedArray, "packedArray must not be null"); + BytesReference packed = new BytesArray(packedArray, offset, length); + return new PackedLongArray(packed, dimension, packedArray, offset); + } + + private PackedLongArray(BytesReference packed, int dimension, byte[] bytes, int bytesOffset) { + super(packed, dimension, bytes, bytesOffset, Long.BYTES, "long"); + } + + static PackedLongArray readBodyFrom(StreamInput in, int dim) throws IOException { + return fromPackedBytes(in.readBytesReference(), dim); + } + + @Override + public long get(int i) { + checkIndex(i); + long[] v = cached; + if (v != null) { + return v[i]; + } + ResolvedBytes resolved = ensureBytes(); + return decodeLongLEAt(resolved.bytes, resolved.offset + i * Long.BYTES); + } + + @Override + public long[] asLongArray() { + long[] v = cached; + if (v != null) return v; + + ResolvedBytes resolved = ensureBytes(); + + v = new long[dimension]; + int p = resolved.offset; + for (int i = 0; i < dimension; i++) { + v[i] = decodeLongLEAt(resolved.bytes, p); + p += Long.BYTES; + } + + cached = v; + return v; + } +} diff --git a/server/src/main/java/org/opensearch/index/mapper/extrasource/PrimitiveDoubleArray.java b/server/src/main/java/org/opensearch/index/mapper/extrasource/PrimitiveDoubleArray.java new file mode 100644 index 0000000000000..ba375f128276a --- /dev/null +++ b/server/src/main/java/org/opensearch/index/mapper/extrasource/PrimitiveDoubleArray.java @@ -0,0 +1,47 @@ +/* + * 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.index.mapper.extrasource; + +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; + +import java.io.IOException; +import java.util.Objects; + +/** + * Primitive doubles. + * This can be used by clients that already have double[]. + */ +final class PrimitiveDoubleArray extends AbstractPrimitiveArray implements DoubleArrayValue { + private final double[] v; + + PrimitiveDoubleArray(double[] v) { + super(Objects.requireNonNull(v, "values must not be null").length); + this.v = v; + } + + @Override + public double get(int i) { + return v[i]; + } + + @Override + public double[] asDoubleArray() { + return v; + } + + @Override + public void writePayloadTo(StreamOutput out) throws IOException { + out.writeDoubleArray(v); + } + + static PrimitiveDoubleArray readBodyFrom(StreamInput in) throws IOException { + return new PrimitiveDoubleArray(in.readDoubleArray()); + } +} diff --git a/server/src/main/java/org/opensearch/index/mapper/extrasource/PrimitiveFloatArray.java b/server/src/main/java/org/opensearch/index/mapper/extrasource/PrimitiveFloatArray.java index a921863b39859..c03222d697634 100644 --- a/server/src/main/java/org/opensearch/index/mapper/extrasource/PrimitiveFloatArray.java +++ b/server/src/main/java/org/opensearch/index/mapper/extrasource/PrimitiveFloatArray.java @@ -8,38 +8,24 @@ package org.opensearch.index.mapper.extrasource; -import org.opensearch.core.common.bytes.BytesReference; import org.opensearch.core.common.io.stream.StreamInput; import org.opensearch.core.common.io.stream.StreamOutput; import java.io.IOException; +import java.util.Objects; /** * Primitive floats * This can be used by clients that already have float[]. */ -final class PrimitiveFloatArray implements FloatArrayValue { +final class PrimitiveFloatArray extends AbstractPrimitiveArray implements FloatArrayValue { private final float[] v; public PrimitiveFloatArray(float[] v) { + super(Objects.requireNonNull(v, "values must not be null").length); this.v = v; } - @Override - public int dimension() { - return v.length; - } - - @Override - public boolean isPackedLE() { - return false; - } - - @Override - public BytesReference packedBytes() { - throw new IllegalStateException("Not packed"); - } - @Override public float get(int i) { return v[i]; diff --git a/server/src/main/java/org/opensearch/index/mapper/extrasource/PrimitiveIntArray.java b/server/src/main/java/org/opensearch/index/mapper/extrasource/PrimitiveIntArray.java new file mode 100644 index 0000000000000..b258108c86de9 --- /dev/null +++ b/server/src/main/java/org/opensearch/index/mapper/extrasource/PrimitiveIntArray.java @@ -0,0 +1,47 @@ +/* + * 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.index.mapper.extrasource; + +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; + +import java.io.IOException; +import java.util.Objects; + +/** + * Primitive ints. + * This can be used by clients that already have int[]. + */ +final class PrimitiveIntArray extends AbstractPrimitiveArray implements IntArrayValue { + private final int[] v; + + PrimitiveIntArray(int[] v) { + super(Objects.requireNonNull(v, "values must not be null").length); + this.v = v; + } + + @Override + public int get(int i) { + return v[i]; + } + + @Override + public int[] asIntArray() { + return v; + } + + @Override + public void writePayloadTo(StreamOutput out) throws IOException { + out.writeIntArray(v); + } + + static PrimitiveIntArray readBodyFrom(StreamInput in) throws IOException { + return new PrimitiveIntArray(in.readIntArray()); + } +} diff --git a/server/src/main/java/org/opensearch/index/mapper/extrasource/PrimitiveLongArray.java b/server/src/main/java/org/opensearch/index/mapper/extrasource/PrimitiveLongArray.java new file mode 100644 index 0000000000000..1d6d8559ce314 --- /dev/null +++ b/server/src/main/java/org/opensearch/index/mapper/extrasource/PrimitiveLongArray.java @@ -0,0 +1,47 @@ +/* + * 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.index.mapper.extrasource; + +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; + +import java.io.IOException; +import java.util.Objects; + +/** + * Primitive longs. + * This can be used by clients that already have long[]. + */ +final class PrimitiveLongArray extends AbstractPrimitiveArray implements LongArrayValue { + private final long[] v; + + PrimitiveLongArray(long[] v) { + super(Objects.requireNonNull(v, "values must not be null").length); + this.v = v; + } + + @Override + public long get(int i) { + return v[i]; + } + + @Override + public long[] asLongArray() { + return v; + } + + @Override + public void writePayloadTo(StreamOutput out) throws IOException { + out.writeLongArray(v); + } + + static PrimitiveLongArray readBodyFrom(StreamInput in) throws IOException { + return new PrimitiveLongArray(in.readLongArray()); + } +} diff --git a/server/src/main/java/org/opensearch/index/query/MoreLikeThisQueryBuilder.java b/server/src/main/java/org/opensearch/index/query/MoreLikeThisQueryBuilder.java index 25b6b3471a8d3..e9149cd008227 100644 --- a/server/src/main/java/org/opensearch/index/query/MoreLikeThisQueryBuilder.java +++ b/server/src/main/java/org/opensearch/index/query/MoreLikeThisQueryBuilder.java @@ -1128,7 +1128,7 @@ private static Fields[] getFieldsFor(MultiTermVectorsResponse responses) throws } likeFields.add(getResponse.getFields()); } - return likeFields.toArray(Fields.EMPTY_ARRAY); + return likeFields.toArray(new Fields[0]); } private static void checkRoutingMissingException(MultiTermVectorsItemResponse response) { diff --git a/server/src/main/java/org/opensearch/index/query/NestedQueryBuilder.java b/server/src/main/java/org/opensearch/index/query/NestedQueryBuilder.java index e3294ebc4376f..753cb57c9a8dc 100644 --- a/server/src/main/java/org/opensearch/index/query/NestedQueryBuilder.java +++ b/server/src/main/java/org/opensearch/index/query/NestedQueryBuilder.java @@ -517,7 +517,10 @@ public ObjectMapper getChildObjectMapper() { public void visit(QueryBuilderVisitor visitor) { visitor.accept(this); if (query != null) { - visitor.getChildVisitor(BooleanClause.Occur.MUST).accept(query); + final QueryBuilderVisitor subVisitor = visitor.getChildVisitor(BooleanClause.Occur.MUST); + if (subVisitor != null) { + query.visit(subVisitor); + } } } } diff --git a/server/src/main/java/org/opensearch/index/shard/IndexShard.java b/server/src/main/java/org/opensearch/index/shard/IndexShard.java index c5cf064562ba2..46ac14f2cfbdf 100644 --- a/server/src/main/java/org/opensearch/index/shard/IndexShard.java +++ b/server/src/main/java/org/opensearch/index/shard/IndexShard.java @@ -416,6 +416,8 @@ Runnable getGlobalCheckpointSyncer() { // Used to limit the number of concurrent translog tasks. When the semaphore is exhausted, serial recovery is used. private static final Semaphore translogConcurrentRecoverySemaphore = new Semaphore(1000); + private static final long REPLICA_SYNC_POLL_INTERVAL_MS = 500; + private final DataFormatRegistry dataFormatRegistry; private final Map checksumStrategies; @@ -1050,10 +1052,12 @@ public void relocated( forceRefreshes.close(); boolean syncTranslog = (isRemoteTranslogEnabled() || this.isMigratingToRemote()) - && Durability.ASYNC == indexSettings.getTranslogDurability(); - // Since all the index permits are acquired at this point, the translog buffer will not change. - // It is safe to perform sync of translogs now as this will ensure for remote-backed indexes, the - // translogs has been uploaded to the remote store. + && (Durability.ASYNC == indexSettings.getTranslogDurability() || indexSettings.isPluggableDataFormatEnabled()); + // Force a final, blocking translog upload to remote for ALL remote-backed indexes before draining + // uploads below. This must run for REQUEST durability too, not just ASYNC: with REQUEST the freshest + // acked ops may still be in the buffered upload path and not yet on remote. If we drained without + // this sync, the pending upload would hit the drained syncPermit, no-op (TLOG-SKIP), and those acked + // ops would never reach remote, silently lost on handoff since the target recovers from remote. if (syncTranslog) { maybeSync(); } @@ -1532,7 +1536,11 @@ public Engine.GetResult get(Engine.Get get) { if (mapper == null) { return GetResult.NOT_EXISTS; } - return applyOnEngine(getIndexer(), engine -> engine.get(get, this::acquireSearcher)); + try { + return getIndexer().getById(get, this::acquireSearcher); + } catch (IOException e) { + throw new OpenSearchException("get-by-id failed for id [" + get.id() + "]", e); + } } /** @@ -3837,6 +3845,77 @@ public Set getReplicationStatsForTrackedReplicas() return replicationTracker.getSegmentReplicationStats(); } + /** + * Stable marker substring embedded in every replica-sync-timeout message. Used for detection + * in mixed-version clusters or log parsing, analogous to + * {@link org.opensearch.storage.action.tiering.MergeDrainTimeoutException#MERGE_DRAIN_TIMEOUT_MARKER}. + */ + public static final String REPLICA_SYNC_TIMEOUT_MARKER = "[REPLICA_SYNC_TIMEOUT]"; + + /** + * Waits for all tracked replicas to be in sync with the primary's latest checkpoint. + * Polls every 500ms until either all replicas report {@code checkpointsBehindCount == 0} + * or the timeout is exceeded. + *

    + * Used during tiering preparation to verify replicas are in sync after + * {@code waitForRemoteStoreSync()} — ensures replicas have downloaded the latest + * segments before shard relocation begins. + * + * @param timeout maximum time to wait for replicas to sync + * @throws IOException if replicas fail to sync within the timeout + */ + public void waitForReplicaSync(TimeValue timeout) throws IOException { + if (!indexSettings.isSegRepEnabledOrRemoteNode()) { + return; + } + long startNanos = System.nanoTime(); + Set stats = Set.of(); + while (System.nanoTime() - startNanos < timeout.nanos()) { + stats = getReplicationStatsForTrackedReplicas(); + if (stats.isEmpty() + || stats.stream() + .allMatch( + s -> s.getCheckpointsBehindCount() == 0 && s.getBytesBehindCount() == 0 && s.getCurrentReplicationTimeMillis() == 0 + )) { + logger.debug("All replicas in sync for shard [{}]", shardId); + return; + } + long behindReplicas = stats.stream().filter(s -> s.getCheckpointsBehindCount() > 0 || s.getBytesBehindCount() > 0).count(); + long maxCheckpointsBehind = stats.stream().mapToLong(SegmentReplicationShardStats::getCheckpointsBehindCount).max().orElse(0); + long maxBytesBehind = stats.stream().mapToLong(SegmentReplicationShardStats::getBytesBehindCount).max().orElse(0); + logger.debug( + "Waiting for replica sync on shard [{}]: {} replica(s) still behind, max checkpoints behind: {}, max bytes behind: {}", + shardId, + behindReplicas, + maxCheckpointsBehind, + maxBytesBehind + ); + try { + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new OpenSearchException("Interrupted waiting for replica sync on shard [" + shardId + "]", e); + } + } + // Build diagnostic message with per-replica details + long behindCount = stats.stream().filter(s -> s.getCheckpointsBehindCount() > 0 || s.getBytesBehindCount() > 0).count(); + long maxBehind = stats.stream().mapToLong(SegmentReplicationShardStats::getCheckpointsBehindCount).max().orElse(0); + long maxBytes = stats.stream().mapToLong(SegmentReplicationShardStats::getBytesBehindCount).max().orElse(0); + throw new IOException( + REPLICA_SYNC_TIMEOUT_MARKER + + " Shard [" + + shardId + + "] replicas failed to sync within " + + timeout + + ". Replicas still behind: " + + behindCount + + ", max checkpoints behind: " + + maxBehind + + ", max bytes behind: " + + maxBytes + ); + } + public ReplicationStats getReplicationStats() { if (indexSettings.isSegRepEnabledOrRemoteNode() && !routingEntry().primary()) { return segmentReplicationStatsProvider.apply(shardId); diff --git a/server/src/main/java/org/opensearch/index/store/remote/DataFormatAwareRemoteDirectory.java b/server/src/main/java/org/opensearch/index/store/remote/DataFormatAwareRemoteDirectory.java index dbeda9067bbac..3c64578e5568e 100644 --- a/server/src/main/java/org/opensearch/index/store/remote/DataFormatAwareRemoteDirectory.java +++ b/server/src/main/java/org/opensearch/index/store/remote/DataFormatAwareRemoteDirectory.java @@ -48,6 +48,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.UnaryOperator; /** @@ -379,19 +380,75 @@ private void uploadBlob( } else { expectedChecksum = calculateChecksumOfChecksum(from, src); } - IndexInput indexInput = from.openInput(src, ioContext); + final IndexInput rawIndexInput = from.openInput(src, ioContext); + // Wrap to detect double-close and already-closed slice attempts. These indicate + // lifecycle bugs — double-close means two code paths are releasing the same input, + // and an already-closed slice attempt means the master was closed before all parts + // completed (should not happen with the ref count in place). + final AtomicReference indexInputClosed = new AtomicReference<>(false); + final IndexInput indexInput = new org.apache.lucene.store.FilterIndexInput("tracked:" + src, rawIndexInput) { + @Override + public void close() throws IOException { + if (indexInputClosed.getAndSet(true)) { + logger.warn( + () -> new ParameterizedMessage( + "IndexInput for [{}] closed a second time (double-close) on thread [{}]; " + + "possible lifecycle bug in the upload path", + src, + Thread.currentThread().getName() + ) + ); + } else { + logger.debug(() -> new ParameterizedMessage("IndexInput.close() for [{}]", src)); + } + super.close(); + } + + @Override + public IndexInput clone() { + if (indexInputClosed.get()) { + logger.warn( + () -> new ParameterizedMessage( + "IndexInput.slice() attempted on already-closed IndexInput for [{}] on thread [{}];" + + " the master was closed before all parts completed", + src, + Thread.currentThread().getName() + ) + ); + } + // Delegate to the underlying IndexInput's clone() — NOT super.clone(). + // FilterIndexInput inherits Object.clone() which produces a shallow wrapper + // copy sharing the same 'in' field; that causes double-close when the shallow + // copy is closed via OffsetRangeRefCount. The supplier now uses slice() rather + // than clone(), so this path is only reached by external callers (if any); + // those callers receive an untracked raw clone, which is intentional since + // the tracking wrapper is for the master lifecycle only. + return in.clone(); + } + }; try { long contentLength = indexInput.length(); boolean remoteIntegrityEnabled = (targetContainer instanceof AsyncMultiStreamBlobContainer) && ((AsyncMultiStreamBlobContainer) targetContainer).remoteIntegrityCheckSupported(); - lowPriorityUpload = lowPriorityUpload || contentLength > ByteSizeUnit.GB.toBytes(15); - - RemoteTransferContainer.OffsetRangeInputStreamSupplier supplier = lowPriorityUpload + final boolean effectiveLowPriority = lowPriorityUpload || contentLength > ByteSizeUnit.GB.toBytes(15); + lowPriorityUpload = effectiveLowPriority; + + // Use slice() instead of clone() so each part gets its own independent + // MemorySegment[] array copy (via ArrayUtil.copyOfSubArray in buildSlice). + // clone() passes the master's segments[] array by reference to MultiSegmentImpl; + // when any clone closes, Arrays.fill(segments, null) corrupts the shared array, + // causing AlreadyClosedException on all subsequent provideStream() calls. + // slice() always allocates a new array for non-full-range slices, so each part's + // close only nullifies its own private copy. No extra mmap; just a new Java object + // pointing into the existing mapped region. + RemoteTransferContainer.OffsetRangeInputStreamSupplier supplier = effectiveLowPriority ? (size, position) -> lowPriorityUploadRateLimiter.apply( - new OffsetRangeIndexInputStream(indexInput.clone(), size, position) + new OffsetRangeIndexInputStream(indexInput.slice("part@" + position, position, size), size, 0) ) - : (size, position) -> uploadRateLimiter.apply(new OffsetRangeIndexInputStream(indexInput.clone(), size, position)); + : (size, position) -> uploadRateLimiter.apply( + new OffsetRangeIndexInputStream(indexInput.slice("part@" + position, position, size), size, 0) + ); RemoteTransferContainer remoteTransferContainer = new RemoteTransferContainer( src, @@ -409,7 +466,13 @@ private void uploadBlob( postUploadRunner, listener, remoteTransferContainer, - indexInput + () -> { + try { + indexInput.close(); + } catch (IOException e) { + logger.warn(() -> new ParameterizedMessage("Error closing IndexInput for file [{}]", src), e); + } + } ); WriteContext writeContext = remoteTransferContainer.createWriteContext(); @@ -496,7 +559,7 @@ private ActionListener createCompletionListener( Runnable postUploadRunner, ActionListener listener, RemoteTransferContainer remoteTransferContainer, - IndexInput indexInput + Runnable onClose ) { ActionListener completionListener = ActionListener.wrap(resp -> { try { @@ -530,13 +593,9 @@ private ActionListener createCompletionListener( } }); - completionListener = ActionListener.runAfter(completionListener, () -> { - try { - indexInput.close(); - } catch (IOException e) { - logger.warn("Error closing IndexInput", e); - } - }); + if (onClose != null) { + completionListener = ActionListener.runAfter(completionListener, onClose); + } return completionListener; } diff --git a/server/src/main/java/org/opensearch/index/store/remote/filecache/NodeCacheService.java b/server/src/main/java/org/opensearch/index/store/remote/filecache/NodeCacheService.java index 745f537390064..ac0f363ae2d34 100644 --- a/server/src/main/java/org/opensearch/index/store/remote/filecache/NodeCacheService.java +++ b/server/src/main/java/org/opensearch/index/store/remote/filecache/NodeCacheService.java @@ -28,6 +28,7 @@ import org.opensearch.plugins.BlockCacheProvider; import org.opensearch.plugins.BlockCacheRegistry; import org.opensearch.plugins.BlockCacheStats; +import org.opensearch.plugins.BlockCacheTieredStats; import java.io.Closeable; import java.io.IOException; @@ -246,6 +247,7 @@ public BlockCacheStats combinedBlockCacheStats() { long hits = 0, misses = 0, hitBytes = 0, missBytes = 0; long evictions = 0, evictionBytes = 0, removed = 0, removedBytes = 0; long memUsed = 0, diskUsed = 0, total = 0, activeInBytes = 0; + BlockCacheTieredStats tiered = null; for (BlockCache bc : blockCaches) { BlockCacheStats s = bc.stats(); hits += s.hits(); @@ -260,6 +262,9 @@ public BlockCacheStats combinedBlockCacheStats() { diskUsed += s.diskBytesUsed(); total += s.totalBytes(); activeInBytes += s.activeInBytes(); + if (tiered == null) { + tiered = bc.tieredStats(); + } } return new BlockCacheStats( hits, @@ -273,7 +278,8 @@ public BlockCacheStats combinedBlockCacheStats() { memUsed, diskUsed, total, - activeInBytes + activeInBytes, + tiered ); } diff --git a/server/src/main/java/org/opensearch/index/translog/InternalTranslogManager.java b/server/src/main/java/org/opensearch/index/translog/InternalTranslogManager.java index 9e340181e4696..031fba0403cc7 100644 --- a/server/src/main/java/org/opensearch/index/translog/InternalTranslogManager.java +++ b/server/src/main/java/org/opensearch/index/translog/InternalTranslogManager.java @@ -21,6 +21,7 @@ import org.opensearch.index.translog.listener.TranslogEventListener; import org.opensearch.index.translog.transfer.TranslogUploadFailedException; +import java.io.Closeable; import java.io.IOException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.BooleanSupplier; @@ -432,6 +433,11 @@ public String getTranslogUUID() { return translog.getTranslogUUID(); } + @Override + public Closeable acquireHistoryRetentionLock() { + return translog.acquireRetentionLock(); + } + /** * * @param localCheckpointOfLastCommit local checkpoint reference of last commit to translog diff --git a/server/src/main/java/org/opensearch/index/translog/TranslogManager.java b/server/src/main/java/org/opensearch/index/translog/TranslogManager.java index ec312636e7ee1..83b4260c5f741 100644 --- a/server/src/main/java/org/opensearch/index/translog/TranslogManager.java +++ b/server/src/main/java/org/opensearch/index/translog/TranslogManager.java @@ -185,4 +185,12 @@ public interface TranslogManager extends Closeable { * @return the uuid of the translog */ String getTranslogUUID(); + + /** + * Acquire retention lock on the translog + * @return releasable for releasing the lock + */ + default Closeable acquireHistoryRetentionLock() { + return () -> {}; + } } diff --git a/server/src/main/java/org/opensearch/indices/IndexingMemoryController.java b/server/src/main/java/org/opensearch/indices/IndexingMemoryController.java index 84854a16ec58c..2b2d72099cfa7 100644 --- a/server/src/main/java/org/opensearch/indices/IndexingMemoryController.java +++ b/server/src/main/java/org/opensearch/indices/IndexingMemoryController.java @@ -115,10 +115,10 @@ public class IndexingMemoryController implements IndexingOperationListener, Clos /** How much native (off-heap) memory we allow for indexing buffers across all shards. * Accepts either a percentage (of available native memory = total physical - JVM heap) or an absolute byte value. - * Default is 10%. */ + * Default is 7%. */ public static final Setting NATIVE_INDEX_BUFFER_SIZE_SETTING = Setting.nativeMemorySizeSetting( "indices.memory.native_index_buffer_size", - "10%", + "7%", Property.NodeScope ); diff --git a/server/src/main/java/org/opensearch/indices/IndicesService.java b/server/src/main/java/org/opensearch/indices/IndicesService.java index 9f7d43685dfd9..ac64ae8424d89 100644 --- a/server/src/main/java/org/opensearch/indices/IndicesService.java +++ b/server/src/main/java/org/opensearch/indices/IndicesService.java @@ -752,7 +752,7 @@ public IndicesService( null, null, null, - null + new DataFormatRegistry(pluginsService) ); } @@ -1234,7 +1234,12 @@ private synchronized IndexService createIndexService( } private EngineConfigFactory getEngineConfigFactory(final IndexSettings idxSettings) { - return new EngineConfigFactory(this.pluginsService, idxSettings); + return new EngineConfigFactory( + this.pluginsService, + idxSettings, + dataFormatRegistry.getDocumentLookupProvider(), + dataFormatRegistry.getDocumentMetadataResolver() + ); } private IngestionConsumerFactory getIngestionConsumerFactory(final IndexSettings idxSettings) { diff --git a/server/src/main/java/org/opensearch/indices/replication/checkpoint/ReplicationCheckpoint.java b/server/src/main/java/org/opensearch/indices/replication/checkpoint/ReplicationCheckpoint.java index 39c2039191def..dbf100af0ddb8 100644 --- a/server/src/main/java/org/opensearch/indices/replication/checkpoint/ReplicationCheckpoint.java +++ b/server/src/main/java/org/opensearch/indices/replication/checkpoint/ReplicationCheckpoint.java @@ -196,7 +196,13 @@ public void writeTo(StreamOutput out) throws IOException { @Override public int compareTo(ReplicationCheckpoint other) { - return this.isAheadOf(other) ? -1 : 1; + if (this.isAheadOf(other)) { + return -1; + } + if (other.isAheadOf(this)) { + return 1; + } + return 0; } @Override diff --git a/server/src/main/java/org/opensearch/lucene/util/CombinedBitSet.java b/server/src/main/java/org/opensearch/lucene/util/CombinedBitSet.java index 1ef37a68f9fe0..9e125f4c39eb9 100644 --- a/server/src/main/java/org/opensearch/lucene/util/CombinedBitSet.java +++ b/server/src/main/java/org/opensearch/lucene/util/CombinedBitSet.java @@ -142,4 +142,17 @@ public int nextSetBit(int start, int end) { return next; } + + @Override + public int nextClearBit(int start, int upperBound) { + assert start >= 0 && start < length() : "start=" + start + " numBits=" + length(); + assert upperBound >= start && upperBound <= length() : "upperBound=" + upperBound + " numBits=" + length(); + + for (int i = start; i < upperBound; i++) { + if (get(i) == false) { + return i; + } + } + return DocIdSetIterator.NO_MORE_DOCS; + } } diff --git a/server/src/main/java/org/opensearch/node/Node.java b/server/src/main/java/org/opensearch/node/Node.java index 341d7516d8e25..335e49d51a814 100644 --- a/server/src/main/java/org/opensearch/node/Node.java +++ b/server/src/main/java/org/opensearch/node/Node.java @@ -60,6 +60,7 @@ import org.opensearch.arrow.spi.PoolGroup; import org.opensearch.bootstrap.BootstrapCheck; import org.opensearch.bootstrap.BootstrapContext; +import org.opensearch.bootstrap.BootstrapSettings; import org.opensearch.cluster.ClusterInfoService; import org.opensearch.cluster.ClusterManagerMetrics; import org.opensearch.cluster.ClusterModule; @@ -578,6 +579,10 @@ protected Node(final Environment initialEnvironment, Collection clas final Settings settings = pluginsService.updatedSettings(); + if (BootstrapSettings.SERIAL_FILTER_SETTING.get(settings)) { + BootstrapSettings.initializeSerialFilter(); + } + final List identityPlugins = new ArrayList<>(); identityPlugins.addAll(pluginsService.filterPlugins(IdentityPlugin.class)); @@ -1348,6 +1353,20 @@ protected Node(final Environment initialEnvironment, Collection clas final RestController restController = actionModule.getRestController(); + // Discover the native-allocator stats supplier from any plugin that publishes a + // NativeAllocatorStatsRegistry component (today: ArrowBasePlugin). Lookup mirrors + // the SearchRequestOperationsListener instanceof filter on pluginComponents elsewhere + // in this file. Server has no compile-time dependency on arrow-base. + // Discovered here (ahead of AdmissionControlService) so it can be forwarded to the + // native-memory admission controller for indexing-pool based rejection. + final Optional nativeAllocatorStatsRegistry = pluginComponents.stream() + .filter(c -> c instanceof NativeAllocatorStatsRegistry) + .map(c -> (NativeAllocatorStatsRegistry) c) + .findFirst(); + final Supplier nativeAllocatorStatsSupplier = nativeAllocatorStatsRegistry.map( + NativeAllocatorStatsRegistry::supplier + ).orElse(null); + final NodeResourceUsageTracker nodeResourceUsageTracker = new NodeResourceUsageTracker( monitorService.fsService(), threadPool, @@ -1360,11 +1379,19 @@ protected Node(final Environment initialEnvironment, Collection clas threadPool ); + // Inject the node-level native memory pressure signal (same signal admission control uses) + // into the allocator so it can make over-commit admission decisions when a native pool is + // full. Reuses the registry discovered above. No-op when no such plugin is loaded. + nativeAllocatorStatsRegistry.ifPresent( + reg -> reg.setNativeMemoryPressureSupplier(nodeResourceUsageTracker::getNativeMemoryUtilizationPercent) + ); + final AdmissionControlService admissionControlService = new AdmissionControlService( settings, clusterService, threadPool, - resourceUsageCollectorService + resourceUsageCollectorService, + nativeAllocatorStatsSupplier ); AdmissionControlTransportInterceptor admissionControlTransportInterceptor = new AdmissionControlTransportInterceptor( @@ -1639,16 +1666,6 @@ protected Node(final Environment initialEnvironment, Collection clas analyticsTaskCancellationStatsSupplier ); - // Discover the native-allocator stats supplier from any plugin that publishes a - // NativeAllocatorStatsRegistry component (today: ArrowBasePlugin). Lookup mirrors - // the SearchRequestOperationsListener instanceof filter on pluginComponents elsewhere - // in this file. Server has no compile-time dependency on arrow-base. - final Supplier nativeAllocatorStatsSupplier = pluginComponents.stream() - .filter(c -> c instanceof NativeAllocatorStatsRegistry) - .map(c -> ((NativeAllocatorStatsRegistry) c).supplier()) - .findFirst() - .orElse(null); - this.nodeService = new NodeService( settings, threadPool, diff --git a/server/src/main/java/org/opensearch/node/resource/tracker/ResourceTrackerSettings.java b/server/src/main/java/org/opensearch/node/resource/tracker/ResourceTrackerSettings.java index baacf68a8e4f1..475ab9d525aa9 100644 --- a/server/src/main/java/org/opensearch/node/resource/tracker/ResourceTrackerSettings.java +++ b/server/src/main/java/org/opensearch/node/resource/tracker/ResourceTrackerSettings.java @@ -143,12 +143,14 @@ static String deriveNativeMemoryLimitDefault(long ram, long heap) { if (ram <= 0 || heap <= 0 || heap >= ram) { return "0b"; } - // 79% of off-heap (RAM - heap) leaves ~21% headroom for unmanaged native consumers: - // Lucene mmap, Liquid cache, OS page cache, sidecar processes. Matches the partitioning + // 80% of off-heap (RAM - heap) leaves ~20% headroom for unmanaged native consumers: + // Lucene mmap, Liquid cache, OS page cache, sidecar processes. Raised from 79% to 80% + // to accommodate the DataFusion parquet cache budget (1% of off-heap carved from + // unmanaged headroom + 2% from the DataFusion operator pool). Matches the partitioning // model in PR #21732. Operators with predominantly analytics workloads can raise this // toward 100%; search-heavy workloads needing more page cache should lower it. long offHeap = ram - heap; - return Long.toString(offHeap * 79 / 100) + "b"; + return Long.toString(offHeap * 80 / 100) + "b"; } /** diff --git a/server/src/main/java/org/opensearch/plugin/stats/NativeAllocatorStatsRegistry.java b/server/src/main/java/org/opensearch/plugin/stats/NativeAllocatorStatsRegistry.java index 4d49308601b82..f5184a0e77f2b 100644 --- a/server/src/main/java/org/opensearch/plugin/stats/NativeAllocatorStatsRegistry.java +++ b/server/src/main/java/org/opensearch/plugin/stats/NativeAllocatorStatsRegistry.java @@ -10,6 +10,8 @@ import org.opensearch.common.Nullable; +import java.util.function.Consumer; +import java.util.function.DoubleSupplier; import java.util.function.Supplier; /** @@ -34,6 +36,13 @@ public final class NativeAllocatorStatsRegistry { private final Supplier supplier; + /** + * Optional sink that forwards a node-level native-memory pressure supplier into the underlying + * allocator. Set by the plugin that owns the allocator (today: {@code ArrowBasePlugin}); may be + * {@code null} when the plugin does not support over-commit admission. + */ + private final Consumer pressureSink; + /** * Constructs a registry wrapping the given supplier. * @@ -42,7 +51,32 @@ public final class NativeAllocatorStatsRegistry { * (e.g. plugin closed). Must not be {@code null} itself. */ public NativeAllocatorStatsRegistry(Supplier supplier) { + this(supplier, null); + } + + /** + * Constructs a registry wrapping the given stats supplier and a pressure-supplier sink. + * + * @param supplier stats snapshot supplier (see single-arg constructor). + * @param pressureSink forwards an injected node-level native-memory pressure supplier into the + * allocator; may be {@code null}. + */ + public NativeAllocatorStatsRegistry(Supplier supplier, Consumer pressureSink) { this.supplier = supplier; + this.pressureSink = pressureSink; + } + + /** + * Injects the node-level native-memory pressure supplier (percent 0–100, or negative when + * unavailable) into the allocator so it can make over-commit admission decisions. No-op when the + * owning plugin did not provide a sink. + * + * @param pressureSupplier supplies the current node native-memory utilization percentage. + */ + public void setNativeMemoryPressureSupplier(DoubleSupplier pressureSupplier) { + if (pressureSink != null) { + pressureSink.accept(pressureSupplier); + } } /** diff --git a/server/src/main/java/org/opensearch/plugins/BlockCache.java b/server/src/main/java/org/opensearch/plugins/BlockCache.java index c2942a18b6fbd..2968d9f43814f 100644 --- a/server/src/main/java/org/opensearch/plugins/BlockCache.java +++ b/server/src/main/java/org/opensearch/plugins/BlockCache.java @@ -110,4 +110,17 @@ default void evictPrefix(String prefix) {} * @return {@code true} if the cache was cleared successfully, {@code false} on failure */ boolean clear(); + + /** + * Returns optional per-tier stats for tiered caches (e.g. separate data and metadata caches). + * + *

    The default implementation returns {@code null}, indicating a single-tier cache. + * Tiered implementations override to provide a breakdown that gets rendered as + * {@code data_cache_stats} and {@code metadata_cache_stats} in the REST response. + * + * @return tiered stats breakdown, or {@code null} if not applicable + */ + default BlockCacheTieredStats tieredStats() { + return null; + } } diff --git a/server/src/main/java/org/opensearch/plugins/BlockCacheStats.java b/server/src/main/java/org/opensearch/plugins/BlockCacheStats.java index f3706ecce5f88..9592c87a05edb 100644 --- a/server/src/main/java/org/opensearch/plugins/BlockCacheStats.java +++ b/server/src/main/java/org/opensearch/plugins/BlockCacheStats.java @@ -62,11 +62,42 @@ */ @ExperimentalApi public record BlockCacheStats(long hits, long misses, long hitBytes, long missBytes, long evictions, long evictionBytes, long removed, - long removedBytes, long memoryBytesUsed, long diskBytesUsed, long totalBytes, long activeInBytes) + long removedBytes, long memoryBytesUsed, long diskBytesUsed, long totalBytes, long activeInBytes, BlockCacheTieredStats tieredStats) implements Writeable, ToXContentFragment { + public BlockCacheStats( + long hits, + long misses, + long hitBytes, + long missBytes, + long evictions, + long evictionBytes, + long removed, + long removedBytes, + long memoryBytesUsed, + long diskBytesUsed, + long totalBytes, + long activeInBytes + ) { + this( + hits, + misses, + hitBytes, + missBytes, + evictions, + evictionBytes, + removed, + removedBytes, + memoryBytesUsed, + diskBytesUsed, + totalBytes, + activeInBytes, + null + ); + } + public BlockCacheStats(StreamInput in) throws IOException { this( in.readLong(), @@ -80,7 +111,8 @@ public BlockCacheStats(StreamInput in) throws IOException { in.readLong(), in.readLong(), in.readLong(), - in.readLong() + in.readLong(), + in.getVersion().onOrAfter(org.opensearch.Version.V_3_8_0) ? in.readOptionalWriteable(BlockCacheTieredStats::new) : null ); } @@ -98,19 +130,21 @@ public void writeTo(StreamOutput out) throws IOException { out.writeLong(diskBytesUsed); out.writeLong(totalBytes); out.writeLong(activeInBytes); + if (out.getVersion().onOrAfter(org.opensearch.Version.V_3_8_0)) { + out.writeOptionalWriteable(tieredStats); + } } @Override public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { builder.startObject("block_cache"); - // over_all_stats: aggregate across all block cache implementations (today only Foyer) renderBlockCacheSubStats(builder, "over_all_stats"); - // full_file_stats: not supported in Foyer yet renderFullFileSubStats(builder, "full_file_stats"); - // block_file_stats: Foyer's own stats renderBlockCacheSubStats(builder, "block_file_stats"); - // pinned_file_stats: not supported in Foyer yet renderPinnedSubStats(builder, "pinned_file_stats"); + if (tieredStats != null) { + tieredStats.toXContent(builder, params); + } builder.endObject(); return builder; } @@ -119,16 +153,15 @@ private void renderBlockCacheSubStats(XContentBuilder builder, String name) thro builder.startObject(name); builder.field("active_in_bytes", activeInBytes); builder.field("used_in_bytes", diskBytesUsed + memoryBytesUsed); - builder.field("pinned_in_bytes", 0); // not supported in Foyer yet + builder.field("pinned_in_bytes", 0); builder.field("evictions_in_bytes", evictionBytes); builder.field("removed_in_bytes", removedBytes); - builder.field("active_percent", 0); // not supported in Foyer yet + builder.field("active_percent", 0); builder.field("hit_count", hits); builder.field("miss_count", misses); builder.endObject(); } - // full_file_stats and pinned_file_stats are not supported in Foyer yet — rendered as zeros private static void renderFullFileSubStats(XContentBuilder builder, String name) throws IOException { builder.startObject(name); builder.field("active_in_bytes", 0); diff --git a/server/src/main/java/org/opensearch/plugins/BlockCacheTieredStats.java b/server/src/main/java/org/opensearch/plugins/BlockCacheTieredStats.java new file mode 100644 index 0000000000000..7ec18883a87bc --- /dev/null +++ b/server/src/main/java/org/opensearch/plugins/BlockCacheTieredStats.java @@ -0,0 +1,129 @@ +/* + * 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.plugins; + +import org.opensearch.common.annotation.ExperimentalApi; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.common.io.stream.Writeable; +import org.opensearch.core.xcontent.ToXContentFragment; +import org.opensearch.core.xcontent.XContentBuilder; + +import java.io.IOException; + +/** + * Per-tier breakdown for tiered block caches (e.g. separate data and metadata SSDs). + * + *

    Rendered as {@code data_cache_stats} and {@code metadata_cache_stats} sub-objects + * within the {@code block_cache} section of the node stats response. Only present + * when the block cache is configured in tiered mode. + * + *

    The {@code removed} counters track explicit invalidations (e.g. shard deletion + * via {@code evict_prefix}, prune via {@code clear()}) on each tier independently + * from capacity-driven evictions. + * + *

    Wire format is gated at {@link org.opensearch.Version#V_3_8_0} via the outer + * {@link BlockCacheStats} reader; the inner field set defined here was finalized + * before the V_3_8_0 release and does not require a separate gate. + * + * @opensearch.experimental + */ +@ExperimentalApi +public record BlockCacheTieredStats(long dataHits, long dataMisses, long dataHitBytes, long dataMissBytes, long dataEvictions, + long dataEvictionBytes, long dataRemoved, long dataRemovedBytes, long dataUsedBytes, long dataCapacityBytes, long dataActiveInBytes, + long metadataHits, long metadataMisses, long metadataHitBytes, long metadataMissBytes, long metadataEvictions, + long metadataEvictionBytes, long metadataRemoved, long metadataRemovedBytes, long metadataUsedBytes, long metadataCapacityBytes, + long metadataActiveInBytes) implements Writeable, ToXContentFragment { + + public BlockCacheTieredStats(StreamInput in) throws IOException { + this( + in.readLong(), + in.readLong(), + in.readLong(), + in.readLong(), + in.readLong(), + in.readLong(), + in.readLong(), + in.readLong(), + in.readLong(), + in.readLong(), + in.readLong(), + in.readLong(), + in.readLong(), + in.readLong(), + in.readLong(), + in.readLong(), + in.readLong(), + in.readLong(), + in.readLong(), + in.readLong(), + in.readLong(), + in.readLong() + ); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeLong(dataHits); + out.writeLong(dataMisses); + out.writeLong(dataHitBytes); + out.writeLong(dataMissBytes); + out.writeLong(dataEvictions); + out.writeLong(dataEvictionBytes); + out.writeLong(dataRemoved); + out.writeLong(dataRemovedBytes); + out.writeLong(dataUsedBytes); + out.writeLong(dataCapacityBytes); + out.writeLong(dataActiveInBytes); + out.writeLong(metadataHits); + out.writeLong(metadataMisses); + out.writeLong(metadataHitBytes); + out.writeLong(metadataMissBytes); + out.writeLong(metadataEvictions); + out.writeLong(metadataEvictionBytes); + out.writeLong(metadataRemoved); + out.writeLong(metadataRemovedBytes); + out.writeLong(metadataUsedBytes); + out.writeLong(metadataCapacityBytes); + out.writeLong(metadataActiveInBytes); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject("data_cache_stats"); + builder.field("active_in_bytes", dataActiveInBytes); + builder.field("used_in_bytes", dataUsedBytes); + builder.field("capacity_in_bytes", dataCapacityBytes); + builder.field("evictions_in_bytes", dataEvictionBytes); + builder.field("removed_in_bytes", dataRemovedBytes); + builder.field("hit_count", dataHits); + builder.field("miss_count", dataMisses); + builder.field("hit_bytes", dataHitBytes); + builder.field("miss_bytes", dataMissBytes); + builder.field("eviction_count", dataEvictions); + builder.field("removed_count", dataRemoved); + builder.endObject(); + + builder.startObject("metadata_cache_stats"); + builder.field("active_in_bytes", metadataActiveInBytes); + builder.field("used_in_bytes", metadataUsedBytes); + builder.field("capacity_in_bytes", metadataCapacityBytes); + builder.field("evictions_in_bytes", metadataEvictionBytes); + builder.field("removed_in_bytes", metadataRemovedBytes); + builder.field("hit_count", metadataHits); + builder.field("miss_count", metadataMisses); + builder.field("hit_bytes", metadataHitBytes); + builder.field("miss_bytes", metadataMissBytes); + builder.field("eviction_count", metadataEvictions); + builder.field("removed_count", metadataRemoved); + builder.endObject(); + + return builder; + } +} diff --git a/server/src/main/java/org/opensearch/plugins/DocumentLookupProvider.java b/server/src/main/java/org/opensearch/plugins/DocumentLookupProvider.java new file mode 100644 index 0000000000000..61bc2f7bdd99c --- /dev/null +++ b/server/src/main/java/org/opensearch/plugins/DocumentLookupProvider.java @@ -0,0 +1,70 @@ +/* + * 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.plugins; + +import org.opensearch.common.annotation.ExperimentalApi; +import org.opensearch.core.index.Index; +import org.opensearch.index.engine.Engine; +import org.opensearch.index.engine.exec.DocumentMetadataResolver; +import org.opensearch.index.engine.exec.IndexReaderProvider; +import org.opensearch.index.get.DocumentLookupResult; + +import java.io.IOException; +import java.util.List; + +/** + * SPI for pluggable get-by-id lookup. Implementations resolve a document id + * against the shard's current reader snapshot and return a + * {@link DocumentLookupResult} with the source (and optionally other fields) + * for the matching row. + * + * @opensearch.experimental + */ +@ExperimentalApi +public interface DocumentLookupProvider { + + /** + * Resolve the document referenced by {@code get} against {@code reader}. + * + * @param get the get request (id, realtime flag, etc.) + * @param reader a point-in-time index reader acquired by the caller + * @param index the index, used by implementations as the scan table name + * @param resolver the {@link DocumentMetadataResolver} used to resolve a document's row location + * @return the lookup result; never {@code null} + * @throws IOException if resolution fails + */ + DocumentLookupResult getById(Engine.Get get, IndexReaderProvider.Reader reader, Index index, DocumentMetadataResolver resolver) + throws IOException; + + /** + * Resolves only version metadata ({@code _version}/{@code _seq_no}/{@code _primary_term}) for an id, + * skipping {@code _source} reconstruction. The {@code resolver} resolves the document's row location. + */ + default DocumentLookupResult getVersionMetadata( + String id, + IndexReaderProvider.Reader reader, + Index index, + DocumentMetadataResolver resolver + ) throws IOException { + return DocumentLookupResult.notFound(id); + } + + /** + * Returns metadata for all documents with {@code _seq_no > fromSeqNoExclusive}. Default returns empty list. + * The {@code resolver} resolves each document's row location. + */ + default List getDocsAboveSeqNo( + long fromSeqNoExclusive, + IndexReaderProvider.Reader reader, + Index index, + DocumentMetadataResolver resolver + ) throws IOException { + return List.of(); + } +} diff --git a/server/src/main/java/org/opensearch/ratelimitting/admissioncontrol/AdmissionControlService.java b/server/src/main/java/org/opensearch/ratelimitting/admissioncontrol/AdmissionControlService.java index e97593c15cc01..fa567e374ccb7 100644 --- a/server/src/main/java/org/opensearch/ratelimitting/admissioncontrol/AdmissionControlService.java +++ b/server/src/main/java/org/opensearch/ratelimitting/admissioncontrol/AdmissionControlService.java @@ -14,6 +14,7 @@ import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.settings.Settings; import org.opensearch.node.ResourceUsageCollectorService; +import org.opensearch.plugin.stats.NativeAllocatorPoolStats; import org.opensearch.ratelimitting.admissioncontrol.controllers.AdmissionController; import org.opensearch.ratelimitting.admissioncontrol.controllers.CpuBasedAdmissionController; import org.opensearch.ratelimitting.admissioncontrol.controllers.IoBasedAdmissionController; @@ -27,6 +28,7 @@ import java.util.List; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.function.Supplier; import static org.opensearch.ratelimitting.admissioncontrol.controllers.CpuBasedAdmissionController.CPU_BASED_ADMISSION_CONTROLLER; import static org.opensearch.ratelimitting.admissioncontrol.controllers.IoBasedAdmissionController.IO_BASED_ADMISSION_CONTROLLER; @@ -43,6 +45,7 @@ public class AdmissionControlService { private final ClusterService clusterService; private final Settings settings; private final ResourceUsageCollectorService resourceUsageCollectorService; + private final Supplier nativeAllocatorStatsSupplier; /** * @@ -50,12 +53,15 @@ public class AdmissionControlService { * @param clusterService ClusterService Instance * @param threadPool ThreadPool Instance * @param resourceUsageCollectorService Instance used to get node resource usage stats + * @param nativeAllocatorStatsSupplier Nullable supplier of native allocator pool stats, forwarded to the + * native-memory admission controller for indexing-pool based rejection */ public AdmissionControlService( Settings settings, ClusterService clusterService, ThreadPool threadPool, - ResourceUsageCollectorService resourceUsageCollectorService + ResourceUsageCollectorService resourceUsageCollectorService, + Supplier nativeAllocatorStatsSupplier ) { this.threadPool = threadPool; this.admissionControlSettings = new AdmissionControlSettings(clusterService.getClusterSettings(), settings); @@ -63,6 +69,7 @@ public AdmissionControlService( this.clusterService = clusterService; this.settings = settings; this.resourceUsageCollectorService = resourceUsageCollectorService; + this.nativeAllocatorStatsSupplier = nativeAllocatorStatsSupplier; this.initialize(); } @@ -122,7 +129,8 @@ private AdmissionController controllerFactory(String admissionControllerName) { admissionControllerName, this.resourceUsageCollectorService, this.clusterService, - this.settings + this.settings, + this.nativeAllocatorStatsSupplier ); default: throw new IllegalArgumentException("Not Supported AdmissionController : " + admissionControllerName); diff --git a/server/src/main/java/org/opensearch/ratelimitting/admissioncontrol/controllers/NativeMemoryBasedAdmissionController.java b/server/src/main/java/org/opensearch/ratelimitting/admissioncontrol/controllers/NativeMemoryBasedAdmissionController.java index da494bb510807..b1c02a1b5580b 100644 --- a/server/src/main/java/org/opensearch/ratelimitting/admissioncontrol/controllers/NativeMemoryBasedAdmissionController.java +++ b/server/src/main/java/org/opensearch/ratelimitting/admissioncontrol/controllers/NativeMemoryBasedAdmissionController.java @@ -10,16 +10,21 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.opensearch.arrow.spi.PoolGroup; import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.settings.Settings; +import org.opensearch.common.unit.TimeValue; +import org.opensearch.common.util.SingleObjectCache; import org.opensearch.core.concurrency.OpenSearchRejectedExecutionException; import org.opensearch.node.NodeResourceUsageStats; import org.opensearch.node.ResourceUsageCollectorService; +import org.opensearch.plugin.stats.NativeAllocatorPoolStats; import org.opensearch.ratelimitting.admissioncontrol.enums.AdmissionControlActionType; import org.opensearch.ratelimitting.admissioncontrol.settings.NativeMemoryBasedAdmissionControllerSettings; import java.util.Locale; import java.util.Optional; +import java.util.function.Supplier; /** * Class for Native Memory Based Admission Controller in OpenSearch, which aims to provide @@ -29,22 +34,53 @@ public class NativeMemoryBasedAdmissionController extends AdmissionController { public static final String NATIVE_MEMORY_BASED_ADMISSION_CONTROLLER = "global_native_memory_usage"; private static final Logger LOGGER = LogManager.getLogger(NativeMemoryBasedAdmissionController.class); + + /** + * Refresh interval for the cached indexing-pool utilization snapshot. The pool stats supplier is + * sampled at most once per interval so the per-request admission path only reads a cached value. + */ + private static final TimeValue INDEXING_POOL_STATS_REFRESH_INTERVAL = TimeValue.timeValueSeconds(1); + private NativeMemoryBasedAdmissionControllerSettings settings; + /** + * Nullable supplier of native allocator pool stats, installed by a plugin (today: ArrowBasePlugin). + * When {@code null} the indexing-pool admission check is skipped entirely and behavior is unchanged. + */ + private final Supplier nativeAllocatorStatsSupplier; + + /** + * Cached indexing-pool utilization percentage ({@code allocated / limit * 100}), or {@code null} when + * no stats supplier is installed. A value of {@code -1.0} means the signal is currently unavailable. + */ + private final SingleObjectCache indexingPoolUtilizationCache; + /** * @param admissionControllerName Name of the admission controller * @param resourceUsageCollectorService Instance used to get node resource usage stats * @param clusterService ClusterService Instance * @param settings Immutable settings instance + * @param nativeAllocatorStatsSupplier Nullable supplier of native allocator pool stats; when {@code null} + * the indexing-pool admission check is disabled */ public NativeMemoryBasedAdmissionController( String admissionControllerName, ResourceUsageCollectorService resourceUsageCollectorService, ClusterService clusterService, - Settings settings + Settings settings, + Supplier nativeAllocatorStatsSupplier ) { super(admissionControllerName, resourceUsageCollectorService, clusterService); this.settings = new NativeMemoryBasedAdmissionControllerSettings(clusterService.getClusterSettings(), settings); + this.nativeAllocatorStatsSupplier = nativeAllocatorStatsSupplier; + this.indexingPoolUtilizationCache = nativeAllocatorStatsSupplier == null + ? null + : new SingleObjectCache<>(INDEXING_POOL_STATS_REFRESH_INTERVAL, -1.0) { + @Override + protected Double refresh() { + return computeIndexingPoolUtilizationPercent(); + } + }; } /** @@ -107,9 +143,53 @@ private boolean isLimitsBreached(String actionName, AdmissionControlActionType a } } } + // Indexing-pool based check: reject indexing requests when the INDEXING native memory pool + // utilization breaches its configured limit. Skipped entirely when no stats supplier is installed. + if (admissionControlActionType == AdmissionControlActionType.INDEXING && this.indexingPoolUtilizationCache != null) { + double indexingPoolUsage = this.indexingPoolUtilizationCache.getOrRefresh(); + long indexingPoolLimit = this.settings.getIndexingNativeMemoryPoolUsageLimit(); + if (indexingPoolUsage >= 0.0 && indexingPoolUsage >= indexingPoolLimit) { + LOGGER.warn( + "NativeMemoryBasedAdmissionController limit reached as the current indexing native memory pool " + + "usage [{}] exceeds the allowed limit [{}] for transport action [{}] in admissionControlMode [{}]", + indexingPoolUsage, + indexingPoolLimit, + actionName, + this.settings.getTransportLayerAdmissionControllerMode() + ); + return true; + } + } return false; } + /** + * Computes the current utilization percentage of the INDEXING native memory pool group as + * {@code allocated / limit * 100}. Returns {@code -1.0} (signal unavailable) when the supplier is + * absent, returns {@code null}, the INDEXING group is missing, or the pool limit is non-positive. + */ + private double computeIndexingPoolUtilizationPercent() { + Supplier supplier = this.nativeAllocatorStatsSupplier; + if (supplier == null) { + return -1.0; + } + NativeAllocatorPoolStats stats; + try { + stats = supplier.get(); + } catch (RuntimeException e) { + LOGGER.debug("native allocator pool stats supplier threw; skipping indexing-pool admission check", e); + return -1.0; + } + if (stats == null) { + return -1.0; + } + NativeAllocatorPoolStats.PoolStats indexingGroup = stats.getGroupedStats().get(PoolGroup.INDEXING.getName()); + if (indexingGroup == null || indexingGroup.getLimitBytes() <= 0) { + return -1.0; + } + return 100.0 * indexingGroup.getAllocatedBytes() / indexingGroup.getLimitBytes(); + } + /** * Get memory rejection threshold based on action type */ diff --git a/server/src/main/java/org/opensearch/ratelimitting/admissioncontrol/settings/NativeMemoryBasedAdmissionControllerSettings.java b/server/src/main/java/org/opensearch/ratelimitting/admissioncontrol/settings/NativeMemoryBasedAdmissionControllerSettings.java index abc051f5a7837..f81a14a35897f 100644 --- a/server/src/main/java/org/opensearch/ratelimitting/admissioncontrol/settings/NativeMemoryBasedAdmissionControllerSettings.java +++ b/server/src/main/java/org/opensearch/ratelimitting/admissioncontrol/settings/NativeMemoryBasedAdmissionControllerSettings.java @@ -26,12 +26,14 @@ public class NativeMemoryBasedAdmissionControllerSettings { public static class Defaults { public static final long NATIVE_MEMORY_USAGE_LIMIT = 95; public static final long CLUSTER_ADMIN_NATIVE_MEMORY_USAGE_LIMIT = 95; + public static final long INDEXING_NATIVE_MEMORY_POOL_USAGE_LIMIT = 90; } private AdmissionControlMode transportLayerMode; private Long searchNativeMemoryUsageLimit; private Long indexingNativeMemoryUsageLimit; private Long clusterAdminNativeMemoryUsageLimit; + private Long indexingNativeMemoryPoolUsageLimit; /** * Feature level setting to operate in shadow-mode or in enforced-mode. If enforced field is set @@ -78,6 +80,20 @@ public static class Defaults { Setting.Property.NodeScope ); + /** + * This setting is used to reject indexing requests based on the utilization of the indexing native + * memory pool (the {@code PoolGroup.INDEXING} group of the native allocator pools), as opposed to the + * node-wide native memory utilization governed by {@link #INDEXING_NATIVE_MEMORY_USAGE_LIMIT}. + * The value is a percentage (0-100) of the pool's allocated bytes over its limit bytes. + */ + public static final Setting INDEXING_NATIVE_MEMORY_POOL_USAGE_LIMIT = Setting.longSetting( + "admission_control.indexing.native_memory_pool_usage.limit", + Defaults.INDEXING_NATIVE_MEMORY_POOL_USAGE_LIMIT, + 0, + Setting.Property.Dynamic, + Setting.Property.NodeScope + ); + public NativeMemoryBasedAdmissionControllerSettings(ClusterSettings clusterSettings, Settings settings) { this.transportLayerMode = NATIVE_MEMORY_BASED_ADMISSION_CONTROLLER_TRANSPORT_LAYER_MODE.get(settings); clusterSettings.addSettingsUpdateConsumer( @@ -87,9 +103,11 @@ public NativeMemoryBasedAdmissionControllerSettings(ClusterSettings clusterSetti this.searchNativeMemoryUsageLimit = SEARCH_NATIVE_MEMORY_USAGE_LIMIT.get(settings); this.indexingNativeMemoryUsageLimit = INDEXING_NATIVE_MEMORY_USAGE_LIMIT.get(settings); this.clusterAdminNativeMemoryUsageLimit = CLUSTER_ADMIN_NATIVE_MEMORY_USAGE_LIMIT.get(settings); + this.indexingNativeMemoryPoolUsageLimit = INDEXING_NATIVE_MEMORY_POOL_USAGE_LIMIT.get(settings); clusterSettings.addSettingsUpdateConsumer(SEARCH_NATIVE_MEMORY_USAGE_LIMIT, this::setSearchNativeMemoryUsageLimit); clusterSettings.addSettingsUpdateConsumer(INDEXING_NATIVE_MEMORY_USAGE_LIMIT, this::setIndexingNativeMemoryUsageLimit); clusterSettings.addSettingsUpdateConsumer(CLUSTER_ADMIN_NATIVE_MEMORY_USAGE_LIMIT, this::setClusterAdminNativeMemoryUsageLimit); + clusterSettings.addSettingsUpdateConsumer(INDEXING_NATIVE_MEMORY_POOL_USAGE_LIMIT, this::setIndexingNativeMemoryPoolUsageLimit); } public void setTransportLayerMode(AdmissionControlMode transportLayerMode) { @@ -123,4 +141,12 @@ public Long getClusterAdminNativeMemoryUsageLimit() { public void setClusterAdminNativeMemoryUsageLimit(Long clusterAdminNativeMemoryUsageLimit) { this.clusterAdminNativeMemoryUsageLimit = clusterAdminNativeMemoryUsageLimit; } + + public Long getIndexingNativeMemoryPoolUsageLimit() { + return indexingNativeMemoryPoolUsageLimit; + } + + public void setIndexingNativeMemoryPoolUsageLimit(Long indexingNativeMemoryPoolUsageLimit) { + this.indexingNativeMemoryPoolUsageLimit = indexingNativeMemoryPoolUsageLimit; + } } diff --git a/server/src/main/java/org/opensearch/repositories/fs/FsRepository.java b/server/src/main/java/org/opensearch/repositories/fs/FsRepository.java index 1d202cce11697..f9af8d376c127 100644 --- a/server/src/main/java/org/opensearch/repositories/fs/FsRepository.java +++ b/server/src/main/java/org/opensearch/repositories/fs/FsRepository.java @@ -102,7 +102,19 @@ public class FsRepository extends BlobStoreRepository { Property.Deprecated ); - public static final Setting BASE_PATH_SETTING = Setting.simpleString("base_path"); + public static final Setting BASE_PATH_SETTING = Setting.simpleString("base_path", value -> { + // CWE-22 hardening (string-only fail-fast; avoids constructing a Path from a raw user-supplied string): + // reject absolute base_path values (POSIX "/", Windows "\\"/UNC, drive-letter "C:..") and any parent-directory + // ("..") segment. An absolute base_path makes Path.resolve discard the path.repo-validated location, escaping + // containment; a ".." segment lets the resolved path climb above it. The authoritative, location-aware + // containment check is performed in validateBasePathWithinRepo(). + if (Strings.hasLength(value) + && (value.startsWith("/") || value.startsWith("\\") || value.matches("^[A-Za-z]:.*") || value.contains(".."))) { + throw new IllegalArgumentException( + "[base_path] must be a relative path that does not contain '..' segments; got [" + value + "]" + ); + } + }); protected final Environment environment; @@ -169,12 +181,43 @@ protected void readMetadata() { } final String basePath = BASE_PATH_SETTING.get(metadata.settings()); if (Strings.hasLength(basePath)) { + if (isUnderRepo(basePath) == false) { + throw new RepositoryException( + metadata.name(), + "base_path [" + basePath + "] resolves to a location outside of the repository paths specified by path.repo" + ); + } this.basePath = new BlobPath().add(basePath); } else { this.basePath = BlobPath.cleanPath(); } } + /** + * Defense-in-depth containment check (CWE-22) that complements the {@link #BASE_PATH_SETTING} setting-level + * validator: ensures the user-supplied {@code base_path}, once resolved against the already-validated repository + * {@code location}, still falls within one of the operator-configured {@code path.repo} directories. The setting + * validator already rejects absolute and upward-escaping values; this location-aware check is the authoritative + * backstop. Without containment, an absolute {@code base_path} would cause {@link java.nio.file.Path#resolve} to + * discard the validated location entirely and redirect all blob-store operations to an arbitrary filesystem path + * (the {@code /_snapshot//_cleanup} arbitrary-deletion vector). + */ + private boolean isUnderRepo(String basePath) { + final String location = REPOSITORIES_LOCATION_SETTING.get(metadata.settings()); + final Path locationFile = environment.resolveRepoFile(location); + if (locationFile == null) { + // location is already validated by validateLocation(); a null here is treated as a containment failure. + return false; + } + final Path resolved = locationFile.resolve(basePath).normalize(); + for (Path repoPath : environment.repoFiles()) { + if (resolved.startsWith(repoPath)) { + return true; + } + } + return false; + } + protected void validateLocation() { String location = REPOSITORIES_LOCATION_SETTING.get(metadata.settings()); if (location.isEmpty()) { diff --git a/server/src/main/java/org/opensearch/snapshots/RestoreService.java b/server/src/main/java/org/opensearch/snapshots/RestoreService.java index 666e31fcd0ed6..eca658dfd0579 100644 --- a/server/src/main/java/org/opensearch/snapshots/RestoreService.java +++ b/server/src/main/java/org/opensearch/snapshots/RestoreService.java @@ -173,6 +173,78 @@ public class RestoreService implements ClusterStateApplier { USER_UNREMOVABLE_SETTINGS = unmodifiableSet(unremovable); } + /** + * Creates a settings filter predicate that separates internal ignore patterns from user ignore patterns. + * Internal ignore patterns override protection and can filter any setting. + * User ignore patterns respect protected settings and cannot filter them. + * + * @param userIgnoreSettings array of user-provided settings to ignore + * @param internalIgnoreSettings array of internal settings to ignore (override protection) + * @param protectedSettings set of settings that user cannot remove + * @return a predicate that returns true if the setting should be kept, false if it should be filtered out + */ + static Predicate createSettingsFilterPredicate( + String[] userIgnoreSettings, + String[] internalIgnoreSettings, + Set protectedSettings + ) { + Set userKeyFilters = new HashSet<>(); + List userSimpleMatchPatterns = new ArrayList<>(); + Set internalKeyFilters = new HashSet<>(); + List internalSimpleMatchPatterns = new ArrayList<>(); + + // Process user ignore settings + for (String ignoredSetting : userIgnoreSettings) { + if (!Regex.isSimpleMatchPattern(ignoredSetting)) { + userKeyFilters.add(ignoredSetting); + } else { + userSimpleMatchPatterns.add(ignoredSetting); + } + } + + // Process internal ignore settings + for (String ignoredSetting : internalIgnoreSettings) { + if (!Regex.isSimpleMatchPattern(ignoredSetting)) { + internalKeyFilters.add(ignoredSetting); + } else { + internalSimpleMatchPatterns.add(ignoredSetting); + } + } + + return k -> { + // Check internal ignore patterns first (they override protection) + if (internalKeyFilters.contains(k)) { + return false; + } + for (String pattern : internalSimpleMatchPatterns) { + if (Regex.simpleMatch(pattern, k)) { + return false; + } + } + + // Check user ignore patterns only for non-protected settings + if (!protectedSettings.contains(k)) { + if (userKeyFilters.contains(k)) { + return false; + } + for (String pattern : userSimpleMatchPatterns) { + if (Regex.simpleMatch(pattern, k)) { + return false; + } + } + } + return true; + }; + } + + /** + * Returns the set of settings that users cannot remove during restore. + * Exposed for testing purposes. + */ + static Set getUserUnremovableSettings() { + return USER_UNREMOVABLE_SETTINGS; + } + private final ClusterService clusterService; private final RepositoriesService repositoriesService; @@ -818,8 +890,7 @@ private IndexMetadata updateIndexSettings( } IndexMetadata.Builder builder = IndexMetadata.builder(indexMetadata); Settings settings = indexMetadata.getSettings(); - Set keyFilters = new HashSet<>(); - List simpleMatchPatterns = new ArrayList<>(); + for (String ignoredSetting : ignoreSettings) { if (!Regex.isSimpleMatchPattern(ignoredSetting)) { if (USER_UNREMOVABLE_SETTINGS.contains(ignoredSetting)) { @@ -832,38 +903,24 @@ private IndexMetadata updateIndexSettings( snapshot, "cannot remove UnmodifiableOnRestore setting [" + ignoredSetting + "] on restore" ); - } else { - keyFilters.add(ignoredSetting); } - } else { - simpleMatchPatterns.add(ignoredSetting); } } - // add internal settings to ignore settings list - for (String ignoredSetting : ignoreSettingsInternal) { - if (!Regex.isSimpleMatchPattern(ignoredSetting)) { - keyFilters.add(ignoredSetting); - } else { - simpleMatchPatterns.add(ignoredSetting); + // Build combined protected settings set including dynamic unmodifiable settings + Set protectedSettings = new HashSet<>(USER_UNREMOVABLE_SETTINGS); + for (String key : settings.keySet()) { + if (indexScopedSettings.isUnmodifiableOnRestoreSetting(key)) { + protectedSettings.add(key); } } - Predicate settingsFilter = k -> { - if (USER_UNREMOVABLE_SETTINGS.contains(k) == false && !indexScopedSettings.isUnmodifiableOnRestoreSetting(k)) { - for (String filterKey : keyFilters) { - if (k.equals(filterKey)) { - return false; - } - } - for (String pattern : simpleMatchPatterns) { - if (Regex.simpleMatch(pattern, k)) { - return false; - } - } - } - return true; - }; + Predicate settingsFilter = createSettingsFilterPredicate( + ignoreSettings, + ignoreSettingsInternal, + protectedSettings + ); + Settings.Builder settingsBuilder = Settings.builder() .put(settings.filter(settingsFilter)) .put(normalizedChangeSettings.filter(k -> { diff --git a/server/src/main/java/org/opensearch/snapshots/SnapshotsService.java b/server/src/main/java/org/opensearch/snapshots/SnapshotsService.java index d9b6d3a303a4d..66026b817bb8a 100644 --- a/server/src/main/java/org/opensearch/snapshots/SnapshotsService.java +++ b/server/src/main/java/org/opensearch/snapshots/SnapshotsService.java @@ -92,6 +92,8 @@ import org.opensearch.core.common.io.stream.StreamInput; import org.opensearch.core.index.Index; import org.opensearch.core.index.shard.ShardId; +import org.opensearch.index.IndexModule; +import org.opensearch.index.IndexSettings; import org.opensearch.index.store.RemoteSegmentStoreDirectoryFactory; import org.opensearch.index.store.lockmanager.RemoteStoreLockManagerFactory; import org.opensearch.indices.RemoteStoreSettings; @@ -505,7 +507,35 @@ public ClusterState execute(ClusterState currentState) { createSnapshotPreValidations(currentState, repositoryData, repositoryName, snapshotName); - List indices = new ArrayList<>(currentState.metadata().indices().keySet()); + // Exclude warm-tiered pluggable-data-format indexes from V2 snapshots; they are + // not currently restorable. The rest of the cluster is captured normally. + final List allIndices = new ArrayList<>(currentState.metadata().indices().keySet()); + final List excludedWarmDfa = new ArrayList<>(); + final List indices = new ArrayList<>(); + for (String indexName : allIndices) { + IndexMetadata idxMd = currentState.metadata().index(indexName); + if (idxMd != null + && IndexSettings.PLUGGABLE_DATAFORMAT_ENABLED_SETTING.get(idxMd.getSettings()) + && IndexModule.IS_WARM_INDEX_SETTING.get(idxMd.getSettings())) { + excludedWarmDfa.add(indexName); + } else { + indices.add(indexName); + } + } + if (excludedWarmDfa.isEmpty() == false) { + logger.info( + "[{}][{}] excluding [{}] warm-tiered pluggable data format index(es) from snapshot v2 (not currently supported)", + repositoryName, + snapshotName, + excludedWarmDfa.size() + ); + logger.trace( + "[{}][{}] excluded warm-tiered pluggable data format indexes from snapshot v2: {}", + repositoryName, + snapshotName, + excludedWarmDfa + ); + } final List dataStreams = indexNameExpressionResolver.dataStreamNames( currentState, diff --git a/server/src/main/java/org/opensearch/storage/action/tiering/TransportPrepareTieringAction.java b/server/src/main/java/org/opensearch/storage/action/tiering/TransportPrepareTieringAction.java index ee74a17c828e1..3f281ea6e06cb 100644 --- a/server/src/main/java/org/opensearch/storage/action/tiering/TransportPrepareTieringAction.java +++ b/server/src/main/java/org/opensearch/storage/action/tiering/TransportPrepareTieringAction.java @@ -63,6 +63,7 @@ public class TransportPrepareTieringAction extends TransportBroadcastByNodeActio private final IndicesService indicesService; private final ThreadPool threadPool; private volatile TimeValue prepareTieringTimeout; + private volatile TimeValue replicaSyncTimeout; /** * Constructs a TransportPrepareTieringAction. @@ -94,6 +95,9 @@ public TransportPrepareTieringAction( this.threadPool = transportService.getThreadPool(); this.prepareTieringTimeout = TieringUtils.PREPARE_TIERING_TIMEOUT.get(clusterService.getSettings()); clusterService.getClusterSettings().addSettingsUpdateConsumer(TieringUtils.PREPARE_TIERING_TIMEOUT, this::setPrepareTieringTimeout); + this.replicaSyncTimeout = TieringUtils.REPLICA_SYNC_TIMEOUT_SETTING.get(clusterService.getSettings()); + clusterService.getClusterSettings() + .addSettingsUpdateConsumer(TieringUtils.REPLICA_SYNC_TIMEOUT_SETTING, this::setReplicaSyncTimeout); } private void setPrepareTieringTimeout(TimeValue timeout) { @@ -101,6 +105,11 @@ private void setPrepareTieringTimeout(TimeValue timeout) { logger.info("Updated prepare tiering timeout to [{}]", timeout); } + private void setReplicaSyncTimeout(TimeValue timeout) { + this.replicaSyncTimeout = timeout; + logger.info("Updated replica sync timeout to [{}]", timeout); + } + /** * Opt-in to async shard operation execution. Each shard's syncAndFlush is dispatched * to the thread pool in parallel, and the transport response is sent only after @@ -254,6 +263,9 @@ private void completeSyncAndFlush(IndexShard indexShard, ShardRouting shardRouti // "prepare_tiering" source bypasses the freeze guard — this is the last refresh ever. indexShard.refresh("prepare_tiering"); indexShard.waitForRemoteStoreSync(); + // Wait for replicas to apply the latest checkpoint before relocation. + // This ensures replicas have downloaded the merged segment(s) from remote store. + indexShard.waitForReplicaSync(replicaSyncTimeout); verifyNoUncommittedOps(indexShard, shardRouting); logger.debug("Shard [{}] prepared for tiering successfully", shardRouting.shardId()); diff --git a/server/src/main/java/org/opensearch/storage/common/tiering/TieringUtils.java b/server/src/main/java/org/opensearch/storage/common/tiering/TieringUtils.java index 85c220e2edc5d..657f6ecc18903 100644 --- a/server/src/main/java/org/opensearch/storage/common/tiering/TieringUtils.java +++ b/server/src/main/java/org/opensearch/storage/common/tiering/TieringUtils.java @@ -143,6 +143,17 @@ private TieringUtils() {} Setting.Property.NodeScope ); + public static final String REPLICA_SYNC_TIMEOUT_KEY = "tiering.prepare.replica_sync_timeout"; + /** Setting for how long to wait for replicas to sync during tiering preparation. Dynamically updatable. */ + public static final Setting REPLICA_SYNC_TIMEOUT_SETTING = Setting.timeSetting( + REPLICA_SYNC_TIMEOUT_KEY, + TimeValue.timeValueSeconds(30), + TimeValue.timeValueSeconds(5), + TimeValue.timeValueMinutes(5), + Setting.Property.NodeScope, + Setting.Property.Dynamic + ); + /** * List of index name prefixes that should be allowed to be migrated. * This takes precedence over the 'block-listed' index prefixes. diff --git a/server/src/main/java/org/opensearch/transport/RemoteClusterAwareClient.java b/server/src/main/java/org/opensearch/transport/RemoteClusterAwareClient.java index b3361d88ee7a0..1b91adf3a564a 100644 --- a/server/src/main/java/org/opensearch/transport/RemoteClusterAwareClient.java +++ b/server/src/main/java/org/opensearch/transport/RemoteClusterAwareClient.java @@ -35,29 +35,48 @@ import org.opensearch.action.ActionRequest; import org.opensearch.action.ActionType; import org.opensearch.cluster.node.DiscoveryNode; +import org.opensearch.common.annotation.ExperimentalApi; import org.opensearch.common.settings.Settings; import org.opensearch.core.action.ActionListener; import org.opensearch.core.action.ActionResponse; +import org.opensearch.core.transport.TransportResponse; import org.opensearch.threadpool.ThreadPool; import org.opensearch.transport.client.Client; import org.opensearch.transport.client.support.AbstractClient; /** - * Client that is aware of remote clusters + * Client that is aware of remote clusters. Beyond the regular request/response path it exposes + * {@link #sendStreamRequest} for streaming (Arrow Flight) requests to a remote cluster, so it is a + * public type callers can hold to reach that streaming path. * - * @opensearch.internal + * @opensearch.experimental */ -final class RemoteClusterAwareClient extends AbstractClient { +@ExperimentalApi +public final class RemoteClusterAwareClient extends AbstractClient { private final TransportService service; private final String clusterAlias; private final RemoteClusterService remoteClusterService; + // Null when the stream transport is not enabled on this node; sendStreamRequest then throws rather + // than falling back to the regular request/response path. + private final StreamTransportService streamTransportService; RemoteClusterAwareClient(Settings settings, ThreadPool threadPool, TransportService service, String clusterAlias) { + this(settings, threadPool, service, clusterAlias, null); + } + + RemoteClusterAwareClient( + Settings settings, + ThreadPool threadPool, + TransportService service, + String clusterAlias, + StreamTransportService streamTransportService + ) { super(settings, threadPool); this.service = service; this.clusterAlias = clusterAlias; this.remoteClusterService = service.getRemoteClusterService(); + this.streamTransportService = streamTransportService; } @Override @@ -84,6 +103,63 @@ protected void }, listener::onFailure)); } + /** + * Streaming sibling of {@link #doExecute}: sends {@code request} to the remote cluster over the + * stream (Arrow Flight) transport and delivers the response batches to {@code handler}. + * + *

    Semantics mirror {@link #doExecute} exactly — same {@code ensureConnected} on the remote + * cluster, same node selection ({@link RemoteClusterAwareRequest#getPreferredTargetNode()} when + * the request implements it, otherwise any connected remote node) — except the request is + * carried by {@link StreamTransportService} with {@link TransportRequestOptions.Type#STREAM}. + * Because the stream transport maintains its own connections, the target node is connected on + * the stream transport (no-op if already connected) before the request is sent. + * + * @throws IllegalStateException if the stream transport is not enabled on this node. + */ + public void sendStreamRequest( + String action, + TransportRequest request, + DiscoveryNode targetNode, + StreamTransportResponseHandler handler + ) { + if (streamTransportService == null) { + throw new IllegalStateException( + "stream transport is not enabled on this node; cannot open a streaming request to remote cluster [" + clusterAlias + "]" + ); + } + remoteClusterService.ensureConnected(clusterAlias, ActionListener.wrap(v -> { + final DiscoveryNode node = targetNode != null ? targetNode : selectTargetNode(request); + // The stream transport has its own connection manager, so connect the node there separately + // (the ensureConnected above only covers the regular transport). + streamTransportService.connectToNode(node, null, ActionListener.wrap(c -> { + final TransportRequestOptions options = TransportRequestOptions.builder() + .withType(TransportRequestOptions.Type.STREAM) + .withTimeout(streamTransportService.getStreamTransportReqTimeout()) + .build(); + streamTransportService.sendRequest(node, action, request, options, handler); + }, e -> handler.handleException(wrapAsTransportException(node, e)))); + }, e -> handler.handleException(wrapAsTransportException(null, e)))); + } + + /** + * Node-selection rule shared with {@link #doExecute}: the request's preferred target when it is a + * {@link RemoteClusterAwareRequest}, otherwise any connected remote node. ({@code doExecute} keeps + * its connection-oriented form because it needs a {@link Transport.Connection}, not just the node.) + */ + private DiscoveryNode selectTargetNode(TransportRequest request) { + if (request instanceof RemoteClusterAwareRequest remoteClusterAwareRequest) { + return remoteClusterAwareRequest.getPreferredTargetNode(); + } + return remoteClusterService.getConnection(clusterAlias).getNode(); + } + + private static TransportException wrapAsTransportException(DiscoveryNode node, Exception e) { + if (e instanceof TransportException te) { + return te; + } + return node != null ? new ConnectTransportException(node, "failed to open streaming connection", e) : new TransportException(e); + } + @Override public void close() { // do nothing diff --git a/server/src/main/java/org/opensearch/transport/RemoteClusterService.java b/server/src/main/java/org/opensearch/transport/RemoteClusterService.java index fc0f3ab8f9546..fa42216bbba5c 100644 --- a/server/src/main/java/org/opensearch/transport/RemoteClusterService.java +++ b/server/src/main/java/org/opensearch/transport/RemoteClusterService.java @@ -409,6 +409,22 @@ public void onFailure(Exception e) { * @throws IllegalArgumentException if the given clusterAlias doesn't exist */ public Client getRemoteClusterClient(ThreadPool threadPool, String clusterAlias) { + return getRemoteClusterClient(threadPool, clusterAlias, null); + } + + /** + * Returns a client to the remote cluster that additionally supports streaming requests over the + * given {@link StreamTransportService} (Arrow Flight) via + * {@link RemoteClusterAwareClient#sendStreamRequest}. Regular request/response behaviour is + * identical to {@link #getRemoteClusterClient(ThreadPool, String)}; passing a null + * {@code streamTransportService} yields a client whose streaming path is disabled. + * + * @param threadPool the {@link ThreadPool} for the client + * @param clusterAlias the cluster alias the remote cluster is registered under + * @param streamTransportService node-local stream transport service, or null if not enabled + * @throws IllegalArgumentException if the given clusterAlias doesn't exist + */ + public Client getRemoteClusterClient(ThreadPool threadPool, String clusterAlias, StreamTransportService streamTransportService) { if (transportService.getRemoteClusterService().isEnabled() == false) { throw new IllegalArgumentException( "this node does not have the " + DiscoveryNodeRole.REMOTE_CLUSTER_CLIENT_ROLE.roleName() + " role" @@ -417,7 +433,7 @@ public Client getRemoteClusterClient(ThreadPool threadPool, String clusterAlias) if (transportService.getRemoteClusterService().getRemoteClusterNames().contains(clusterAlias) == false) { throw new NoSuchRemoteClusterException(clusterAlias); } - return new RemoteClusterAwareClient(settings, threadPool, transportService, clusterAlias); + return new RemoteClusterAwareClient(settings, threadPool, transportService, clusterAlias, streamTransportService); } Collection getConnections() { diff --git a/server/src/main/java/org/opensearch/transport/StreamTransportService.java b/server/src/main/java/org/opensearch/transport/StreamTransportService.java index 6535e9c8fda41..0cf56be06bd09 100644 --- a/server/src/main/java/org/opensearch/transport/StreamTransportService.java +++ b/server/src/main/java/org/opensearch/transport/StreamTransportService.java @@ -12,6 +12,7 @@ import org.apache.logging.log4j.Logger; import org.opensearch.cluster.node.DiscoveryNode; import org.opensearch.common.Nullable; +import org.opensearch.common.annotation.ExperimentalApi; import org.opensearch.common.settings.ClusterSettings; import org.opensearch.common.settings.Setting; import org.opensearch.common.settings.Settings; @@ -31,8 +32,9 @@ /** * Transport service for streaming requests, handling StreamTransportResponse. - * @opensearch.internal + * @opensearch.experimental */ +@ExperimentalApi public class StreamTransportService extends TransportService { private static final Logger logger = LogManager.getLogger(StreamTransportService.class); public static final Setting STREAM_TRANSPORT_REQ_TIMEOUT_SETTING = Setting.timeSetting( @@ -134,4 +136,14 @@ public Transport.Connection getConnection(DiscoveryNode node) { private void setStreamTransportReqTimeout(TimeValue streamTransportReqTimeout) { this.streamTransportReqTimeout = streamTransportReqTimeout; } + + /** + * The configured streaming request timeout ({@code transport.stream.request_timeout}). Callers + * that build their own {@link TransportRequestOptions} (rather than using the + * {@link #sendChildRequest} overload that injects it) should apply this so streaming requests + * honor the same timeout the service applies by default. + */ + public TimeValue getStreamTransportReqTimeout() { + return streamTransportReqTimeout; + } } diff --git a/server/src/main/java/org/opensearch/transport/TaskTransportChannel.java b/server/src/main/java/org/opensearch/transport/TaskTransportChannel.java index c93c121833dde..de088d884f485 100644 --- a/server/src/main/java/org/opensearch/transport/TaskTransportChannel.java +++ b/server/src/main/java/org/opensearch/transport/TaskTransportChannel.java @@ -78,6 +78,16 @@ public void sendResponseBatch(TransportResponse response) { channel.sendResponseBatch(response); } + @Override + public void sendResponseBatch(TransportResponse response, boolean sync) { + channel.sendResponseBatch(response, sync); + } + + @Override + public boolean isCancelled() { + return channel.isCancelled(); + } + @Override public void completeStream() { try { diff --git a/server/src/main/java/org/opensearch/transport/TransportChannel.java b/server/src/main/java/org/opensearch/transport/TransportChannel.java index bca653ba12f3c..e22196163cbeb 100644 --- a/server/src/main/java/org/opensearch/transport/TransportChannel.java +++ b/server/src/main/java/org/opensearch/transport/TransportChannel.java @@ -74,6 +74,28 @@ default void sendResponseBatch(TransportResponse response) { throw new UnsupportedOperationException(); } + /** + * Sends a batch of responses, optionally blocking the caller until the batch has been handed off to + * the transport. Defaults to the asynchronous behaviour of {@link #sendResponseBatch(TransportResponse)} + * when {@code sync == false}. + * + *

    With {@code sync == true} the caller blocks until the batch has been written to the transport's + * outbound buffer, so it cannot queue the next batch ahead of this one — a bound on outstanding + * batches (useful when the batch holds large buffers that must not accumulate). This does not wait + * for the bytes to leave the machine; if the outbound buffer is full the transport's own readiness + * back-pressure gates the send. The send itself still runs on the channel's send executor; only the + * caller is made to wait. A caller that requests this MUST drive a single stream from a single thread + * and MUST NOT call from the channel's send-executor thread. + * + * @param response the batch of responses to send + * @param sync {@code true} to block the caller until the batch is handed to the transport; {@code false} for async dispatch + * @throws StreamException with {@link StreamErrorCode#CANCELLED} if the stream has been canceled. + */ + @ExperimentalApi + default void sendResponseBatch(TransportResponse response, boolean sync) { + sendResponseBatch(response); + } + /** * Call this method on a successful completion the streaming response. * Note: not calling this method on success will result in a memory leak @@ -83,6 +105,18 @@ default void completeStream() { throw new UnsupportedOperationException(); } + /** + * Whether the stream has been cancelled (by the consumer, or due to a transport error) without + * waiting for the next {@link #sendResponseBatch(TransportResponse)} to throw. A long-lived sender + * that parks between sends can poll this to exit promptly when the consumer goes away, instead of + * leaking until its next send throws or a keepalive timeout fires. Default {@code false} for + * channels without a cancellation signal. + */ + @ExperimentalApi + default boolean isCancelled() { + return false; + } + void sendResponse(TransportResponse response) throws IOException; void sendResponse(Exception exception) throws IOException; diff --git a/server/src/main/java/org/opensearch/transport/stream/StreamingTransportChannel.java b/server/src/main/java/org/opensearch/transport/stream/StreamingTransportChannel.java index 5656cf48756ca..de99d280432e7 100644 --- a/server/src/main/java/org/opensearch/transport/stream/StreamingTransportChannel.java +++ b/server/src/main/java/org/opensearch/transport/stream/StreamingTransportChannel.java @@ -27,8 +27,6 @@ @ExperimentalApi public interface StreamingTransportChannel extends TransportChannel { - // TODO: introduce a way to poll for cancellation in addition to current way of detection i.e. depending on channel - // throwing StreamException with CANCELLED error code. /** * Sends a batch of responses to the request that this channel is associated with. * Call {@link #completeStream()} on a successful completion. diff --git a/server/src/test/java/org/opensearch/OpenSearchTimeoutExceptionTests.java b/server/src/test/java/org/opensearch/OpenSearchTimeoutExceptionTests.java new file mode 100644 index 0000000000000..17bf10429c4c2 --- /dev/null +++ b/server/src/test/java/org/opensearch/OpenSearchTimeoutExceptionTests.java @@ -0,0 +1,25 @@ +/* + * 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; + +import org.opensearch.core.rest.RestStatus; +import org.opensearch.test.OpenSearchTestCase; + +public class OpenSearchTimeoutExceptionTests extends OpenSearchTestCase { + + public void testStatusIsGatewayTimeout() { + OpenSearchTimeoutException e = new OpenSearchTimeoutException("request timed out"); + assertEquals(RestStatus.GATEWAY_TIMEOUT, e.status()); + } + + public void testStatusIsGatewayTimeoutWithCause() { + OpenSearchTimeoutException e = new OpenSearchTimeoutException(new RuntimeException("cause")); + assertEquals(RestStatus.GATEWAY_TIMEOUT, e.status()); + } +} diff --git a/server/src/test/java/org/opensearch/action/admin/indices/rollover/TransportRolloverActionTests.java b/server/src/test/java/org/opensearch/action/admin/indices/rollover/TransportRolloverActionTests.java index 2eccfbafb7529..7a06f31885be6 100644 --- a/server/src/test/java/org/opensearch/action/admin/indices/rollover/TransportRolloverActionTests.java +++ b/server/src/test/java/org/opensearch/action/admin/indices/rollover/TransportRolloverActionTests.java @@ -45,6 +45,9 @@ import org.opensearch.action.support.PlainActionFuture; import org.opensearch.cluster.ClusterName; import org.opensearch.cluster.ClusterState; +import org.opensearch.cluster.DataStreamTestHelper; +import org.opensearch.cluster.block.ClusterBlockException; +import org.opensearch.cluster.block.ClusterBlocks; import org.opensearch.cluster.metadata.AliasMetadata; import org.opensearch.cluster.metadata.IndexMetadata; import org.opensearch.cluster.metadata.IndexNameExpressionResolver; @@ -58,6 +61,7 @@ import org.opensearch.cluster.routing.UnassignedInfo; import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.UUIDs; +import org.opensearch.common.collect.Tuple; import org.opensearch.common.settings.Settings; import org.opensearch.common.unit.TimeValue; import org.opensearch.common.util.concurrent.ThreadContext; @@ -66,6 +70,7 @@ import org.opensearch.core.common.unit.ByteSizeUnit; import org.opensearch.core.common.unit.ByteSizeValue; import org.opensearch.core.index.shard.ShardId; +import org.opensearch.index.IndexModule; import org.opensearch.index.cache.query.QueryCacheStats; import org.opensearch.index.cache.request.RequestCacheStats; import org.opensearch.index.engine.SegmentsStats; @@ -98,8 +103,11 @@ import static java.util.Collections.emptyList; import static org.opensearch.action.admin.indices.rollover.TransportRolloverAction.evaluateConditions; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; import static org.mockito.Mockito.any; import static org.mockito.Mockito.anyBoolean; import static org.mockito.Mockito.doAnswer; @@ -469,6 +477,266 @@ private static Condition createTestCondition() { return condition; } + private TransportRolloverAction newRolloverActionForCheckBlock(ClusterState state) { + ClusterService clusterService = mock(ClusterService.class); + when(clusterService.state()).thenReturn(state); + ThreadPool threadPool = mock(ThreadPool.class); + when(threadPool.getThreadContext()).thenReturn(new ThreadContext(Settings.EMPTY)); + IndexNameExpressionResolver resolver = new IndexNameExpressionResolver(new ThreadContext(Settings.EMPTY)); + MetadataRolloverService rolloverService = new MetadataRolloverService( + threadPool, + mock(MetadataCreateIndexService.class), + mock(MetadataIndexAliasesService.class), + resolver + ); + return new TransportRolloverAction( + mock(TransportService.class), + clusterService, + threadPool, + mock(ActionFilters.class), + resolver, + rolloverService, + mock(Client.class) + ); + } + + public void testCheckBlockSkipsBlockOnNonWriteAliasMember() { + // Given: an alias whose write index is unblocked, and a non-write + // alias member that carries a METADATA_WRITE block (CCR-style) + String alias = "logs-alias"; + String writeIndexName = "logs-000002"; + String nonWriteMember = "logs-000001"; + + IndexMetadata writeIndex = IndexMetadata.builder(writeIndexName) + .settings(settings(Version.CURRENT)) + .putAlias(AliasMetadata.builder(alias).writeIndex(true).build()) + .numberOfShards(1) + .numberOfReplicas(1) + .build(); + IndexMetadata blockedNonWriteMember = IndexMetadata.builder(nonWriteMember) + .settings(settings(Version.CURRENT)) + .putAlias(AliasMetadata.builder(alias).writeIndex(false).build()) + .numberOfShards(1) + .numberOfReplicas(1) + .build(); + + ClusterState state = ClusterState.builder(ClusterName.DEFAULT) + .metadata(Metadata.builder().put(writeIndex, false).put(blockedNonWriteMember, false)) + // INDEX_WRITE_BLOCK has level WRITE only and would not trip checkBlock's METADATA_WRITE + // probe; INDEX_METADATA_BLOCK is the closest standard constant that includes METADATA_WRITE, + // matching the level CCR's INDEX_REPLICATION_BLOCK uses. + .blocks(ClusterBlocks.builder().addIndexBlock(nonWriteMember, IndexMetadata.INDEX_METADATA_BLOCK).build()) + .build(); + + TransportRolloverAction action = newRolloverActionForCheckBlock(state); + + // When: checkBlock runs for a rollover targeting the alias + ClusterBlockException result = action.checkBlock(new RolloverRequest(alias, null), state); + + // Then: returns null (the bug fix — non-write member is irrelevant) + assertThat(result, is(nullValue())); + } + + public void testCheckBlockAbortsWhenWriteIndexHasMetadataWriteBlock() { + // Given: an alias whose write index has a METADATA_WRITE block applied + String alias = "logs-alias"; + String writeIndexName = "logs-000002"; + + IndexMetadata writeIndex = IndexMetadata.builder(writeIndexName) + .settings(settings(Version.CURRENT)) + .putAlias(AliasMetadata.builder(alias).writeIndex(true).build()) + .numberOfShards(1) + .numberOfReplicas(1) + .build(); + + ClusterState state = ClusterState.builder(ClusterName.DEFAULT) + .metadata(Metadata.builder().put(writeIndex, false)) + // INDEX_METADATA_BLOCK includes METADATA_WRITE level (see sibling test for rationale). + .blocks(ClusterBlocks.builder().addIndexBlock(writeIndexName, IndexMetadata.INDEX_METADATA_BLOCK).build()) + .build(); + + TransportRolloverAction action = newRolloverActionForCheckBlock(state); + + // When: checkBlock runs + ClusterBlockException result = action.checkBlock(new RolloverRequest(alias, null), state); + + // Then: returns a ClusterBlockException naming only the write index + // (regression guard for the still-correct abort path) + assertThat(result, is(notNullValue())); + assertThat(result.getMessage(), containsString(writeIndexName)); + } + + public void testCheckBlockSkipsBlockOnNonWriteDataStreamBacking() { + // Given: a data stream with multiple backing indices; an older backing + // (not the write backing) has a METADATA_WRITE block + String dataStreamName = "logs-ds"; + int generations = 2; + + ClusterState baseState = DataStreamTestHelper.getClusterStateWithDataStreams( + List.of(new Tuple<>(dataStreamName, generations)), + List.of() + ); + + // The write backing is the highest-generation index; older backings come first. + String olderBacking = baseState.metadata().dataStreams().get(dataStreamName).getIndices().getFirst().getName(); + + ClusterState state = ClusterState.builder(baseState) + // INDEX_METADATA_BLOCK includes METADATA_WRITE level (see sibling test for rationale). + .blocks(ClusterBlocks.builder().addIndexBlock(olderBacking, IndexMetadata.INDEX_METADATA_BLOCK).build()) + .build(); + + TransportRolloverAction action = newRolloverActionForCheckBlock(state); + + // When: checkBlock runs for a rollover targeting the data stream + ClusterBlockException result = action.checkBlock(new RolloverRequest(dataStreamName, null), state); + + // Then: returns null (data-stream parity with the alias case) + assertThat(result, is(nullValue())); + } + + public void testCheckBlockSkipsRemoteSnapshotWriteIndexWithoutUserReadOnlyBlock() { + // Given: an alias whose write index is a remote_snapshot. Such indices come with + // an implicit METADATA_WRITE block; without a user-set read-only setting, the + // precheck should defer (parity with ClusterBlocks#indicesWithRemoteSnapshotBlockedException). + String alias = "logs-alias"; + String writeIndexName = "logs-000002"; + + Settings remoteSnapshotSettings = Settings.builder() + .put(settings(Version.CURRENT).build()) + .put(IndexModule.INDEX_STORE_TYPE_SETTING.getKey(), IndexModule.Type.REMOTE_SNAPSHOT.getSettingsKey()) + .build(); + + IndexMetadata writeIndex = IndexMetadata.builder(writeIndexName) + .settings(remoteSnapshotSettings) + .putAlias(AliasMetadata.builder(alias).writeIndex(true).build()) + .numberOfShards(1) + .numberOfReplicas(1) + .build(); + + ClusterState state = ClusterState.builder(ClusterName.DEFAULT) + .metadata(Metadata.builder().put(writeIndex, false)) + .blocks(ClusterBlocks.builder().addIndexBlock(writeIndexName, IndexMetadata.INDEX_METADATA_BLOCK).build()) + .build(); + + TransportRolloverAction action = newRolloverActionForCheckBlock(state); + + // When: checkBlock runs + ClusterBlockException result = action.checkBlock(new RolloverRequest(alias, null), state); + + // Then: returns null (the implicit remote_snapshot block is exempted) + assertThat(result, is(nullValue())); + } + + public void testCheckBlockAbortsWhenRemoteSnapshotHasUserReadOnlyBlock() { + // Given: a remote_snapshot write index with a user-set INDEX_READ_ONLY_SETTING. + // That's an intentional read-only block, so the precheck must still abort. + String alias = "logs-alias"; + String writeIndexName = "logs-000002"; + + Settings remoteSnapshotSettings = Settings.builder() + .put(settings(Version.CURRENT).build()) + .put(IndexModule.INDEX_STORE_TYPE_SETTING.getKey(), IndexModule.Type.REMOTE_SNAPSHOT.getSettingsKey()) + .put(IndexMetadata.SETTING_READ_ONLY, true) + .build(); + + IndexMetadata writeIndex = IndexMetadata.builder(writeIndexName) + .settings(remoteSnapshotSettings) + .putAlias(AliasMetadata.builder(alias).writeIndex(true).build()) + .numberOfShards(1) + .numberOfReplicas(1) + .build(); + + ClusterState state = ClusterState.builder(ClusterName.DEFAULT) + .metadata(Metadata.builder().put(writeIndex, false)) + .blocks(ClusterBlocks.builder().addIndexBlock(writeIndexName, IndexMetadata.INDEX_METADATA_BLOCK).build()) + .build(); + + TransportRolloverAction action = newRolloverActionForCheckBlock(state); + + // When: checkBlock runs + ClusterBlockException result = action.checkBlock(new RolloverRequest(alias, null), state); + + // Then: aborts naming the write index (user opted in to read-only) + assertThat(result, is(notNullValue())); + assertThat(result.getMessage(), containsString(writeIndexName)); + } + + public void testCheckBlockReturnsNullWhenRolloverTargetUnresolvable() { + // Given: cluster state with no abstraction matching the rollover target + ClusterState state = ClusterState.builder(ClusterName.DEFAULT).metadata(Metadata.builder()).build(); + + TransportRolloverAction action = newRolloverActionForCheckBlock(state); + + // When: checkBlock runs + ClusterBlockException result = action.checkBlock(new RolloverRequest("does-not-exist", null), state); + + // Then: returns null (defers to main path's canonical "not found" error) + assertThat(result, is(nullValue())); + } + + public void testCheckBlockReturnsNullWhenAliasHasNoWriteMember() { + // Given: an alias with members but none marked is_write_index=true. + // (Multiple non-write members with no write member is the only shape that makes + // Alias#getWriteIndex() return null; a single non-write member is implicitly the write index.) + String alias = "logs-alias"; + String memberA = "logs-000001"; + String memberB = "logs-000002"; + + IndexMetadata indexA = IndexMetadata.builder(memberA) + .settings(settings(Version.CURRENT)) + .putAlias(AliasMetadata.builder(alias).writeIndex(false).build()) + .numberOfShards(1) + .numberOfReplicas(1) + .build(); + IndexMetadata indexB = IndexMetadata.builder(memberB) + .settings(settings(Version.CURRENT)) + .putAlias(AliasMetadata.builder(alias).writeIndex(false).build()) + .numberOfShards(1) + .numberOfReplicas(1) + .build(); + + ClusterState state = ClusterState.builder(ClusterName.DEFAULT) + .metadata(Metadata.builder().put(indexA, false).put(indexB, false)) + .build(); + + TransportRolloverAction action = newRolloverActionForCheckBlock(state); + + // When: checkBlock runs + ClusterBlockException result = action.checkBlock(new RolloverRequest(alias, null), state); + + // Then: returns null — defers to MetadataRolloverService's canonical + // "rollover target [...] does not point to a write index" error. + assertThat(result, is(nullValue())); + } + + public void testCheckBlockAbortsWhenDataStreamWriteBackingBlocked() { + // Given: a data stream where the write (highest-generation) backing carries a METADATA_WRITE block + String dataStreamName = "logs-ds"; + int generations = 2; + + ClusterState baseState = DataStreamTestHelper.getClusterStateWithDataStreams( + List.of(new Tuple<>(dataStreamName, generations)), + List.of() + ); + + // Write backing is the highest-generation index — the last in the indices list. + List backings = baseState.metadata().dataStreams().get(dataStreamName).getIndices(); + String writeBacking = backings.getLast().getName(); + + ClusterState state = ClusterState.builder(baseState) + .blocks(ClusterBlocks.builder().addIndexBlock(writeBacking, IndexMetadata.INDEX_METADATA_BLOCK).build()) + .build(); + + TransportRolloverAction action = newRolloverActionForCheckBlock(state); + + // When: checkBlock runs for a rollover targeting the data stream + ClusterBlockException result = action.checkBlock(new RolloverRequest(dataStreamName, null), state); + + // Then: aborts naming the write backing — symmetry with the alias write-index-blocked case. + assertThat(result, is(notNullValue())); + assertThat(result.getMessage(), containsString(writeBacking)); + } + public static IndicesStatsResponse randomIndicesStatsResponse(final IndexMetadata[] indices) { List shardStats = new ArrayList<>(); for (final IndexMetadata index : indices) { diff --git a/server/src/test/java/org/opensearch/action/search/CanMatchPreFilterSearchPhaseTests.java b/server/src/test/java/org/opensearch/action/search/CanMatchPreFilterSearchPhaseTests.java index bb51aeaeee9dd..0b44bbe505638 100644 --- a/server/src/test/java/org/opensearch/action/search/CanMatchPreFilterSearchPhaseTests.java +++ b/server/src/test/java/org/opensearch/action/search/CanMatchPreFilterSearchPhaseTests.java @@ -192,6 +192,166 @@ public void run() throws IOException { } } + public void testSkippedShardGroupsAreNotSentToCanMatch() throws InterruptedException { + final TransportSearchAction.SearchTimeProvider timeProvider = new TransportSearchAction.SearchTimeProvider( + 0, + System.nanoTime(), + System::nanoTime + ); + + Map lookup = new ConcurrentHashMap<>(); + DiscoveryNode primaryNode = new DiscoveryNode("node_1", buildNewFakeTransportAddress(), Version.CURRENT); + DiscoveryNode replicaNode = new DiscoveryNode("node_2", buildNewFakeTransportAddress(), Version.CURRENT); + lookup.put("node1", new SearchAsyncActionTests.MockConnection(primaryNode)); + lookup.put("node2", new SearchAsyncActionTests.MockConnection(replicaNode)); + + List canMatchShardIds = Collections.synchronizedList(new ArrayList<>()); + SearchTransportService searchTransportService = new SearchTransportService(null, null) { + @Override + public void sendCanMatch( + Transport.Connection connection, + ShardSearchRequest request, + SearchTask task, + ActionListener listener + ) { + canMatchShardIds.add(request.shardId().id()); + listener.onResponse(new SearchService.CanMatchResponse(true, null)); + } + }; + + AtomicReference> result = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + GroupShardsIterator shardsIter = SearchAsyncActionTests.getShardsIter( + "idx", + new OriginalIndices(new String[] { "idx" }, SearchRequest.DEFAULT_INDICES_OPTIONS), + 2, + randomBoolean(), + primaryNode, + replicaNode + ); + shardsIter.get(0).resetAndSkip(); + + final SearchRequest searchRequest = new SearchRequest(); + searchRequest.allowPartialSearchResults(true); + + final SearchRequestOperationsListener searchRequestOperationsListener = new SearchRequestOperationsListener.CompositeListener( + List.of(assertingListener), + LogManager.getLogger() + ); + CanMatchPreFilterSearchPhase canMatchPhase = new CanMatchPreFilterSearchPhase( + logger, + searchTransportService, + (clusterAlias, node) -> lookup.get(node), + Collections.singletonMap("_na_", new AliasFilter(null, Strings.EMPTY_ARRAY)), + Collections.emptyMap(), + Collections.emptyMap(), + OpenSearchExecutors.newDirectExecutorService(), + searchRequest, + null, + shardsIter, + timeProvider, + ClusterState.EMPTY_STATE, + null, + (iter) -> new SearchPhase("test") { + @Override + public void run() throws IOException { + result.set(iter); + searchRequestOperationsListener.onPhaseEnd(new MockSearchPhaseContext(1, searchRequest, this), null); + latch.countDown(); + } + }, + SearchResponse.Clusters.EMPTY, + new SearchRequestContext(searchRequestOperationsListener, searchRequest, () -> null), + NoopTracer.INSTANCE + ); + + canMatchPhase.start(); + latch.await(); + + assertThat(canMatchShardIds, equalTo(List.of(1))); + assertTrue(result.get().get(0).skip()); + assertFalse(result.get().get(1).skip()); + } + + public void testAllSkippedShardGroupsAreNotSentToCanMatch() throws InterruptedException { + final TransportSearchAction.SearchTimeProvider timeProvider = new TransportSearchAction.SearchTimeProvider( + 0, + System.nanoTime(), + System::nanoTime + ); + + Map lookup = new ConcurrentHashMap<>(); + DiscoveryNode primaryNode = new DiscoveryNode("node_1", buildNewFakeTransportAddress(), Version.CURRENT); + DiscoveryNode replicaNode = new DiscoveryNode("node_2", buildNewFakeTransportAddress(), Version.CURRENT); + lookup.put("node1", new SearchAsyncActionTests.MockConnection(primaryNode)); + lookup.put("node2", new SearchAsyncActionTests.MockConnection(replicaNode)); + + SearchTransportService searchTransportService = new SearchTransportService(null, null) { + @Override + public void sendCanMatch( + Transport.Connection connection, + ShardSearchRequest request, + SearchTask task, + ActionListener listener + ) { + fail("can_match should not be sent to skipped shard groups"); + } + }; + + AtomicReference> result = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + GroupShardsIterator shardsIter = SearchAsyncActionTests.getShardsIter( + "idx", + new OriginalIndices(new String[] { "idx" }, SearchRequest.DEFAULT_INDICES_OPTIONS), + 2, + randomBoolean(), + primaryNode, + replicaNode + ); + shardsIter.get(0).resetAndSkip(); + shardsIter.get(1).resetAndSkip(); + + final SearchRequest searchRequest = new SearchRequest(); + searchRequest.allowPartialSearchResults(true); + + final SearchRequestOperationsListener searchRequestOperationsListener = new SearchRequestOperationsListener.CompositeListener( + List.of(assertingListener), + LogManager.getLogger() + ); + CanMatchPreFilterSearchPhase canMatchPhase = new CanMatchPreFilterSearchPhase( + logger, + searchTransportService, + (clusterAlias, node) -> lookup.get(node), + Collections.singletonMap("_na_", new AliasFilter(null, Strings.EMPTY_ARRAY)), + Collections.emptyMap(), + Collections.emptyMap(), + OpenSearchExecutors.newDirectExecutorService(), + searchRequest, + null, + shardsIter, + timeProvider, + ClusterState.EMPTY_STATE, + null, + (iter) -> new SearchPhase("test") { + @Override + public void run() throws IOException { + result.set(iter); + searchRequestOperationsListener.onPhaseEnd(new MockSearchPhaseContext(1, searchRequest, this), null); + latch.countDown(); + } + }, + SearchResponse.Clusters.EMPTY, + new SearchRequestContext(searchRequestOperationsListener, searchRequest, () -> null), + NoopTracer.INSTANCE + ); + + canMatchPhase.start(); + latch.await(); + + assertTrue(result.get().get(0).skip()); + assertTrue(result.get().get(1).skip()); + } + public void testFilterWithFailure() throws InterruptedException { final TransportSearchAction.SearchTimeProvider timeProvider = new TransportSearchAction.SearchTimeProvider( 0, diff --git a/server/src/test/java/org/opensearch/action/search/SearchIndexPruningServiceTests.java b/server/src/test/java/org/opensearch/action/search/SearchIndexPruningServiceTests.java new file mode 100644 index 0000000000000..8626ce06356f2 --- /dev/null +++ b/server/src/test/java/org/opensearch/action/search/SearchIndexPruningServiceTests.java @@ -0,0 +1,452 @@ +/* + * 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.action.search; + +import org.opensearch.Version; +import org.opensearch.action.OriginalIndices; +import org.opensearch.action.search.pruning.FieldDomainEvaluationContext; +import org.opensearch.action.search.pruning.FieldDomainEvaluators; +import org.opensearch.action.search.pruning.QueryConstraint; +import org.opensearch.action.search.pruning.QueryConstraintExtractor; +import org.opensearch.action.search.pruning.SearchIndexPruningResult; +import org.opensearch.action.search.pruning.SearchIndexPruningSettings; +import org.opensearch.cluster.ClusterName; +import org.opensearch.cluster.ClusterState; +import org.opensearch.cluster.metadata.IndexMetadata; +import org.opensearch.cluster.metadata.Metadata; +import org.opensearch.cluster.routing.GroupShardsIterator; +import org.opensearch.common.settings.ClusterSettings; +import org.opensearch.common.settings.Setting; +import org.opensearch.common.settings.Settings; +import org.opensearch.core.index.shard.ShardId; +import org.opensearch.index.fielddomain.ClusterStateFieldDomainProvider; +import org.opensearch.index.fielddomain.DateRangeFieldDomain; +import org.opensearch.index.fielddomain.FieldDomain; +import org.opensearch.index.fielddomain.FieldDomainProvider; +import org.opensearch.index.fielddomain.IndexFieldDomainMetadata; +import org.opensearch.index.query.QueryBuilders; +import org.opensearch.search.builder.PointInTimeBuilder; +import org.opensearch.search.builder.SearchSourceBuilder; +import org.opensearch.test.OpenSearchTestCase; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.hamcrest.Matchers.equalTo; + +public class SearchIndexPruningServiceTests extends OpenSearchTestCase { + private static final String FIELD = "field_a"; + + public void testPrunesOnlyProvablyDisjointLocalShardGroups() { + SearchShardIterator oldIndex = shardIterator("logs-000001", 0); + SearchShardIterator matchingIndex = shardIterator("logs-000002", 0); + GroupShardsIterator shardIterators = new GroupShardsIterator<>(List.of(oldIndex, matchingIndex)); + SearchIndexPruningService service = serviceWithDomains( + Map.of("logs-000001", Optional.of(domain(false)), "logs-000002", Optional.of(domain(true))) + ); + + SearchIndexPruningResult result = service.prune(searchRequest(), shardIterators, ClusterState.EMPTY_STATE, evaluationContext()); + + assertTrue(result.pruned()); + assertThat(result.originalShardGroups(), equalTo(2)); + assertThat(result.prunedShardGroups(), equalTo(1)); + assertTrue(result.isPrunedShardGroup(0)); + assertFalse(result.isPrunedShardGroup(1)); + assertFalse(oldIndex.skip()); + assertFalse(matchingIndex.skip()); + + applyPruningResult(result); + + assertTrue(oldIndex.skip()); + assertFalse(matchingIndex.skip()); + } + + public void testKeepsShardGroupsWhenDomainIsMissingOrUnfinalized() { + SearchShardIterator missingMetadata = shardIterator("logs-000001", 0); + SearchShardIterator unfinalizedDomain = shardIterator("logs-000002", 0); + GroupShardsIterator shardIterators = new GroupShardsIterator<>(List.of(missingMetadata, unfinalizedDomain)); + SearchIndexPruningService service = serviceWithDomains( + Map.of("logs-000001", Optional.empty(), "logs-000002", Optional.of(domain(false, false))) + ); + + SearchIndexPruningResult result = service.prune(searchRequest(), shardIterators, ClusterState.EMPTY_STATE, evaluationContext()); + + assertFalse(result.pruned()); + assertFalse(missingMetadata.skip()); + assertFalse(unfinalizedDomain.skip()); + } + + public void testKeepsRemoteShardGroups() { + SearchShardIterator remoteIndex = shardIterator("remote-cluster", "logs-000001", 0); + SearchShardIterator localIndex = shardIterator("logs-000002", 0); + GroupShardsIterator shardIterators = new GroupShardsIterator<>(List.of(remoteIndex, localIndex)); + SearchIndexPruningService service = serviceWithDomains( + Map.of("logs-000001", Optional.of(domain(false)), "logs-000002", Optional.of(domain(false))) + ); + + SearchIndexPruningResult result = service.prune(searchRequest(), shardIterators, ClusterState.EMPTY_STATE, evaluationContext()); + + assertTrue(result.pruned()); + assertFalse(result.isPrunedShardGroup(0)); + assertTrue(result.isPrunedShardGroup(1)); + assertFalse(remoteIndex.skip()); + assertFalse(localIndex.skip()); + } + + public void testPruneIsFailOpenWhenDomainProviderThrows() { + AtomicInteger lookups = new AtomicInteger(); + FieldDomainProvider provider = (clusterState, indexName, field) -> { + lookups.incrementAndGet(); + throw new IllegalStateException("exception"); + }; + SearchIndexPruningService service = new SearchIndexPruningService( + clusterSettings(), + provider, + genericConstraintExtractor(), + genericEvaluators() + ); + + SearchShardIterator shardIterator = shardIterator("logs-000001", 0); + GroupShardsIterator shardIterators = new GroupShardsIterator<>(List.of(shardIterator)); + + SearchIndexPruningResult result = service.prune(searchRequest(), shardIterators, ClusterState.EMPTY_STATE, evaluationContext()); + + assertThat(lookups.get(), equalTo(1)); + assertFalse(result.pruned()); + assertThat(result.originalShardGroups(), equalTo(1)); + assertFalse(shardIterator.skip()); + } + + public void testFallsBackWhenAllShardGroupsWouldBePruned() { + SearchShardIterator first = shardIterator("logs-000001", 0); + SearchShardIterator second = shardIterator("logs-000002", 0); + GroupShardsIterator shardIterators = new GroupShardsIterator<>(List.of(first, second)); + SearchIndexPruningService service = serviceWithDomains( + Map.of("logs-000001", Optional.of(domain(false)), "logs-000002", Optional.of(domain(false))) + ); + + SearchIndexPruningResult result = service.prune(searchRequest(), shardIterators, ClusterState.EMPTY_STATE, evaluationContext()); + + assertFalse(result.pruned()); + assertThat(result.prunedShardGroups(), equalTo(0)); + assertFalse(first.skip()); + assertFalse(second.skip()); + } + + public void testFallsBackWhenAllActiveShardGroupsWouldBePrunedAndPreservesExistingSkips() { + SearchShardIterator alreadySkipped = shardIterator("logs-000001", 0); + SearchShardIterator activeCandidate = shardIterator("logs-000002", 0); + alreadySkipped.resetAndSkip(); + GroupShardsIterator shardIterators = new GroupShardsIterator<>(List.of(alreadySkipped, activeCandidate)); + SearchIndexPruningService service = serviceWithDomains( + Map.of("logs-000001", Optional.empty(), "logs-000002", Optional.of(domain(false))) + ); + + SearchIndexPruningResult result = service.prune(searchRequest(), shardIterators, ClusterState.EMPTY_STATE, evaluationContext()); + + assertFalse(result.pruned()); + assertThat(result.prunedShardGroups(), equalTo(0)); + assertTrue(alreadySkipped.skip()); + assertFalse(activeCandidate.skip()); + } + + public void testDoesNotPrunePointInTimeSearches() { + SearchShardIterator oldIndex = shardIterator("logs-000001", 0); + SearchShardIterator matchingIndex = shardIterator("logs-000002", 0); + GroupShardsIterator shardIterators = new GroupShardsIterator<>(List.of(oldIndex, matchingIndex)); + SearchIndexPruningService service = serviceWithDomains( + Map.of("logs-000001", Optional.of(domain(false)), "logs-000002", Optional.of(domain(true))) + ); + + SearchRequest request = searchRequest(); + request.source().pointInTimeBuilder(new PointInTimeBuilder("pit-id")); + + SearchIndexPruningResult result = service.prune(request, shardIterators, ClusterState.EMPTY_STATE, evaluationContext()); + + assertFalse(result.pruned()); + assertFalse(oldIndex.skip()); + assertFalse(matchingIndex.skip()); + } + + public void testPruningSettingsAreRegisteredAsBuiltInClusterSettings() { + assertTrue(ClusterSettings.BUILT_IN_CLUSTER_SETTINGS.containsAll(SearchIndexPruningSettings.getSettings())); + } + + public void testAppliesDynamicPruningSettingsAsConsistentConfigSnapshot() { + ClusterSettings clusterSettings = clusterSettings(Settings.EMPTY); + SearchIndexPruningService service = serviceWithDomains( + clusterSettings, + Map.of("logs-000001", Optional.of(domain(false)), "logs-000002", Optional.of(domain(true))) + ); + + GroupShardsIterator shardIterators = new GroupShardsIterator<>( + List.of(shardIterator("logs-000001", 0), shardIterator("logs-000002", 0)) + ); + SearchRequest request = searchRequest(); + + assertFalse(service.prune(request, shardIterators, ClusterState.EMPTY_STATE, evaluationContext()).pruned()); + + clusterSettings.applySettings(pruningSettings(true, 1, FIELD)); + assertTrue(service.prune(request, shardIterators, ClusterState.EMPTY_STATE, evaluationContext()).isPrunedShardGroup(0)); + + clusterSettings.applySettings(pruningSettings(false, 1, FIELD)); + assertFalse(service.prune(request, shardIterators, ClusterState.EMPTY_STATE, evaluationContext()).pruned()); + + clusterSettings.applySettings(pruningSettings(true, 1, "event.ingested")); + assertFalse(service.prune(request, shardIterators, ClusterState.EMPTY_STATE, evaluationContext()).pruned()); + + clusterSettings.applySettings(pruningSettings(true, 3, FIELD)); + assertFalse(service.prune(request, shardIterators, ClusterState.EMPTY_STATE, evaluationContext()).pruned()); + + clusterSettings.applySettings(pruningSettings(true, 1, FIELD)); + assertTrue(service.prune(request, shardIterators, ClusterState.EMPTY_STATE, evaluationContext()).isPrunedShardGroup(0)); + } + + public void testMinShardThresholdUsesActiveShardGroups() { + AtomicInteger lookups = new AtomicInteger(); + FieldDomainProvider provider = (clusterState, indexName, field) -> { + lookups.incrementAndGet(); + return Optional.of(domain(false)); + }; + SearchIndexPruningService service = new SearchIndexPruningService( + clusterSettings(pruningSettings(true, 2, FIELD)), + provider, + genericConstraintExtractor(), + genericEvaluators() + ); + + SearchShardIterator alreadySkipped = shardIterator("logs-000001", 0); + SearchShardIterator activeCandidate = shardIterator("logs-000002", 0); + alreadySkipped.resetAndSkip(); + GroupShardsIterator shardIterators = new GroupShardsIterator<>(List.of(alreadySkipped, activeCandidate)); + + SearchIndexPruningResult result = service.prune(searchRequest(), shardIterators, ClusterState.EMPTY_STATE, evaluationContext()); + + assertFalse(result.pruned()); + assertThat(lookups.get(), equalTo(0)); + } + + public void testCachesBoundsLookupPerIndexAndFieldDuringSinglePruningPass() { + AtomicInteger lookups = new AtomicInteger(); + FieldDomainProvider provider = (clusterState, indexName, field) -> { + lookups.incrementAndGet(); + return Optional.of(new TestFieldDomain(field)); + }; + QueryConstraintExtractor extractor = (source, fields) -> List.of(new TestQueryConstraint(FIELD)); + FieldDomainEvaluators evaluators = new FieldDomainEvaluators(List.of((bounds, constraint, context) -> false)); + SearchIndexPruningService service = new SearchIndexPruningService(clusterSettings(), provider, extractor, evaluators); + + GroupShardsIterator shardIterators = new GroupShardsIterator<>( + List.of(shardIterator("logs-000001", 0), shardIterator("logs-000001", 1)) + ); + + service.prune(new SearchRequest().source(new SearchSourceBuilder()), shardIterators, ClusterState.EMPTY_STATE, evaluationContext()); + + assertThat(lookups.get(), equalTo(1)); + } + + public void testWiresDateRangeMetadataFromClusterStateProvider() { + SearchIndexPruningService service = new SearchIndexPruningService(dateClusterSettings(), new ClusterStateFieldDomainProvider()); + + SearchShardIterator oldIndex = shardIterator("logs-000001", 0); + SearchShardIterator matchingIndex = shardIterator("logs-000002", 0); + GroupShardsIterator shardIterators = new GroupShardsIterator<>(List.of(oldIndex, matchingIndex)); + + ClusterState clusterState = clusterState( + indexMetadata("logs-000001", new DateRangeFieldDomain("@timestamp", 100L, 200L, true, "test")), + indexMetadata("logs-000002", new DateRangeFieldDomain("@timestamp", 350L, 450L, true, "test")) + ); + + SearchIndexPruningResult result = service.prune( + dateRangeSearchRequest(300L, 400L), + shardIterators, + clusterState, + evaluationContext() + ); + + assertTrue(result.pruned()); + assertTrue(result.isPrunedShardGroup(0)); + assertFalse(result.isPrunedShardGroup(1)); + } + + private static SearchRequest searchRequest() { + return new SearchRequest().source(new SearchSourceBuilder()); + } + + private static SearchRequest dateRangeSearchRequest(long from, long to) { + return new SearchRequest().source(new SearchSourceBuilder().query(QueryBuilders.rangeQuery("@timestamp").gte(from).lte(to))); + } + + private static FieldDomainEvaluationContext evaluationContext() { + return new FieldDomainEvaluationContext(() -> 0L); + } + + private static ClusterSettings clusterSettings() { + Settings settings = Settings.builder() + .put(SearchIndexPruningSettings.ENABLED.getKey(), true) + .put(SearchIndexPruningSettings.MIN_SHARDS.getKey(), 1) + .putList(SearchIndexPruningSettings.FIELDS.getKey(), FIELD) + .build(); + return clusterSettings(settings); + } + + private static ClusterSettings dateClusterSettings() { + Settings settings = Settings.builder() + .put(SearchIndexPruningSettings.ENABLED.getKey(), true) + .put(SearchIndexPruningSettings.MIN_SHARDS.getKey(), 1) + .putList(SearchIndexPruningSettings.FIELDS.getKey(), "@timestamp") + .build(); + return clusterSettings(settings); + } + + private static ClusterSettings clusterSettings(Settings settings) { + Set> builtInSettings = Set.copyOf(SearchIndexPruningSettings.getSettings()); + return new ClusterSettings(settings, builtInSettings); + } + + private static Settings pruningSettings(boolean enabled, int minShards, String... fields) { + return Settings.builder() + .put(SearchIndexPruningSettings.ENABLED.getKey(), enabled) + .put(SearchIndexPruningSettings.MIN_SHARDS.getKey(), minShards) + .putList(SearchIndexPruningSettings.FIELDS.getKey(), fields) + .build(); + } + + private static ClusterState clusterState(IndexMetadata... indexMetadata) { + Metadata.Builder metadata = Metadata.builder(); + for (IndexMetadata metadataEntry : indexMetadata) { + metadata.put(metadataEntry, false); + } + return ClusterState.builder(ClusterName.DEFAULT).metadata(metadata).build(); + } + + private static IndexMetadata indexMetadata(String index, DateRangeFieldDomain domain) { + return IndexFieldDomainMetadata.getInstance().putFieldDomain(plainIndexMetadata(index), domain); + } + + private static IndexMetadata plainIndexMetadata(String index) { + return indexMetadataBuilder(index).build(); + } + + private static IndexMetadata.Builder indexMetadataBuilder(String index) { + return IndexMetadata.builder(index) + .settings( + Settings.builder() + .put(IndexMetadata.SETTING_VERSION_CREATED, Version.CURRENT) + .put(IndexMetadata.SETTING_INDEX_UUID, "_na_") + .build() + ) + .numberOfShards(1) + .numberOfReplicas(0); + } + + private static SearchShardIterator shardIterator(String index, int shardId) { + return shardIterator(null, index, shardId); + } + + private static SearchShardIterator shardIterator(String clusterAlias, String index, int shardId) { + return new SearchShardIterator( + clusterAlias, + new ShardId(index, "_na_", shardId), + List.of("node-1"), + OriginalIndices.NONE, + null, + null + ); + } + + private static void applyPruningResult(SearchIndexPruningResult pruningResult) { + for (int shardGroupIndex = 0; shardGroupIndex < pruningResult.originalShardGroups(); shardGroupIndex++) { + if (pruningResult.isPrunedShardGroup(shardGroupIndex)) { + pruningResult.shardIterators().get(shardGroupIndex).resetAndSkip(); + } + } + } + + private static SearchIndexPruningService serviceWithDomains(Map> domainsByIndex) { + return serviceWithDomains(clusterSettings(), domainsByIndex); + } + + private static SearchIndexPruningService serviceWithDomains( + ClusterSettings clusterSettings, + Map> domainsByIndex + ) { + FieldDomainProvider provider = (clusterState, indexName, field) -> domainsByIndex.getOrDefault(indexName, Optional.empty()); + return new SearchIndexPruningService(clusterSettings, provider, genericConstraintExtractor(), genericEvaluators()); + } + + private static QueryConstraintExtractor genericConstraintExtractor() { + return (source, fields) -> fields.contains(FIELD) ? List.of(new TestQueryConstraint(FIELD)) : List.of(); + } + + private static FieldDomainEvaluators genericEvaluators() { + return new FieldDomainEvaluators( + List.of((domain, constraint, context) -> domain instanceof TestFieldDomain ? ((TestFieldDomain) domain).canMatch() : true) + ); + } + + private static TestFieldDomain domain(boolean canMatch) { + return domain(true, canMatch); + } + + private static TestFieldDomain domain(boolean finalized, boolean canMatch) { + return new TestFieldDomain(FIELD, finalized, canMatch); + } + + private static final class TestQueryConstraint implements QueryConstraint { + private final String field; + + private TestQueryConstraint(String field) { + this.field = field; + } + + @Override + public String field() { + return field; + } + } + + private static final class TestFieldDomain implements FieldDomain { + private final String field; + private final boolean finalized; + private final boolean canMatch; + + private TestFieldDomain(String field) { + this(field, true, true); + } + + private TestFieldDomain(String field, boolean finalized, boolean canMatch) { + this.field = field; + this.finalized = finalized; + this.canMatch = canMatch; + } + + @Override + public String field() { + return field; + } + + @Override + public String type() { + return "test"; + } + + @Override + public boolean finalized() { + return finalized; + } + + private boolean canMatch() { + return canMatch; + } + } +} diff --git a/server/src/test/java/org/opensearch/action/search/TransportSearchActionTests.java b/server/src/test/java/org/opensearch/action/search/TransportSearchActionTests.java index 74ac72aedbf2d..e1f515d9f5c19 100644 --- a/server/src/test/java/org/opensearch/action/search/TransportSearchActionTests.java +++ b/server/src/test/java/org/opensearch/action/search/TransportSearchActionTests.java @@ -59,6 +59,7 @@ import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.SetOnce; import org.opensearch.common.collect.Tuple; +import org.opensearch.common.settings.ClusterSettings; import org.opensearch.common.settings.Settings; import org.opensearch.common.util.concurrent.ThreadContext; import org.opensearch.core.action.ActionListener; @@ -1206,6 +1207,9 @@ public void testResolveIndices() { ClusterService clusterService = mock(ClusterService.class); when(clusterService.state()).thenReturn(clusterState); + when(clusterService.getClusterSettings()).thenReturn( + new ClusterSettings(Settings.EMPTY, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS) + ); IndexNameExpressionResolver indexNameExpressionResolver = new IndexNameExpressionResolver(new ThreadContext(Settings.EMPTY)); NamedWriteableRegistry namedWriteableRegistry = new NamedWriteableRegistry( Arrays.asList( diff --git a/server/src/test/java/org/opensearch/action/search/TransportSearchHelperTests.java b/server/src/test/java/org/opensearch/action/search/TransportSearchHelperTests.java index 1668f2043fefd..6a583a0306a20 100644 --- a/server/src/test/java/org/opensearch/action/search/TransportSearchHelperTests.java +++ b/server/src/test/java/org/opensearch/action/search/TransportSearchHelperTests.java @@ -41,6 +41,9 @@ import org.opensearch.test.OpenSearchTestCase; import org.opensearch.test.VersionUtils; +import java.io.ByteArrayOutputStream; +import java.util.Base64; + import static org.hamcrest.Matchers.equalTo; public class TransportSearchHelperTests extends OpenSearchTestCase { @@ -92,4 +95,23 @@ public void testParseScrollId() { assertEquals(42, parseScrollId.getContext()[2].getSearchContextId().getId()); assertThat(parseScrollId.getContext()[2].getSearchContextId().getSessionId(), equalTo("c")); } + + public void testParseScrollIdRejectsOversizedCount() throws Exception { + // Craft a scroll_id with a varint-encoded count far exceeding the payload size. + // This would previously cause an OOM by allocating a huge array. + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + // Write type string: length=1, char='q' (any valid type) + baos.write(1); // vint string length + baos.write('q'); + // Write a massive vint count (Integer.MAX_VALUE encoded as 5-byte varint) + baos.write(0xFF); + baos.write(0xFF); + baos.write(0xFF); + baos.write(0xFF); + baos.write(0x07); + String scrollId = Base64.getUrlEncoder().encodeToString(baos.toByteArray()); + + IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> TransportSearchHelper.parseScrollId(scrollId)); + assertEquals("Cannot parse scroll id", e.getMessage()); + } } diff --git a/server/src/test/java/org/opensearch/action/search/pruning/DateRangeFieldDomainEvaluatorTests.java b/server/src/test/java/org/opensearch/action/search/pruning/DateRangeFieldDomainEvaluatorTests.java new file mode 100644 index 0000000000000..856103c52d7da --- /dev/null +++ b/server/src/test/java/org/opensearch/action/search/pruning/DateRangeFieldDomainEvaluatorTests.java @@ -0,0 +1,250 @@ +/* + * 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.action.search.pruning; + +import org.opensearch.common.geo.ShapeRelation; +import org.opensearch.index.fielddomain.DateRangeFieldDomain; +import org.opensearch.index.fielddomain.FieldDomain; +import org.opensearch.test.OpenSearchTestCase; + +public class DateRangeFieldDomainEvaluatorTests extends OpenSearchTestCase { + private final FieldDomainEvaluators evaluators = FieldDomainEvaluators.defaultEvaluators(); + private final FieldDomainEvaluationContext context = new FieldDomainEvaluationContext(() -> 0L); + + public void testCanMatchReturnsFalseForDisjointDateRanges() { + assertFalse(evaluators.canMatch(bounds(100L, 200L, true), constraint(300L, 400L, true, true), context)); + assertFalse(evaluators.canMatch(bounds(100L, 200L, true), constraint(200L, 400L, false, true), context)); + assertFalse(evaluators.canMatch(bounds(100L, 200L, true), constraint(null, 99L, true, true), context)); + } + + public void testCanMatchReturnsTrueForIntersectingDateRanges() { + assertTrue(evaluators.canMatch(bounds(100L, 200L, true), constraint(50L, 100L, true, true), context)); + assertTrue(evaluators.canMatch(bounds(100L, 200L, true), constraint(200L, 300L, true, true), context)); + assertTrue(evaluators.canMatch(bounds(100L, 200L, true), constraint(150L, 250L, true, true), context)); + } + + public void testCanMatchHandlesOpenEndedDateRanges() { + assertFalse(evaluators.canMatch(bounds(100L, 200L, true), constraint(201L, null, true, true), context)); + assertTrue(evaluators.canMatch(bounds(100L, 200L, true), constraint(200L, null, true, true), context)); + assertFalse(evaluators.canMatch(bounds(100L, 200L, true), constraint(null, 99L, true, true), context)); + assertTrue(evaluators.canMatch(bounds(100L, 200L, true), constraint(null, 100L, true, true), context)); + } + + public void testCanMatchHonorsInclusiveAndExclusiveDateRangeBoundaries() { + RangeQueryConstraint lowerInclusive = constraint("1970-01-01T00:00:02Z", "1970-01-01T00:00:03Z", true, true); + RangeQueryConstraint lowerExclusive = constraint("1970-01-01T00:00:02Z", "1970-01-01T00:00:03Z", false, true); + RangeQueryConstraint upperInclusive = constraint("1970-01-01T00:00:00Z", "1970-01-01T00:00:01Z", true, true); + RangeQueryConstraint upperExclusive = constraint("1970-01-01T00:00:00Z", "1970-01-01T00:00:01Z", true, false); + + assertTrue(evaluators.canMatch(bounds(1_000L, 2_000L, true), lowerInclusive, context)); + assertFalse(evaluators.canMatch(bounds(1_000L, 2_000L, true), lowerExclusive, context)); + assertTrue(evaluators.canMatch(bounds(1_000L, 2_000L, true), upperInclusive, context)); + assertFalse(evaluators.canMatch(bounds(1_000L, 2_000L, true), upperExclusive, context)); + } + + public void testCanMatchHandlesSinglePointDateRanges() { + assertTrue(evaluators.canMatch(bounds(100L, 100L, true), constraint(100L, 100L, true, true), context)); + assertFalse(evaluators.canMatch(bounds(100L, 100L, true), constraint(100L, 100L, false, true), context)); + assertFalse(evaluators.canMatch(bounds(100L, 100L, true), constraint(100L, 100L, true, false), context)); + } + + public void testCanMatchReturnsFalseForEmptyQueryRange() { + assertFalse(evaluators.canMatch(bounds(100L, 200L, true), constraint(100L, 101L, false, false), context)); + } + + public void testCanMatchParsesIsoDateRangeConstraints() { + RangeQueryConstraint query = constraint("1970-01-01T00:00:03Z", "1970-01-01T00:00:04Z", true, true); + + assertFalse(evaluators.canMatch(bounds(1_000L, 2_000L, true), query, context)); + assertTrue(evaluators.canMatch(bounds(3_000L, 4_000L, true), query, context)); + } + + public void testCanMatchParsesDateMathUsingSearchStartTime() { + FieldDomainEvaluationContext searchStartContext = new FieldDomainEvaluationContext(() -> 10_000L); + RangeQueryConstraint query = constraint("now-2m", "now", true, true); + + assertTrue(evaluators.canMatch(bounds(0L, 9_000L, true), query, searchStartContext)); + assertFalse(evaluators.canMatch(bounds(11_000L, 12_000L, true), query, searchStartContext)); + } + + public void testCanMatchParsesQueryTimeZone() { + RangeQueryConstraint query = constraint("@timestamp", "1970-01-01", null, true, true, null, "+01:00", null); + + assertTrue(evaluators.canMatch(bounds(-3_600_000L, -3_600_000L, true), query, context)); + assertFalse(evaluators.canMatch(bounds(-3_600_001L, -3_600_001L, true), query, context)); + } + + public void testCanMatchParsesPartialDateRangeConstraints() { + RangeQueryConstraint query = constraint("1970-01-01", "1970-01-02", true, false); + + assertTrue(evaluators.canMatch(bounds(0L, 86_399_999L, true), query, context)); + assertFalse(evaluators.canMatch(bounds(86_400_000L, 172_799_999L, true), query, context)); + } + + public void testCanMatchHonorsPartialDateRoundingForInclusivity() { + RangeQueryConstraint lowerInclusive = constraint("1970-01-01", null, true, true); + RangeQueryConstraint lowerExclusive = constraint("1970-01-01", null, false, true); + RangeQueryConstraint upperInclusive = constraint(null, "1970-01-01", true, true); + RangeQueryConstraint upperExclusive = constraint(null, "1970-01-01", true, false); + + assertTrue(evaluators.canMatch(bounds(0L, 0L, true), lowerInclusive, context)); + assertFalse(evaluators.canMatch(bounds(-1L, -1L, true), lowerInclusive, context)); + + assertFalse(evaluators.canMatch(bounds(86_399_999L, 86_399_999L, true), lowerExclusive, context)); + assertTrue(evaluators.canMatch(bounds(86_400_000L, 86_400_000L, true), lowerExclusive, context)); + + assertTrue(evaluators.canMatch(bounds(86_399_999L, 86_399_999L, true), upperInclusive, context)); + assertFalse(evaluators.canMatch(bounds(86_400_000L, 86_400_000L, true), upperInclusive, context)); + + assertTrue(evaluators.canMatch(bounds(-1L, -1L, true), upperExclusive, context)); + assertFalse(evaluators.canMatch(bounds(0L, 0L, true), upperExclusive, context)); + } + + public void testCanMatchParsesDateNanosBounds() { + RangeQueryConstraint query = constraint("1970-01-01T00:00:03Z", "1970-01-01T00:00:04Z", true, true); + + assertFalse( + evaluators.canMatch( + new DateRangeFieldDomain("@timestamp", "1000000000", "2000000000", true, "test", null, "nanoseconds"), + query, + context + ) + ); + assertTrue( + evaluators.canMatch( + new DateRangeFieldDomain("@timestamp", "3000000000", "4000000000", true, "test", null, "nanoseconds"), + query, + context + ) + ); + } + + public void testCanMatchParsesDateNanosResolutionTypeCaseInsensitively() { + RangeQueryConstraint query = constraint("1970-01-01T00:00:03Z", "1970-01-01T00:00:04Z", true, true); + + assertFalse( + evaluators.canMatch( + new DateRangeFieldDomain("@timestamp", "1000000000", "2000000000", true, "test", null, "DATE_NANOS"), + query, + context + ) + ); + } + + public void testCanMatchHonorsDateNanosBoundaryTouch() { + RangeQueryConstraint inclusive = constraint("1970-01-01T00:00:02Z", "1970-01-01T00:00:03Z", true, true); + RangeQueryConstraint exclusive = constraint("1970-01-01T00:00:02Z", "1970-01-01T00:00:03Z", false, true); + DateRangeFieldDomain domain = new DateRangeFieldDomain("@timestamp", "2000000000", "2000000000", true, "test", null, "nanoseconds"); + + assertTrue(evaluators.canMatch(domain, inclusive, context)); + assertFalse(evaluators.canMatch(domain, exclusive, context)); + } + + public void testCanMatchParsesCustomMetadataFormat() { + RangeQueryConstraint query = constraint("1970/01/01", "1970/01/02", true, false); + + assertTrue( + evaluators.canMatch( + new DateRangeFieldDomain("@timestamp", "0", "86399999", true, "test", "yyyy/MM/dd", "milliseconds"), + query, + context + ) + ); + assertFalse( + evaluators.canMatch( + new DateRangeFieldDomain("@timestamp", "86400000", "172799999", true, "test", "yyyy/MM/dd", "milliseconds"), + query, + context + ) + ); + } + + public void testCanMatchUsesQueryFormatOverMetadataFormat() { + RangeQueryConstraint query = constraint("@timestamp", "1970|01|01", null, true, true, "yyyy|MM|dd", null, null); + + assertFalse( + evaluators.canMatch( + new DateRangeFieldDomain("@timestamp", "-1", "-1", true, "test", "yyyy/MM/dd", "milliseconds"), + query, + context + ) + ); + } + + public void testCanMatchReturnsTrueWhenEvaluationIsUnsupportedOrUnsafe() { + assertTrue(evaluators.canMatch(bounds(100L, 200L, false), constraint(300L, 400L, true, true), context)); + assertTrue(evaluators.canMatch(bounds(100L, 200L, true), constraint("not-a-date", "1970-01-01T00:00:02Z", true, true), context)); + assertTrue(evaluators.canMatch(bounds(100L, 200L, true), constraint("now--", "now", true, true), context)); + assertTrue( + evaluators.canMatch( + new DateRangeFieldDomain("@timestamp", "100", "200", true, "test", "[", "milliseconds"), + constraint(300L, 400L, true, true), + context + ) + ); + assertTrue(evaluators.canMatch(bounds(100L, 200L, true), constraint("other_field", 300L, 400L, true, true), context)); + assertTrue(evaluators.canMatch(new UnsupportedFieldDomain("@timestamp"), constraint(300L, 400L, true, true), context)); + assertTrue(evaluators.canMatch(bounds(100L, 200L, true), (QueryConstraint) () -> "@timestamp", context)); + assertTrue( + evaluators.canMatch( + bounds(100L, 200L, true), + constraint("@timestamp", 300L, 400L, true, true, null, null, ShapeRelation.INTERSECTS), + context + ) + ); + } + + private static DateRangeFieldDomain bounds(long min, long max, boolean finalized) { + return new DateRangeFieldDomain("@timestamp", min, max, finalized, "test"); + } + + private static RangeQueryConstraint constraint(Object lower, Object upper, boolean includeLower, boolean includeUpper) { + return constraint("@timestamp", lower, upper, includeLower, includeUpper); + } + + private static RangeQueryConstraint constraint(String field, Object lower, Object upper, boolean includeLower, boolean includeUpper) { + return constraint(field, lower, upper, includeLower, includeUpper, null, null, null); + } + + private static RangeQueryConstraint constraint( + String field, + Object lower, + Object upper, + boolean includeLower, + boolean includeUpper, + String format, + String timeZone, + ShapeRelation relation + ) { + return new RangeQueryConstraint(field, lower, upper, includeLower, includeUpper, format, timeZone, relation); + } + + private static final class UnsupportedFieldDomain implements FieldDomain { + private final String field; + + private UnsupportedFieldDomain(String field) { + this.field = field; + } + + @Override + public String field() { + return field; + } + + @Override + public String type() { + return "unsupported"; + } + + @Override + public boolean finalized() { + return true; + } + } +} diff --git a/server/src/test/java/org/opensearch/action/search/pruning/MandatoryQueryConstraintExtractorTests.java b/server/src/test/java/org/opensearch/action/search/pruning/MandatoryQueryConstraintExtractorTests.java new file mode 100644 index 0000000000000..3214b175e92ff --- /dev/null +++ b/server/src/test/java/org/opensearch/action/search/pruning/MandatoryQueryConstraintExtractorTests.java @@ -0,0 +1,124 @@ +/* + * 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.action.search.pruning; + +import org.opensearch.common.geo.ShapeRelation; +import org.opensearch.index.query.QueryBuilders; +import org.opensearch.search.builder.SearchSourceBuilder; +import org.opensearch.test.OpenSearchTestCase; + +import java.util.List; +import java.util.Set; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.instanceOf; + +public class MandatoryQueryConstraintExtractorTests extends OpenSearchTestCase { + private final MandatoryQueryConstraintExtractor extractor = new MandatoryQueryConstraintExtractor(); + + public void testExtractsConfiguredRangeFromMandatoryQueryBranches() { + SearchSourceBuilder source = new SearchSourceBuilder().query( + QueryBuilders.boolQuery() + .filter(QueryBuilders.termQuery("service.name", "api")) + .must(QueryBuilders.rangeQuery("@timestamp").gte(100L).lt(200L)) + .should(QueryBuilders.rangeQuery("ignored").gte(1L)) + ); + + List constraints = extractor.extractMandatoryConstraints(source, Set.of("@timestamp")); + + assertThat(constraints.size(), equalTo(1)); + assertThat(constraints.get(0), instanceOf(RangeQueryConstraint.class)); + + RangeQueryConstraint constraint = (RangeQueryConstraint) constraints.get(0); + assertThat(constraint.field(), equalTo("@timestamp")); + assertThat(constraint.lowerValue(), equalTo(100L)); + assertThat(constraint.upperValue(), equalTo(200L)); + assertTrue(constraint.includeLower()); + assertFalse(constraint.includeUpper()); + } + + public void testExtractsRangeFromConstantScoreFilter() { + SearchSourceBuilder source = new SearchSourceBuilder().query( + QueryBuilders.constantScoreQuery(QueryBuilders.rangeQuery("@timestamp").from(100L).to(200L)) + ); + + List constraints = extractor.extractMandatoryConstraints(source, Set.of("@timestamp")); + + assertThat(constraints.size(), equalTo(1)); + assertThat(constraints.get(0).field(), equalTo("@timestamp")); + } + + public void testExtractsDateMathRangeAsRawValues() { + SearchSourceBuilder source = new SearchSourceBuilder().query(QueryBuilders.rangeQuery("@timestamp").gte("now-2m").lte("now")); + + List constraints = extractor.extractMandatoryConstraints(source, Set.of("@timestamp")); + + assertThat(constraints.size(), equalTo(1)); + assertThat(constraints.get(0), instanceOf(RangeQueryConstraint.class)); + + RangeQueryConstraint constraint = (RangeQueryConstraint) constraints.get(0); + assertThat(constraint.field(), equalTo("@timestamp")); + assertThat(constraint.lowerValue(), equalTo("now-2m")); + assertThat(constraint.upperValue(), equalTo("now")); + assertTrue(constraint.includeLower()); + assertTrue(constraint.includeUpper()); + } + + public void testDoesNotExtractRangesFromOptionalBranches() { + SearchSourceBuilder source = new SearchSourceBuilder().query( + QueryBuilders.boolQuery().should(QueryBuilders.rangeQuery("@timestamp").gte(100L).lte(200L)) + ); + + assertTrue(extractor.extractMandatoryConstraints(source, Set.of("@timestamp")).isEmpty()); + } + + public void testDoesNotExtractRangesFromDisMaxBranches() { + SearchSourceBuilder source = new SearchSourceBuilder().query( + QueryBuilders.disMaxQuery() + .add(QueryBuilders.rangeQuery("@timestamp").gte(100L).lte(200L)) + .add(QueryBuilders.termQuery("service.name", "api")) + ); + + assertTrue(extractor.extractMandatoryConstraints(source, Set.of("@timestamp")).isEmpty()); + } + + public void testDoesNotExtractRangesFromNegativeBranches() { + SearchSourceBuilder source = new SearchSourceBuilder().query( + QueryBuilders.boolQuery().mustNot(QueryBuilders.rangeQuery("@timestamp").gte(100L).lte(200L)) + ); + + assertTrue(extractor.extractMandatoryConstraints(source, Set.of("@timestamp")).isEmpty()); + } + + public void testExtractsRangeQueryInterpretationOptions() { + SearchSourceBuilder source = new SearchSourceBuilder().query( + QueryBuilders.rangeQuery("@timestamp") + .gte("2024-01-01") + .format("strict_date_optional_time") + .timeZone("+01:00") + .relation("within") + ); + + List constraints = extractor.extractMandatoryConstraints(source, Set.of("@timestamp")); + + assertThat(constraints.size(), equalTo(1)); + assertThat(constraints.get(0), instanceOf(RangeQueryConstraint.class)); + + RangeQueryConstraint constraint = (RangeQueryConstraint) constraints.get(0); + assertThat(constraint.format(), equalTo("strict_date_optional_time")); + assertThat(constraint.timeZone(), equalTo("+01:00")); + assertThat(constraint.relation(), equalTo(ShapeRelation.WITHIN)); + } + + public void testIgnoresUnconfiguredFields() { + SearchSourceBuilder source = new SearchSourceBuilder().query(QueryBuilders.rangeQuery("@timestamp").gte(100L).lte(200L)); + + assertTrue(extractor.extractMandatoryConstraints(source, Set.of("event.ingested")).isEmpty()); + } +} diff --git a/server/src/test/java/org/opensearch/bootstrap/BootstrapSerialFilterTests.java b/server/src/test/java/org/opensearch/bootstrap/BootstrapSerialFilterTests.java new file mode 100644 index 0000000000000..579b8fe535865 --- /dev/null +++ b/server/src/test/java/org/opensearch/bootstrap/BootstrapSerialFilterTests.java @@ -0,0 +1,152 @@ +/* + * 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.bootstrap; + +import org.opensearch.common.SuppressForbidden; +import org.opensearch.test.OpenSearchTestCase; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InvalidClassException; +import java.io.ObjectInputFilter; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.util.ArrayList; +import java.util.List; + +/** + * Tests for the process-wide deserialization filter installed by {@link Bootstrap#initializeSerialFilter()}. + *

    + * The filter rejects all Java deserialization by default. Plugins that need deserialization + * (e.g., security plugin) opt in by calling {@code setObjectInputFilter()} on their stream, + * which overrides the JVM-wide filter for that stream. + *

    + * Note: String/primitive types use special serialization type codes (TC_STRING) that bypass + * ObjectInputFilter checks. These tests use ArrayList to exercise the filter on real object types. + */ +@SuppressForbidden(reason = "testing the runtime serialization filter that protects against java deserialization") +public class BootstrapSerialFilterTests extends OpenSearchTestCase { + + private static final boolean FILTER_INSTALLED; + + static { + // Install the JVM-wide filter. This can only be set once per JVM — if another test + // or the framework already set it, the end-to-end tests are skipped. + boolean installed = false; + try { + ObjectInputFilter.Config.setSerialFilter(BootstrapSettings.REJECT_ALL_FILTER); + installed = true; + } catch (IllegalStateException e) { + // Already set + } + FILTER_INSTALLED = installed; + } + + // --- Unit tests for the filter logic (always run) --- + + public void testRejectAllFilterRejectsClasses() { + assertEquals(ObjectInputFilter.Status.REJECTED, BootstrapSettings.REJECT_ALL_FILTER.checkInput(filterInfo(String.class))); + } + + public void testRejectAllFilterRejectsAnyClass() { + assertEquals(ObjectInputFilter.Status.REJECTED, BootstrapSettings.REJECT_ALL_FILTER.checkInput(filterInfo(Runtime.class))); + } + + public void testRejectAllFilterUndecidedForNullClass() { + // null serialClass = stream metadata check (depth, bytes, refs), not a class resolution + assertEquals(ObjectInputFilter.Status.UNDECIDED, BootstrapSettings.REJECT_ALL_FILTER.checkInput(filterInfo(null))); + } + + // --- End-to-end tests showing actual runtime behavior --- + + /** + * When a plugin uses ObjectInputStream without setting its own filter, + * deserialization fails with InvalidClassException at runtime. + * This is the protection against unexpected deserialization in plugins/dependencies. + */ + public void testDeserializationRejectedWithoutExplicitFilter() throws Exception { + assumeTrue("JVM-wide serial filter not installed in this JVM", FILTER_INSTALLED); + + byte[] serialized = serialize(new ArrayList<>(List.of("a", "b"))); + try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(serialized))) { + InvalidClassException e = expectThrows(InvalidClassException.class, ois::readObject); + assertTrue(e.getMessage().contains("REJECTED")); + } + } + + /** + * When a plugin explicitly sets its own filter (like security plugin's SafeObjectInputStream), + * the stream-level filter overrides the JVM-wide reject-all, and deserialization succeeds. + */ + public void testDeserializationAllowedWithExplicitFilter() throws Exception { + assumeTrue("JVM-wide serial filter not installed in this JVM", FILTER_INSTALLED); + + byte[] serialized = serialize(new ArrayList<>(List.of("a", "b"))); + try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(serialized))) { + // This is what security plugin does — sets a filter on the stream + ois.setObjectInputFilter(ObjectInputFilter.Config.createFilter("maxdepth=10")); + Object result = ois.readObject(); + assertEquals(List.of("a", "b"), result); + } + } + + /** + * Proves the stream-level filter is actually enforced — not just bypassing all checks. + * A maxdepth=2 filter allows a shallow ArrayList but rejects a deeply nested structure. + */ + public void testStreamFilterDepthConstraintIsEnforced() throws Exception { + assumeTrue("JVM-wide serial filter not installed in this JVM", FILTER_INSTALLED); + + // Create a deeply nested object: ArrayList -> ArrayList -> ArrayList (depth=3) + ArrayList deep = new ArrayList<>(); + deep.add(new ArrayList<>(List.of(new ArrayList<>(List.of("nested"))))); + + byte[] serialized = serialize(deep); + try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(serialized))) { + // maxdepth=2 should reject the depth=3 structure + ois.setObjectInputFilter(ObjectInputFilter.Config.createFilter("maxdepth=2")); + InvalidClassException e = expectThrows(InvalidClassException.class, ois::readObject); + assertTrue(e.getMessage().contains("REJECTED")); + } + } + + // --- Helpers --- + + private static ObjectInputFilter.FilterInfo filterInfo(Class clazz) { + return new ObjectInputFilter.FilterInfo() { + public Class serialClass() { + return clazz; + } + + public long arrayLength() { + return -1; + } + + public long depth() { + return 1; + } + + public long references() { + return 1; + } + + public long streamBytes() { + return 0; + } + }; + } + + private static byte[] serialize(Object obj) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(obj); + } + return baos.toByteArray(); + } +} diff --git a/server/src/test/java/org/opensearch/cluster/metadata/MetadataCreateIndexServiceTests.java b/server/src/test/java/org/opensearch/cluster/metadata/MetadataCreateIndexServiceTests.java index 2bebcd33ac59c..d48e1f40a52b6 100644 --- a/server/src/test/java/org/opensearch/cluster/metadata/MetadataCreateIndexServiceTests.java +++ b/server/src/test/java/org/opensearch/cluster/metadata/MetadataCreateIndexServiceTests.java @@ -985,6 +985,7 @@ public void testDefaultSettings() { ); assertThat(aggregatedIndexSettings.get(SETTING_NUMBER_OF_SHARDS), equalTo("1")); + assertThat(aggregatedIndexSettings.get(SETTING_NUMBER_OF_REPLICAS), equalTo("1")); } public void testSettingsFromClusterState() { @@ -1003,6 +1004,118 @@ public void testSettingsFromClusterState() { assertThat(aggregatedIndexSettings.get(SETTING_NUMBER_OF_SHARDS), equalTo("15")); } + public void testDefaultNumberOfReplicasUsesNodeSetting() { + Settings nodeSettings = Settings.builder().put(Metadata.DEFAULT_REPLICA_COUNT_SETTING.getKey(), 0).build(); + ClusterSettings clusterSettings = new ClusterSettings(nodeSettings, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS); + + Settings aggregatedIndexSettings = aggregateIndexSettings( + ClusterState.EMPTY_STATE, + request, + Settings.EMPTY, + null, + nodeSettings, + IndexScopedSettings.DEFAULT_SCOPED_SETTINGS, + randomShardLimitService(), + Collections.emptySet(), + clusterSettings + ); + + assertThat(aggregatedIndexSettings.get(SETTING_NUMBER_OF_REPLICAS), equalTo("0")); + } + + public void testDefaultNumberOfReplicasUsesPersistentSettingOverNodeSetting() { + Settings nodeSettings = Settings.builder().put(Metadata.DEFAULT_REPLICA_COUNT_SETTING.getKey(), 0).build(); + ClusterSettings clusterSettings = new ClusterSettings(nodeSettings, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS); + Metadata metadata = Metadata.builder() + .persistentSettings(Settings.builder().put(Metadata.DEFAULT_REPLICA_COUNT_SETTING.getKey(), 2).build()) + .build(); + ClusterState clusterState = ClusterState.builder(ClusterName.CLUSTER_NAME_SETTING.getDefault(Settings.EMPTY)) + .metadata(metadata) + .build(); + clusterSettings.applySettings(clusterState.metadata().settings()); + + Settings aggregatedIndexSettings = aggregateIndexSettings( + clusterState, + request, + Settings.EMPTY, + null, + nodeSettings, + IndexScopedSettings.DEFAULT_SCOPED_SETTINGS, + randomShardLimitService(), + Collections.emptySet(), + clusterSettings + ); + + assertThat(aggregatedIndexSettings.get(SETTING_NUMBER_OF_REPLICAS), equalTo("2")); + } + + public void testDefaultNumberOfReplicasUsesTransientSettingOverPersistentAndNodeSettings() { + Settings nodeSettings = Settings.builder().put(Metadata.DEFAULT_REPLICA_COUNT_SETTING.getKey(), 0).build(); + ClusterSettings clusterSettings = new ClusterSettings(nodeSettings, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS); + Metadata metadata = Metadata.builder() + .persistentSettings(Settings.builder().put(Metadata.DEFAULT_REPLICA_COUNT_SETTING.getKey(), 2).build()) + .transientSettings(Settings.builder().put(Metadata.DEFAULT_REPLICA_COUNT_SETTING.getKey(), 3).build()) + .build(); + ClusterState clusterState = ClusterState.builder(ClusterName.CLUSTER_NAME_SETTING.getDefault(Settings.EMPTY)) + .metadata(metadata) + .build(); + clusterSettings.applySettings(clusterState.metadata().settings()); + + Settings aggregatedIndexSettings = aggregateIndexSettings( + clusterState, + request, + Settings.EMPTY, + null, + nodeSettings, + IndexScopedSettings.DEFAULT_SCOPED_SETTINGS, + randomShardLimitService(), + Collections.emptySet(), + clusterSettings + ); + + assertThat(aggregatedIndexSettings.get(SETTING_NUMBER_OF_REPLICAS), equalTo("3")); + } + + public void testExplicitNumberOfReplicasOverridesClusterDefault() { + Settings nodeSettings = Settings.builder().put(Metadata.DEFAULT_REPLICA_COUNT_SETTING.getKey(), 0).build(); + ClusterSettings clusterSettings = new ClusterSettings(nodeSettings, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS); + request.settings(Settings.builder().put(SETTING_NUMBER_OF_REPLICAS, 4).build()); + + Settings aggregatedIndexSettings = aggregateIndexSettings( + ClusterState.EMPTY_STATE, + request, + Settings.EMPTY, + null, + nodeSettings, + IndexScopedSettings.DEFAULT_SCOPED_SETTINGS, + randomShardLimitService(), + Collections.emptySet(), + clusterSettings + ); + + assertThat(aggregatedIndexSettings.get(SETTING_NUMBER_OF_REPLICAS), equalTo("4")); + } + + public void testTemplateNumberOfReplicasOverridesClusterDefault() { + Settings nodeSettings = Settings.builder().put(Metadata.DEFAULT_REPLICA_COUNT_SETTING.getKey(), 0).build(); + ClusterSettings clusterSettings = new ClusterSettings(nodeSettings, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS); + Settings templateSettings = Settings.builder().put(SETTING_NUMBER_OF_REPLICAS, 2).build(); + + Settings aggregatedIndexSettings = aggregateIndexSettings( + ClusterState.EMPTY_STATE, + request, + templateSettings, + null, + nodeSettings, + IndexScopedSettings.DEFAULT_SCOPED_SETTINGS, + randomShardLimitService(), + Collections.emptySet(), + clusterSettings + ); + + assertThat(aggregatedIndexSettings.get(SETTING_NUMBER_OF_REPLICAS), equalTo("2")); + } + public void testTemplateOrder() throws Exception { List templates = new ArrayList<>(3); templates.add( @@ -1278,6 +1391,51 @@ public void testValidateIndexSettings() { threadPool.shutdown(); } + public void testValidateIndexSettingsUsesDefaultNumberOfReplicasFromClusterSettings() { + ClusterService clusterService = mock(ClusterService.class); + ClusterState clusterState = ClusterState.builder(ClusterName.CLUSTER_NAME_SETTING.getDefault(Settings.EMPTY)) + .metadata(Metadata.builder().build()) + .build(); + + ThreadPool threadPool = new TestThreadPool(getTestName()); + Settings settings = Settings.builder() + .put(AwarenessAllocationDecider.CLUSTER_ROUTING_ALLOCATION_AWARENESS_ATTRIBUTE_SETTING.getKey(), "zone, rack") + .put(AwarenessAllocationDecider.CLUSTER_ROUTING_ALLOCATION_AWARENESS_FORCE_GROUP_SETTING.getKey() + "zone.values", "a, b") + .put(AwarenessAllocationDecider.CLUSTER_ROUTING_ALLOCATION_AWARENESS_FORCE_GROUP_SETTING.getKey() + "rack.values", "c, d, e") + .put(AwarenessReplicaBalance.CLUSTER_ROUTING_ALLOCATION_AWARENESS_BALANCE_SETTING.getKey(), true) + .put(Metadata.DEFAULT_REPLICA_COUNT_SETTING.getKey(), 2) + .build(); + ClusterSettings clusterSettings = new ClusterSettings(settings, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS); + when(clusterService.getSettings()).thenReturn(settings); + when(clusterService.getClusterSettings()).thenReturn(clusterSettings); + when(clusterService.state()).thenReturn(clusterState); + + MetadataCreateIndexService checkerService = new MetadataCreateIndexService( + settings, + clusterService, + indicesServices, + null, + null, + createTestShardLimitService(randomIntBetween(1, 1000), false, clusterService), + new Environment(Settings.builder().put("path.home", "dummy").build(), null), + IndexScopedSettings.DEFAULT_SCOPED_SETTINGS, + threadPool, + null, + new SystemIndices(Collections.emptyMap()), + true, + new AwarenessReplicaBalance(settings, clusterService.getClusterSettings()), + DefaultRemoteStoreSettings.INSTANCE, + repositoriesServiceSupplier + ); + + try { + List validationErrors = checkerService.getIndexSettingsValidationErrors(settings, false, Optional.empty()); + assertThat(validationErrors.size(), is(0)); + } finally { + threadPool.shutdown(); + } + } + public void testIndexTemplateReplicationType() { Settings templateSettings = Settings.builder().put(INDEX_REPLICATION_TYPE_SETTING.getKey(), ReplicationType.SEGMENT).build(); @@ -2683,6 +2841,7 @@ public void testAggregateIndexSettingsIndexReplicaIsSetToNull() { .build(); Settings settings = Settings.builder().put(CLUSTER_REMOTE_INDEX_RESTRICT_ASYNC_DURABILITY_SETTING.getKey(), true).build(); clusterSettings = new ClusterSettings(settings, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS); + clusterSettings.applySettings(clusterState.metadata().settings()); Settings aggregatedSettings = aggregateIndexSettings( clusterState, request, diff --git a/server/src/test/java/org/opensearch/cluster/metadata/MetadataIndexTemplateServiceRemoteStoreTests.java b/server/src/test/java/org/opensearch/cluster/metadata/MetadataIndexTemplateServiceRemoteStoreTests.java new file mode 100644 index 0000000000000..fbb88f5c4e929 --- /dev/null +++ b/server/src/test/java/org/opensearch/cluster/metadata/MetadataIndexTemplateServiceRemoteStoreTests.java @@ -0,0 +1,173 @@ +/* + * 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.cluster.metadata; + +import org.opensearch.cluster.ClusterName; +import org.opensearch.cluster.ClusterState; +import org.opensearch.cluster.metadata.MetadataIndexTemplateService.PutRequest; +import org.opensearch.cluster.node.DiscoveryNodes; +import org.opensearch.cluster.routing.allocation.AwarenessReplicaBalance; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.settings.ClusterSettings; +import org.opensearch.common.settings.IndexScopedSettings; +import org.opensearch.common.settings.Settings; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.env.Environment; +import org.opensearch.index.shard.IndexShardTestUtils; +import org.opensearch.indices.DefaultRemoteStoreSettings; +import org.opensearch.indices.IndicesService; +import org.opensearch.indices.SystemIndices; +import org.opensearch.indices.replication.common.ReplicationType; +import org.opensearch.test.OpenSearchTestCase; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static java.util.Collections.singletonList; +import static org.opensearch.cluster.metadata.IndexMetadata.INDEX_REPLICATION_TYPE_SETTING; +import static org.opensearch.cluster.routing.allocation.decider.ShardsLimitAllocationDecider.INDEX_TOTAL_PRIMARY_SHARDS_PER_NODE_SETTING; +import static org.opensearch.common.settings.Settings.builder; +import static org.opensearch.env.Environment.PATH_HOME_SETTING; +import static org.opensearch.indices.ShardLimitValidatorTests.createTestShardLimitService; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.not; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests that index-template validation of {@code index.routing.allocation.total_primary_shards_per_node} + * uses the cluster/node-aware precondition (is this a remote-store cluster?) rather than the index-local + * {@code index.remote_store.enabled} flag a template can never carry. + * + *

    Kept in its own class (rather than {@link MetadataIndexTemplateServiceTests}) because these cases are + * fully mock-driven and do not need the shared single-node fixture; isolating them also avoids perturbing + * the randomized method ordering of that (state-sharing) suite. + */ +public class MetadataIndexTemplateServiceRemoteStoreTests extends OpenSearchTestCase { + + public void testTotalPrimaryShardsTemplateValidatesOnRemoteStoreCluster() { + // Every node is a remote-store node → the template must validate successfully. + PutRequest request = new PutRequest("test", "test_index_primary_shard_constraint_remote"); + request.patterns(singletonList("test_shards_wait*")); + request.settings( + builder().put(IndexMetadata.SETTING_NUMBER_OF_REPLICAS, "1") + .put(IndexMetadata.SETTING_NUMBER_OF_SHARDS, "1") + .put(INDEX_TOTAL_PRIMARY_SHARDS_PER_NODE_SETTING.getKey(), 2) + .put(INDEX_REPLICATION_TYPE_SETTING.getKey(), ReplicationType.SEGMENT.toString()) + .build() + ); + + DiscoveryNodes remoteNodes = DiscoveryNodes.builder() + .add(IndexShardTestUtils.getFakeRemoteEnabledNode("node1")) + .add(IndexShardTestUtils.getFakeRemoteEnabledNode("node2")) + .build(); + + List throwables = putTemplate(xContentRegistry(), request, remoteNodes); + assertThat(throwables, empty()); + } + + public void testTotalPrimaryShardsTemplateRejectedOnEmptyNodeCluster() { + // No nodes → not a remote-store cluster (allMatch is vacuously true on an empty set, so the + // !nodes.isEmpty() guard must reject). Covers the empty-node branch of the hardened check. + PutRequest request = new PutRequest("test", "test_index_primary_shard_constraint_empty"); + request.patterns(singletonList("test_shards_wait*")); + request.settings( + builder().put(IndexMetadata.SETTING_NUMBER_OF_REPLICAS, "1") + .put(IndexMetadata.SETTING_NUMBER_OF_SHARDS, "1") + .put(INDEX_TOTAL_PRIMARY_SHARDS_PER_NODE_SETTING.getKey(), 2) + .put(INDEX_REPLICATION_TYPE_SETTING.getKey(), ReplicationType.SEGMENT.toString()) + .build() + ); + + List throwables = putTemplate(xContentRegistry(), request, DiscoveryNodes.EMPTY_NODES); + assertThat(throwables, not(empty())); + assertThat(throwables.get(0), instanceOf(IllegalArgumentException.class)); + assertThat(throwables.get(0).getMessage(), containsString("can only be used with remote store enabled clusters")); + } + + public void testTotalPrimaryShardsTemplateRejectedWhenSomeNodeIsNotRemoteStore() { + // Mixed cluster (one non-remote node) → not every node is a remote-store node, so the + // setting must be rejected. Covers the allMatch==false branch of the hardened check. + PutRequest request = new PutRequest("test", "test_index_primary_shard_constraint_mixed"); + request.patterns(singletonList("test_shards_wait*")); + request.settings( + builder().put(IndexMetadata.SETTING_NUMBER_OF_REPLICAS, "1") + .put(IndexMetadata.SETTING_NUMBER_OF_SHARDS, "1") + .put(INDEX_TOTAL_PRIMARY_SHARDS_PER_NODE_SETTING.getKey(), 2) + .put(INDEX_REPLICATION_TYPE_SETTING.getKey(), ReplicationType.SEGMENT.toString()) + .build() + ); + + DiscoveryNodes mixedNodes = DiscoveryNodes.builder() + .add(IndexShardTestUtils.getFakeRemoteEnabledNode("remote1")) + .add(IndexShardTestUtils.getFakeDiscoNode("plain1")) + .build(); + + List throwables = putTemplate(xContentRegistry(), request, mixedNodes); + assertThat(throwables, not(empty())); + assertThat(throwables.get(0), instanceOf(IllegalArgumentException.class)); + assertThat(throwables.get(0).getMessage(), containsString("can only be used with remote store enabled clusters")); + } + + private static List putTemplate(NamedXContentRegistry xContentRegistry, PutRequest request, DiscoveryNodes discoveryNodes) { + ClusterService clusterService = mock(ClusterService.class); + Settings settings = Settings.builder().put(PATH_HOME_SETTING.getKey(), "dummy").build(); + ClusterSettings clusterSettings = new ClusterSettings(settings, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS); + Metadata metadata = Metadata.builder().build(); + ClusterState clusterState = ClusterState.builder(ClusterName.CLUSTER_NAME_SETTING.getDefault(Settings.EMPTY)) + .metadata(metadata) + .nodes(discoveryNodes) + .build(); + when(clusterService.state()).thenReturn(clusterState); + when(clusterService.getSettings()).thenReturn(settings); + when(clusterService.getClusterSettings()).thenReturn(clusterSettings); + IndicesService indicesServices = mock(IndicesService.class); + MetadataCreateIndexService createIndexService = new MetadataCreateIndexService( + Settings.EMPTY, + clusterService, + indicesServices, + null, + null, + createTestShardLimitService(randomIntBetween(1, 1000), false), + new Environment(builder().put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString()).build(), null), + IndexScopedSettings.DEFAULT_SCOPED_SETTINGS, + null, + xContentRegistry, + new SystemIndices(Collections.emptyMap()), + true, + new AwarenessReplicaBalance(Settings.EMPTY, clusterService.getClusterSettings()), + DefaultRemoteStoreSettings.INSTANCE, + null + ); + MetadataIndexTemplateService service = new MetadataIndexTemplateService( + clusterService, + createIndexService, + new AliasValidator(), + null, + new IndexScopedSettings(Settings.EMPTY, IndexScopedSettings.BUILT_IN_INDEX_SETTINGS), + xContentRegistry, + null + ); + + final List throwables = new ArrayList<>(); + service.putTemplate(request, new MetadataIndexTemplateService.PutListener() { + @Override + public void onResponse(MetadataIndexTemplateService.PutResponse response) {} + + @Override + public void onFailure(Exception e) { + throwables.add(e); + } + }); + return throwables; + } +} diff --git a/server/src/test/java/org/opensearch/cluster/routing/DelayedAllocationServiceTests.java b/server/src/test/java/org/opensearch/cluster/routing/DelayedAllocationServiceTests.java index 6611554004639..0f5be5798c3dc 100644 --- a/server/src/test/java/org/opensearch/cluster/routing/DelayedAllocationServiceTests.java +++ b/server/src/test/java/org/opensearch/cluster/routing/DelayedAllocationServiceTests.java @@ -233,6 +233,79 @@ public void testDelayedUnassignedScheduleReroute() throws Exception { verifyNoMoreInteractions(clusterService); } + public void testDelayedUnassignedScheduleRerouteUsesNodeLevelClusterDefault() throws Exception { + TimeValue delaySetting = timeValueMillis(100); + Settings nodeSettings = Settings.builder() + .put(NodeRoles.clusterManagerOnlyNode()) + .put(UnassignedInfo.CLUSTER_DELAYED_NODE_LEFT_TIMEOUT_SETTING.getKey(), delaySetting) + .build(); + ClusterService localClusterService = mock(ClusterService.class); + MockAllocationService localAllocationService = createAllocationService(nodeSettings, new DelayedShardsMockGatewayAllocator()); + when(localClusterService.getSettings()).thenReturn(nodeSettings); + TestDelayAllocationService localDelayedAllocationService = new TestDelayAllocationService( + threadPool, + localClusterService, + localAllocationService + ); + verify(localClusterService).addListener(localDelayedAllocationService); + verify(localClusterService).getSettings(); + + Metadata metadata = Metadata.builder() + .put(IndexMetadata.builder("test").settings(settings(Version.CURRENT)).numberOfShards(1).numberOfReplicas(1)) + .build(); + ClusterState clusterState = ClusterState.builder(ClusterName.CLUSTER_NAME_SETTING.getDefault(Settings.EMPTY)) + .metadata(metadata) + .routingTable(RoutingTable.builder().addAsNew(metadata.index("test")).build()) + .nodes(DiscoveryNodes.builder().add(newNode("node1")).add(newNode("node2")).localNodeId("node1").clusterManagerNodeId("node1")) + .build(); + final long baseTimestampNanos = System.nanoTime(); + localAllocationService.setNanoTimeOverride(baseTimestampNanos); + clusterState = localAllocationService.reroute(clusterState, "reroute"); + clusterState = startInitializingShardsAndReroute(localAllocationService, clusterState); + clusterState = startInitializingShardsAndReroute(localAllocationService, clusterState); + + String replicaNodeId = null; + for (ShardRouting shardRouting : clusterState.getRoutingTable().allShards("test")) { + if (shardRouting.primary() == false) { + replicaNodeId = shardRouting.currentNodeId(); + break; + } + } + assertNotNull(replicaNodeId); + + ClusterState beforeNodeLeft = clusterState; + clusterState = ClusterState.builder(clusterState).nodes(DiscoveryNodes.builder(clusterState.nodes()).remove(replicaNodeId)).build(); + clusterState = localAllocationService.disassociateDeadNodes(clusterState, true, "reroute"); + ClusterState stateWithDelayedShard = clusterState; + // isDelayed alone cannot distinguish the node-level 100ms setting from the built-in 1m default. + // The scheduled nextDelay assertion below is the regression check for the node-level setting path. + assertEquals(1, UnassignedInfo.getNumberOfDelayedUnassigned(stateWithDelayedShard)); + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference clusterStateUpdateTask = new AtomicReference<>(); + doAnswer(invocationOnMock -> { + clusterStateUpdateTask.set((ClusterStateUpdateTask) invocationOnMock.getArguments()[1]); + latch.countDown(); + return null; + }).when(localClusterService).submitStateUpdateTask(eq(CLUSTER_UPDATE_TASK_SOURCE), any(ClusterStateUpdateTask.class)); + + long delayUntilClusterChangeEvent = TimeValue.timeValueNanos(randomInt((int) delaySetting.nanos() - 1)).nanos(); + long clusterChangeEventTimestampNanos = baseTimestampNanos + delayUntilClusterChangeEvent; + localDelayedAllocationService.setNanoTimeOverride(clusterChangeEventTimestampNanos); + localDelayedAllocationService.clusterChanged(new ClusterChangedEvent("fake node left", stateWithDelayedShard, beforeNodeLeft)); + + DelayedAllocationService.DelayedRerouteTask delayedRerouteTask = localDelayedAllocationService.delayedRerouteTask.get(); + assertNotNull(delayedRerouteTask); + assertFalse(delayedRerouteTask.cancelScheduling.get()); + assertThat(delayedRerouteTask.baseTimestampNanos, equalTo(clusterChangeEventTimestampNanos)); + assertThat( + delayedRerouteTask.nextDelay.nanos(), + equalTo(delaySetting.nanos() - (clusterChangeEventTimestampNanos - baseTimestampNanos)) + ); + assertTrue(latch.await(30, TimeUnit.SECONDS)); + verify(localClusterService).submitStateUpdateTask(eq(CLUSTER_UPDATE_TASK_SOURCE), eq(clusterStateUpdateTask.get())); + } + /** * This tests that a new delayed reroute is scheduled right after a delayed reroute was run */ @@ -342,7 +415,13 @@ public void testDelayedUnassignedScheduleRerouteAfterDelayedReroute() throws Exc assertThat(firstDelayedRerouteTask.baseTimestampNanos, equalTo(clusterChangeEventTimestampNanos)); assertThat( firstDelayedRerouteTask.nextDelay.nanos(), - equalTo(UnassignedInfo.findNextDelayedAllocation(clusterChangeEventTimestampNanos, stateWithDelayedShards)) + equalTo( + UnassignedInfo.findNextDelayedAllocation( + clusterChangeEventTimestampNanos, + stateWithDelayedShards, + stateWithDelayedShards.metadata().settings() + ) + ) ); assertThat( firstDelayedRerouteTask.nextDelay.nanos(), @@ -386,7 +465,13 @@ public void testDelayedUnassignedScheduleRerouteAfterDelayedReroute() throws Exc assertThat(secondDelayedRerouteTask.baseTimestampNanos, equalTo(clusterChangeEventTimestampNanos)); assertThat( secondDelayedRerouteTask.nextDelay.nanos(), - equalTo(UnassignedInfo.findNextDelayedAllocation(clusterChangeEventTimestampNanos, stateWithOnlyOneDelayedShard)) + equalTo( + UnassignedInfo.findNextDelayedAllocation( + clusterChangeEventTimestampNanos, + stateWithOnlyOneDelayedShard, + stateWithOnlyOneDelayedShard.metadata().settings() + ) + ) ); assertThat( secondDelayedRerouteTask.nextDelay.nanos(), diff --git a/server/src/test/java/org/opensearch/cluster/routing/UnassignedInfoTests.java b/server/src/test/java/org/opensearch/cluster/routing/UnassignedInfoTests.java index cafc5eb5b5694..ed23fbd069837 100644 --- a/server/src/test/java/org/opensearch/cluster/routing/UnassignedInfoTests.java +++ b/server/src/test/java/org/opensearch/cluster/routing/UnassignedInfoTests.java @@ -421,17 +421,52 @@ public void testRemainingDelayCalculation() throws Exception { final Settings indexSettings = Settings.builder() .put(UnassignedInfo.INDEX_DELAYED_NODE_LEFT_TIMEOUT_SETTING.getKey(), TimeValue.timeValueNanos(totalDelayNanos)) .build(); - long delay = unassignedInfo.getRemainingDelay(baseTime, indexSettings); + long delay = unassignedInfo.getRemainingDelay(baseTime, indexSettings, Settings.EMPTY); assertThat(delay, equalTo(totalDelayNanos)); long delta1 = randomIntBetween(1, (int) (totalDelayNanos - 1)); - delay = unassignedInfo.getRemainingDelay(baseTime + delta1, indexSettings); + delay = unassignedInfo.getRemainingDelay(baseTime + delta1, indexSettings, Settings.EMPTY); assertThat(delay, equalTo(totalDelayNanos - delta1)); - delay = unassignedInfo.getRemainingDelay(baseTime + totalDelayNanos, indexSettings); + delay = unassignedInfo.getRemainingDelay(baseTime + totalDelayNanos, indexSettings, Settings.EMPTY); assertThat(delay, equalTo(0L)); - delay = unassignedInfo.getRemainingDelay(baseTime + totalDelayNanos + randomIntBetween(1, 20), indexSettings); + delay = unassignedInfo.getRemainingDelay(baseTime + totalDelayNanos + randomIntBetween(1, 20), indexSettings, Settings.EMPTY); assertThat(delay, equalTo(0L)); } + public void testRemainingDelayUsesClusterDefaultWhenIndexSettingIsMissing() { + final long baseTime = System.nanoTime(); + UnassignedInfo unassignedInfo = new UnassignedInfo( + UnassignedInfo.Reason.NODE_LEFT, + "test", + null, + 0, + baseTime, + System.currentTimeMillis(), + true, + AllocationStatus.NO_ATTEMPT, + Collections.emptySet() + ); + final TimeValue clusterDelay = TimeValue.timeValueMillis(randomIntBetween(1, 200)); + final Settings clusterSettings = Settings.builder() + .put(UnassignedInfo.CLUSTER_DELAYED_NODE_LEFT_TIMEOUT_SETTING.getKey(), clusterDelay) + .build(); + + assertThat(UnassignedInfo.getNodeLeftDelayedTimeout(Settings.EMPTY, clusterSettings), equalTo(clusterDelay)); + assertThat(unassignedInfo.getRemainingDelay(baseTime, Settings.EMPTY, clusterSettings), equalTo(clusterDelay.nanos())); + } + + public void testIndexDelayedAllocationSettingOverridesClusterDefault() { + final TimeValue clusterDelay = TimeValue.timeValueMillis(randomIntBetween(201, 400)); + final TimeValue indexDelay = TimeValue.timeValueMillis(randomIntBetween(1, 200)); + final Settings clusterSettings = Settings.builder() + .put(UnassignedInfo.CLUSTER_DELAYED_NODE_LEFT_TIMEOUT_SETTING.getKey(), clusterDelay) + .build(); + final Settings indexSettings = Settings.builder() + .put(UnassignedInfo.INDEX_DELAYED_NODE_LEFT_TIMEOUT_SETTING.getKey(), indexDelay) + .build(); + + assertThat(UnassignedInfo.getNodeLeftDelayedTimeout(indexSettings, clusterSettings), equalTo(indexDelay)); + } + public void testNumberOfDelayedUnassigned() throws Exception { MockAllocationService allocation = createAllocationService(Settings.EMPTY, new DelayedShardsMockGatewayAllocator()); Metadata metadata = Metadata.builder() @@ -459,6 +494,85 @@ public void testNumberOfDelayedUnassigned() throws Exception { assertThat(clusterState.toString(), UnassignedInfo.getNumberOfDelayedUnassigned(clusterState), equalTo(2)); } + public void testClusterDelayedAllocationSettingControlsNodeLeftDelay() { + MockAllocationService allocation = createAllocationService(Settings.EMPTY, new DelayedShardsMockGatewayAllocator()); + Settings clusterSettings = Settings.builder() + .put(UnassignedInfo.CLUSTER_DELAYED_NODE_LEFT_TIMEOUT_SETTING.getKey(), TimeValue.timeValueMillis(0)) + .build(); + ClusterState clusterState = createStartedClusterState(allocation, clusterSettings, Settings.EMPTY); + + clusterState = ClusterState.builder(clusterState).nodes(DiscoveryNodes.builder(clusterState.nodes()).remove("node2")).build(); + clusterState = allocation.disassociateDeadNodes(clusterState, true, "reroute"); + + assertThat(clusterState.getRoutingNodes().unassigned().size(), equalTo(1)); + assertThat(clusterState.getRoutingNodes().unassigned().iterator().next().unassignedInfo().isDelayed(), equalTo(false)); + } + + public void testNodeLevelClusterDelayedAllocationSettingControlsNodeLeftDelay() { + Settings nodeSettings = Settings.builder() + .put(UnassignedInfo.CLUSTER_DELAYED_NODE_LEFT_TIMEOUT_SETTING.getKey(), TimeValue.timeValueMillis(0)) + .build(); + MockAllocationService allocation = createAllocationService(nodeSettings, new DelayedShardsMockGatewayAllocator()); + ClusterState clusterState = createStartedClusterState(allocation, Settings.EMPTY, Settings.EMPTY); + + clusterState = ClusterState.builder(clusterState).nodes(DiscoveryNodes.builder(clusterState.nodes()).remove("node2")).build(); + clusterState = allocation.disassociateDeadNodes(clusterState, true, "reroute"); + + assertThat(clusterState.getRoutingNodes().unassigned().size(), equalTo(1)); + assertThat(clusterState.getRoutingNodes().unassigned().iterator().next().unassignedInfo().isDelayed(), equalTo(false)); + } + + public void testClusterStateSettingOverridesNodeLevelClusterSettingForNodeLeftDelay() { + Settings nodeSettings = Settings.builder() + .put(UnassignedInfo.CLUSTER_DELAYED_NODE_LEFT_TIMEOUT_SETTING.getKey(), TimeValue.timeValueMillis(0)) + .build(); + Settings clusterSettings = Settings.builder() + .put(UnassignedInfo.CLUSTER_DELAYED_NODE_LEFT_TIMEOUT_SETTING.getKey(), TimeValue.timeValueMillis(100)) + .build(); + MockAllocationService allocation = createAllocationService(nodeSettings, new DelayedShardsMockGatewayAllocator()); + ClusterState clusterState = createStartedClusterState(allocation, clusterSettings, Settings.EMPTY); + + clusterState = ClusterState.builder(clusterState).nodes(DiscoveryNodes.builder(clusterState.nodes()).remove("node2")).build(); + clusterState = allocation.disassociateDeadNodes(clusterState, true, "reroute"); + + assertThat(clusterState.getRoutingNodes().unassigned().size(), equalTo(1)); + assertThat(clusterState.getRoutingNodes().unassigned().iterator().next().unassignedInfo().isDelayed(), equalTo(true)); + } + + public void testClusterStateSettingOverridesNodeLevelClusterSettingWithNoDelayForNodeLeftDelay() { + Settings nodeSettings = Settings.builder() + .put(UnassignedInfo.CLUSTER_DELAYED_NODE_LEFT_TIMEOUT_SETTING.getKey(), TimeValue.timeValueMillis(100)) + .build(); + Settings clusterSettings = Settings.builder() + .put(UnassignedInfo.CLUSTER_DELAYED_NODE_LEFT_TIMEOUT_SETTING.getKey(), TimeValue.timeValueMillis(0)) + .build(); + MockAllocationService allocation = createAllocationService(nodeSettings, new DelayedShardsMockGatewayAllocator()); + ClusterState clusterState = createStartedClusterState(allocation, clusterSettings, Settings.EMPTY); + + clusterState = ClusterState.builder(clusterState).nodes(DiscoveryNodes.builder(clusterState.nodes()).remove("node2")).build(); + clusterState = allocation.disassociateDeadNodes(clusterState, true, "reroute"); + + assertThat(clusterState.getRoutingNodes().unassigned().size(), equalTo(1)); + assertThat(clusterState.getRoutingNodes().unassigned().iterator().next().unassignedInfo().isDelayed(), equalTo(false)); + } + + public void testIndexDelayedAllocationSettingOverridesClusterSettingForNodeLeftDelay() { + MockAllocationService allocation = createAllocationService(Settings.EMPTY, new DelayedShardsMockGatewayAllocator()); + Settings clusterSettings = Settings.builder() + .put(UnassignedInfo.CLUSTER_DELAYED_NODE_LEFT_TIMEOUT_SETTING.getKey(), TimeValue.timeValueMillis(0)) + .build(); + Settings indexSettings = Settings.builder() + .put(UnassignedInfo.INDEX_DELAYED_NODE_LEFT_TIMEOUT_SETTING.getKey(), TimeValue.timeValueMillis(100)) + .build(); + ClusterState clusterState = createStartedClusterState(allocation, clusterSettings, indexSettings); + + clusterState = ClusterState.builder(clusterState).nodes(DiscoveryNodes.builder(clusterState.nodes()).remove("node2")).build(); + clusterState = allocation.disassociateDeadNodes(clusterState, true, "reroute"); + + assertThat(clusterState.getRoutingNodes().unassigned().size(), equalTo(1)); + assertThat(clusterState.getRoutingNodes().unassigned().iterator().next().unassignedInfo().isDelayed(), equalTo(true)); + } + public void testFindNextDelayedAllocation() { MockAllocationService allocation = createAllocationService(Settings.EMPTY, new DelayedShardsMockGatewayAllocator()); final TimeValue delayTest1 = TimeValue.timeValueMillis(randomIntBetween(1, 200)); @@ -506,7 +620,72 @@ public void testFindNextDelayedAllocation() { clusterState = allocation.reroute(clusterState, "time moved"); } - assertThat(UnassignedInfo.findNextDelayedAllocation(baseTime + delta, clusterState), equalTo(expectMinDelaySettingsNanos - delta)); + assertThat( + UnassignedInfo.findNextDelayedAllocation(baseTime + delta, clusterState, clusterState.metadata().settings()), + equalTo(expectMinDelaySettingsNanos - delta) + ); + } + + public void testFindNextDelayedAllocationUsesClusterDefault() { + MockAllocationService allocation = createAllocationService(Settings.EMPTY, new DelayedShardsMockGatewayAllocator()); + final TimeValue clusterDelay = TimeValue.timeValueMillis(randomIntBetween(1, 200)); + final Settings clusterSettings = Settings.builder() + .put(UnassignedInfo.CLUSTER_DELAYED_NODE_LEFT_TIMEOUT_SETTING.getKey(), clusterDelay) + .build(); + + Metadata metadata = Metadata.builder() + .persistentSettings(clusterSettings) + .put(IndexMetadata.builder("test").settings(settings(Version.CURRENT)).numberOfShards(1).numberOfReplicas(1)) + .build(); + ClusterState clusterState = ClusterState.builder(ClusterName.CLUSTER_NAME_SETTING.getDefault(Settings.EMPTY)) + .metadata(metadata) + .routingTable(RoutingTable.builder().addAsNew(metadata.index("test")).build()) + .build(); + clusterState = ClusterState.builder(clusterState) + .nodes(DiscoveryNodes.builder().add(newNode("node1")).add(newNode("node2"))) + .build(); + clusterState = allocation.reroute(clusterState, "reroute"); + clusterState = startInitializingShardsAndReroute(allocation, clusterState); + clusterState = startInitializingShardsAndReroute(allocation, clusterState); + + final long baseTime = System.nanoTime(); + allocation.setNanoTimeOverride(baseTime); + clusterState = ClusterState.builder(clusterState).nodes(DiscoveryNodes.builder(clusterState.nodes()).remove("node2")).build(); + clusterState = allocation.disassociateDeadNodes(clusterState, true, "reroute"); + + assertThat( + UnassignedInfo.findNextDelayedAllocation(baseTime, clusterState, clusterState.metadata().settings()), + equalTo(clusterDelay.nanos()) + ); + } + + public void testFindNextDelayedAllocationUsesNodeLevelClusterDefault() { + MockAllocationService allocation = createAllocationService(Settings.EMPTY, new DelayedShardsMockGatewayAllocator()); + final TimeValue nodeDelay = TimeValue.timeValueMillis(randomIntBetween(1, 200)); + final Settings nodeSettings = Settings.builder() + .put(UnassignedInfo.CLUSTER_DELAYED_NODE_LEFT_TIMEOUT_SETTING.getKey(), nodeDelay) + .build(); + + Metadata metadata = Metadata.builder() + .put(IndexMetadata.builder("test").settings(settings(Version.CURRENT)).numberOfShards(1).numberOfReplicas(1)) + .build(); + ClusterState clusterState = ClusterState.builder(ClusterName.CLUSTER_NAME_SETTING.getDefault(Settings.EMPTY)) + .metadata(metadata) + .routingTable(RoutingTable.builder().addAsNew(metadata.index("test")).build()) + .build(); + clusterState = ClusterState.builder(clusterState) + .nodes(DiscoveryNodes.builder().add(newNode("node1")).add(newNode("node2"))) + .build(); + clusterState = allocation.reroute(clusterState, "reroute"); + clusterState = startInitializingShardsAndReroute(allocation, clusterState); + clusterState = startInitializingShardsAndReroute(allocation, clusterState); + + final long baseTime = System.nanoTime(); + allocation.setNanoTimeOverride(baseTime); + clusterState = ClusterState.builder(clusterState).nodes(DiscoveryNodes.builder(clusterState.nodes()).remove("node2")).build(); + clusterState = allocation.disassociateDeadNodes(clusterState, true, "reroute"); + + assertThat(UnassignedInfo.findNextDelayedAllocation(baseTime, clusterState, nodeSettings), equalTo(nodeDelay.nanos())); } public void testAllocationStatusSerialization() throws IOException { @@ -518,4 +697,21 @@ public void testAllocationStatusSerialization() throws IOException { assertThat(readStatus, equalTo(allocationStatus)); } } + + private ClusterState createStartedClusterState(MockAllocationService allocation, Settings clusterSettings, Settings indexSettings) { + Metadata metadata = Metadata.builder() + .persistentSettings(clusterSettings) + .put(IndexMetadata.builder("test").settings(settings(Version.CURRENT).put(indexSettings)).numberOfShards(1).numberOfReplicas(1)) + .build(); + ClusterState clusterState = ClusterState.builder(ClusterName.CLUSTER_NAME_SETTING.getDefault(Settings.EMPTY)) + .metadata(metadata) + .routingTable(RoutingTable.builder().addAsNew(metadata.index("test")).build()) + .build(); + clusterState = ClusterState.builder(clusterState) + .nodes(DiscoveryNodes.builder().add(newNode("node1")).add(newNode("node2"))) + .build(); + clusterState = allocation.reroute(clusterState, "reroute"); + clusterState = startInitializingShardsAndReroute(allocation, clusterState); + return startInitializingShardsAndReroute(allocation, clusterState); + } } diff --git a/server/src/test/java/org/opensearch/cluster/routing/allocation/allocator/BalancedShardsAllocatorSettingsTests.java b/server/src/test/java/org/opensearch/cluster/routing/allocation/allocator/BalancedShardsAllocatorSettingsTests.java new file mode 100644 index 0000000000000..af5364e225b58 --- /dev/null +++ b/server/src/test/java/org/opensearch/cluster/routing/allocation/allocator/BalancedShardsAllocatorSettingsTests.java @@ -0,0 +1,110 @@ +/* + * 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.cluster.routing.allocation.allocator; + +import org.opensearch.common.settings.ClusterSettings; +import org.opensearch.common.settings.Settings; +import org.opensearch.test.OpenSearchTestCase; + +import static org.opensearch.cluster.routing.allocation.allocator.BalancedShardsAllocator.INDEX_BALANCE_FACTOR_SETTING; +import static org.opensearch.cluster.routing.allocation.allocator.BalancedShardsAllocator.SHARD_BALANCE_FACTOR_SETTING; + +public class BalancedShardsAllocatorSettingsTests extends OpenSearchTestCase { + + public void testBothBalanceFactorsZeroIsRejectedOnConstruction() { + final Settings settings = Settings.builder() + .put(INDEX_BALANCE_FACTOR_SETTING.getKey(), "0.0") + .put(SHARD_BALANCE_FACTOR_SETTING.getKey(), "0.0") + .build(); + final IllegalArgumentException e = expectThrows( + IllegalArgumentException.class, + () -> new BalancedShardsAllocator(settings, new ClusterSettings(settings, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS)) + ); + assertThat(e.getMessage(), org.hamcrest.Matchers.containsString("must sum to a value greater than zero")); + } + + public void testBothBalanceFactorsZeroIsRejectedOnDynamicUpdate() { + final ClusterSettings clusterSettings = new ClusterSettings(Settings.EMPTY, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS); + new BalancedShardsAllocator(Settings.EMPTY, clusterSettings); + + final Settings newSettings = Settings.builder() + .put(INDEX_BALANCE_FACTOR_SETTING.getKey(), "0.0") + .put(SHARD_BALANCE_FACTOR_SETTING.getKey(), "0.0") + .build(); + + final IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> clusterSettings.applySettings(newSettings)); + assertThat(e.getMessage(), org.hamcrest.Matchers.containsString("illegal value can't update")); + assertNotNull(e.getCause()); + assertThat(e.getCause().getMessage(), org.hamcrest.Matchers.containsString("must sum to a value greater than zero")); + } + + public void testIndexBalanceZeroWithNonZeroShardBalanceIsAccepted() { + final Settings settings = Settings.builder() + .put(INDEX_BALANCE_FACTOR_SETTING.getKey(), "0.0") + .put(SHARD_BALANCE_FACTOR_SETTING.getKey(), "1.0") + .build(); + final ClusterSettings clusterSettings = new ClusterSettings(settings, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS); + BalancedShardsAllocator allocator = new BalancedShardsAllocator(settings, clusterSettings); + assertEquals(0.0f, allocator.getIndexBalance(), 0.0f); + assertEquals(1.0f, allocator.getShardBalance(), 0.0f); + } + + public void testShardBalanceZeroWithNonZeroIndexBalanceIsAccepted() { + final Settings settings = Settings.builder() + .put(INDEX_BALANCE_FACTOR_SETTING.getKey(), "1.0") + .put(SHARD_BALANCE_FACTOR_SETTING.getKey(), "0.0") + .build(); + final ClusterSettings clusterSettings = new ClusterSettings(settings, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS); + BalancedShardsAllocator allocator = new BalancedShardsAllocator(settings, clusterSettings); + assertEquals(1.0f, allocator.getIndexBalance(), 0.0f); + assertEquals(0.0f, allocator.getShardBalance(), 0.0f); + } + + public void testDynamicUpdateIndexBalanceToZeroWhileShardBalanceAlreadyZeroIsRejected() { + final Settings initialSettings = Settings.builder() + .put(SHARD_BALANCE_FACTOR_SETTING.getKey(), "0.0") + .put(INDEX_BALANCE_FACTOR_SETTING.getKey(), "1.0") + .build(); + final ClusterSettings clusterSettings = new ClusterSettings(initialSettings, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS); + new BalancedShardsAllocator(initialSettings, clusterSettings); + + final Settings newSettings = Settings.builder().put(INDEX_BALANCE_FACTOR_SETTING.getKey(), "0.0").build(); + + final IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> clusterSettings.applySettings(newSettings)); + assertThat(e.getCause().getMessage(), org.hamcrest.Matchers.containsString("must sum to a value greater than zero")); + } + + public void testDynamicUpdateShardBalanceToZeroWhileIndexBalanceAlreadyZeroIsRejected() { + final Settings initialSettings = Settings.builder() + .put(INDEX_BALANCE_FACTOR_SETTING.getKey(), "0.0") + .put(SHARD_BALANCE_FACTOR_SETTING.getKey(), "1.0") + .build(); + final ClusterSettings clusterSettings = new ClusterSettings(initialSettings, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS); + new BalancedShardsAllocator(initialSettings, clusterSettings); + + final Settings newSettings = Settings.builder().put(SHARD_BALANCE_FACTOR_SETTING.getKey(), "0.0").build(); + + final IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> clusterSettings.applySettings(newSettings)); + assertThat(e.getCause().getMessage(), org.hamcrest.Matchers.containsString("must sum to a value greater than zero")); + } + + public void testValidDynamicUpdateIsAccepted() { + final ClusterSettings clusterSettings = new ClusterSettings(Settings.EMPTY, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS); + BalancedShardsAllocator allocator = new BalancedShardsAllocator(Settings.EMPTY, clusterSettings); + + final Settings newSettings = Settings.builder() + .put(INDEX_BALANCE_FACTOR_SETTING.getKey(), "0.3") + .put(SHARD_BALANCE_FACTOR_SETTING.getKey(), "0.7") + .build(); + + clusterSettings.applySettings(newSettings); + assertEquals(0.3f, allocator.getIndexBalance(), 0.001f); + assertEquals(0.7f, allocator.getShardBalance(), 0.001f); + } +} diff --git a/server/src/test/java/org/opensearch/gateway/ReplicaShardAllocatorTests.java b/server/src/test/java/org/opensearch/gateway/ReplicaShardAllocatorTests.java index ae56bc0f8b3d2..f1dc8fb3fd33e 100644 --- a/server/src/test/java/org/opensearch/gateway/ReplicaShardAllocatorTests.java +++ b/server/src/test/java/org/opensearch/gateway/ReplicaShardAllocatorTests.java @@ -52,6 +52,8 @@ import org.opensearch.cluster.routing.ShardRoutingState; import org.opensearch.cluster.routing.TestShardRouting; import org.opensearch.cluster.routing.UnassignedInfo; +import org.opensearch.cluster.routing.allocation.AllocateUnassignedDecision; +import org.opensearch.cluster.routing.allocation.AllocationDecision; import org.opensearch.cluster.routing.allocation.RoutingAllocation; import org.opensearch.cluster.routing.allocation.decider.AllocationDecider; import org.opensearch.cluster.routing.allocation.decider.AllocationDeciders; @@ -446,6 +448,30 @@ public void testDelayedAllocation() { ); } + public void testDelayedAllocationExplainUsesClusterDefault() { + TimeValue clusterDelay = TimeValue.timeValueHours(1); + Settings nodeSettings = Settings.builder() + .put(UnassignedInfo.CLUSTER_DELAYED_NODE_LEFT_TIMEOUT_SETTING.getKey(), clusterDelay) + .build(); + RoutingAllocation allocation = onePrimaryOnNode1And1Replica( + yesAllocationDeciders(), + Settings.EMPTY, + nodeSettings, + UnassignedInfo.Reason.NODE_LEFT + ); + allocation.debugDecision(true); + testAllocator.addData(node1, "MATCH", new StoreFileMetadata("file1", 10, "MATCH_CHECKSUM", MIN_SUPPORTED_LUCENE_VERSION)); + + ShardRouting unassignedShard = allocation.routingNodes().shardsWithState(ShardRoutingState.UNASSIGNED).get(0); + AllocateUnassignedDecision decision = testAllocator.makeAllocationDecision(unassignedShard, allocation, testAllocator.logger); + + assertThat(decision.getAllocationDecision(), equalTo(AllocationDecision.ALLOCATION_DELAYED)); + assertThat(decision.getAllocationStatus(), equalTo(UnassignedInfo.AllocationStatus.DELAYED_ALLOCATION)); + assertThat(decision.getConfiguredDelayInMillis(), equalTo(clusterDelay.millis())); + assertThat(decision.getRemainingDelayInMillis() > 0, equalTo(true)); + assertThat(decision.getRemainingDelayInMillis() <= clusterDelay.millis(), equalTo(true)); + } + public void testCancelRecoveryBetterSyncId() { RoutingAllocation allocation = onePrimaryOnNode1And1ReplicaRecovering(yesAllocationDeciders()); testAllocator.addData(node1, "MATCH", new StoreFileMetadata("file1", 10, "MATCH_CHECKSUM", MIN_SUPPORTED_LUCENE_VERSION)) @@ -548,17 +574,30 @@ private RoutingAllocation onePrimaryOnNode1And1Replica(AllocationDeciders decide return onePrimaryOnNode1And1Replica(deciders, Settings.EMPTY, UnassignedInfo.Reason.CLUSTER_RECOVERED); } - private RoutingAllocation onePrimaryOnNode1And1Replica(AllocationDeciders deciders, Settings settings, UnassignedInfo.Reason reason) { + private RoutingAllocation onePrimaryOnNode1And1Replica( + AllocationDeciders deciders, + Settings indexSettings, + UnassignedInfo.Reason reason + ) { + return onePrimaryOnNode1And1Replica(deciders, indexSettings, Settings.EMPTY, reason); + } + + private RoutingAllocation onePrimaryOnNode1And1Replica( + AllocationDeciders deciders, + Settings indexSettings, + Settings nodeSettings, + UnassignedInfo.Reason reason + ) { ShardRouting primaryShard = TestShardRouting.newShardRouting(shardId, node1.getId(), true, ShardRoutingState.STARTED); IndexMetadata.Builder indexMetadata = IndexMetadata.builder(shardId.getIndexName()) - .settings(settings(Version.CURRENT).put(settings)) + .settings(settings(Version.CURRENT).put(indexSettings)) .numberOfShards(1) .numberOfReplicas(1) .putInSyncAllocationIds(0, Sets.newHashSet(primaryShard.allocationId().getId())); Metadata metadata = Metadata.builder().put(indexMetadata).build(); // mark shard as delayed if reason is NODE_LEFT boolean delayed = reason == UnassignedInfo.Reason.NODE_LEFT - && UnassignedInfo.INDEX_DELAYED_NODE_LEFT_TIMEOUT_SETTING.get(settings).nanos() > 0; + && UnassignedInfo.getNodeLeftDelayedTimeout(indexSettings, nodeSettings).nanos() > 0; int failedAllocations = reason == UnassignedInfo.Reason.ALLOCATION_FAILED ? 1 : 0; RoutingTable routingTable = RoutingTable.builder() .add( @@ -598,7 +637,8 @@ private RoutingAllocation onePrimaryOnNode1And1Replica(AllocationDeciders decide state, ClusterInfo.EMPTY, SnapshotShardSizeInfo.EMPTY, - System.nanoTime() + System.nanoTime(), + nodeSettings ); } diff --git a/server/src/test/java/org/opensearch/index/MergePolicySettingsTests.java b/server/src/test/java/org/opensearch/index/MergePolicySettingsTests.java index 32c4c048d77ba..6745ed57e228b 100644 --- a/server/src/test/java/org/opensearch/index/MergePolicySettingsTests.java +++ b/server/src/test/java/org/opensearch/index/MergePolicySettingsTests.java @@ -34,6 +34,7 @@ import org.apache.lucene.index.LogByteSizeMergePolicy; import org.apache.lucene.index.NoMergePolicy; import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.FeatureFlags; import org.opensearch.core.common.unit.ByteSizeUnit; import org.opensearch.core.common.unit.ByteSizeValue; import org.opensearch.core.index.shard.ShardId; @@ -106,20 +107,19 @@ public void testDefaultMergePolicy() throws IOException { public void testMergePolicyPrecedence() throws IOException { // 1. INDEX_MERGE_POLICY is not set - // assert defaults + // assert defaults to tiered for non-composite-engine indexes IndexSettings indexSettings = indexSettings(EMPTY_SETTINGS); assertTrue(indexSettings.getMergePolicy(false) instanceof OpenSearchTieredMergePolicy); assertTrue(indexSettings.getMergePolicy(true) instanceof OpenSearchTieredMergePolicy); // 1.1 node setting TIME_SERIES_INDEX_MERGE_POLICY is set as log_byte_size - // assert index policy is tiered whereas time series index policy is log_byte_size + // assert index policy is tiered (default, non-composite-engine) and time series index policy is log_byte_size Settings nodeSettings = Settings.builder() .put(IndexSettings.TIME_SERIES_INDEX_MERGE_POLICY.getKey(), IndexSettings.IndexMergePolicy.LOG_BYTE_SIZE.getValue()) .build(); indexSettings = new IndexSettings(newIndexMeta("test", Settings.EMPTY), nodeSettings); assertTrue(indexSettings.getMergePolicy(false) instanceof OpenSearchTieredMergePolicy); assertTrue(indexSettings.getMergePolicy(true) instanceof LogByteSizeMergePolicy); - // 1.2 node setting TIME_SERIES_INDEX_MERGE_POLICY is set as tiered // assert both index and time series index policy is tiered nodeSettings = Settings.builder() @@ -181,6 +181,53 @@ public void testMergePolicyPrecedence() throws IOException { } + /** + * When the pluggable data format (composite engine) is enabled on an index, the default merge policy + * should be LogByteSizeMergePolicy for both regular and time-series indexes. + */ + @OpenSearchTestCase.LockFeatureFlag(FeatureFlags.PLUGGABLE_DATAFORMAT_EXPERIMENTAL_FLAG) + public void testDefaultMergePolicyForCompositeEngineIndex() throws IOException { + Settings compositeEngineSettings = Settings.builder() + .put(IndexSettings.PLUGGABLE_DATAFORMAT_ENABLED_SETTING.getKey(), true) + .build(); + + // no index-level merge policy set — should default to LBS for composite engine indexes + IndexSettings indexSettings = indexSettings(compositeEngineSettings); + assertTrue(indexSettings.isPluggableDataFormatEnabled()); + assertTrue(indexSettings.getMergePolicy(false) instanceof LogByteSizeMergePolicy); + assertTrue(indexSettings.getMergePolicy(true) instanceof LogByteSizeMergePolicy); + } + + @OpenSearchTestCase.LockFeatureFlag(FeatureFlags.PLUGGABLE_DATAFORMAT_EXPERIMENTAL_FLAG) + public void testExplicitTieredPolicyOverridesDefaultForCompositeEngineIndex() throws IOException { + Settings compositeEngineTieredSettings = Settings.builder() + .put(IndexSettings.PLUGGABLE_DATAFORMAT_ENABLED_SETTING.getKey(), true) + .put(IndexSettings.INDEX_MERGE_POLICY.getKey(), IndexSettings.IndexMergePolicy.TIERED.getValue()) + .build(); + + // explicit tiered setting must be honored even for composite engine indexes + IndexSettings indexSettings = indexSettings(compositeEngineTieredSettings); + assertTrue(indexSettings.isPluggableDataFormatEnabled()); + assertTrue(indexSettings.getMergePolicy(false) instanceof OpenSearchTieredMergePolicy); + assertTrue(indexSettings.getMergePolicy(true) instanceof OpenSearchTieredMergePolicy); + } + + @OpenSearchTestCase.LockFeatureFlag(FeatureFlags.PLUGGABLE_DATAFORMAT_EXPERIMENTAL_FLAG) + public void testTimeSeriesNodeSettingTieredOverridesDefaultForCompositeEngineIndex() throws IOException { + Settings compositeEngineSettings = Settings.builder() + .put(IndexSettings.PLUGGABLE_DATAFORMAT_ENABLED_SETTING.getKey(), true) + .build(); + Settings nodeSettings = Settings.builder() + .put(IndexSettings.TIME_SERIES_INDEX_MERGE_POLICY.getKey(), IndexSettings.IndexMergePolicy.TIERED.getValue()) + .build(); + + // node sets tiered for time-series — must be honored even for composite engine indexes + IndexSettings indexSettings = new IndexSettings(newIndexMeta("test", compositeEngineSettings), nodeSettings); + assertTrue(indexSettings.isPluggableDataFormatEnabled()); + assertTrue(indexSettings.getMergePolicy(false) instanceof LogByteSizeMergePolicy); + assertTrue(indexSettings.getMergePolicy(true) instanceof OpenSearchTieredMergePolicy); + } + public void testInvalidMergePolicy() throws IOException { final Settings invalidSettings = Settings.builder().put(IndexSettings.INDEX_MERGE_POLICY.getKey(), "invalid").build(); diff --git a/server/src/test/java/org/opensearch/index/compositeindex/datacube/startree/builder/StarTreeBuilderTestCase.java b/server/src/test/java/org/opensearch/index/compositeindex/datacube/startree/builder/StarTreeBuilderTestCase.java index 5c96cf8333485..91c5149018f37 100644 --- a/server/src/test/java/org/opensearch/index/compositeindex/datacube/startree/builder/StarTreeBuilderTestCase.java +++ b/server/src/test/java/org/opensearch/index/compositeindex/datacube/startree/builder/StarTreeBuilderTestCase.java @@ -166,7 +166,7 @@ public void setup() throws IOException { } writeState = getWriteState(5, UUID.randomUUID().toString().substring(0, 16).getBytes(StandardCharsets.UTF_8)); - mergeState = new MergeState(null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, false); + mergeState = new MergeState(null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, false, null); dataFileName = IndexFileNames.segmentFileName( writeState.segmentInfo.name, diff --git a/server/src/test/java/org/opensearch/index/engine/DataFormatAwareEngineTests.java b/server/src/test/java/org/opensearch/index/engine/DataFormatAwareEngineTests.java index 28a7440828654..a940122e73ca2 100644 --- a/server/src/test/java/org/opensearch/index/engine/DataFormatAwareEngineTests.java +++ b/server/src/test/java/org/opensearch/index/engine/DataFormatAwareEngineTests.java @@ -24,6 +24,7 @@ import org.opensearch.common.settings.Settings; import org.opensearch.common.unit.TimeValue; import org.opensearch.common.util.BigArrays; +import org.opensearch.core.index.AppendOnlyIndexOperationRetryException; import org.opensearch.core.index.Index; import org.opensearch.core.index.shard.ShardId; import org.opensearch.index.IndexModule; @@ -48,6 +49,7 @@ import org.opensearch.index.engine.exec.commit.Committer; import org.opensearch.index.engine.exec.commit.CommitterFactory; import org.opensearch.index.engine.exec.coord.CatalogSnapshot; +import org.opensearch.index.get.DocumentLookupResult; import org.opensearch.index.mapper.DocumentMapper; import org.opensearch.index.mapper.IdFieldMapper; import org.opensearch.index.mapper.MapperService; @@ -59,8 +61,11 @@ import org.opensearch.index.shard.ShardPath; import org.opensearch.index.store.FsDirectoryFactory; import org.opensearch.index.store.Store; +import org.opensearch.index.translog.InternalTranslogManager; import org.opensearch.index.translog.Translog; import org.opensearch.index.translog.TranslogConfig; +import org.opensearch.index.translog.TranslogDeletionPolicy; +import org.opensearch.plugins.DocumentLookupProvider; import org.opensearch.plugins.PluginsService; import org.opensearch.plugins.SearchBackEndPlugin; import org.opensearch.test.DummyShardLock; @@ -69,6 +74,7 @@ import org.opensearch.threadpool.TestThreadPool; import org.opensearch.threadpool.ThreadPool; +import java.io.Closeable; import java.io.IOException; import java.nio.file.Path; import java.util.ArrayList; @@ -79,6 +85,7 @@ import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; @@ -90,7 +97,10 @@ import static org.hamcrest.Matchers.greaterThan; import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.lessThanOrEqualTo; import static org.hamcrest.Matchers.notNullValue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -159,6 +169,10 @@ private Store createStore() throws IOException { * expects to find: translog UUID, seq-no info, history UUID, etc. */ private void bootstrapStoreWithMetadata(Store store, String translogUUID) throws IOException { + bootstrapStoreWithMetadata(store, translogUUID, SequenceNumbers.NO_OPS_PERFORMED); + } + + private void bootstrapStoreWithMetadata(Store store, String translogUUID, long maxSeqNo) throws IOException { try ( IndexWriter writer = new IndexWriter( store.directory(), @@ -169,7 +183,7 @@ private void bootstrapStoreWithMetadata(Store store, String translogUUID) throws Map commitData = new HashMap<>(); commitData.put(Translog.TRANSLOG_UUID_KEY, translogUUID); commitData.put(SequenceNumbers.LOCAL_CHECKPOINT_KEY, Long.toString(SequenceNumbers.NO_OPS_PERFORMED)); - commitData.put(SequenceNumbers.MAX_SEQ_NO, Long.toString(SequenceNumbers.NO_OPS_PERFORMED)); + commitData.put(SequenceNumbers.MAX_SEQ_NO, Long.toString(maxSeqNo)); commitData.put(Engine.MAX_UNSAFE_AUTO_ID_TIMESTAMP_COMMIT_ID, "-1"); commitData.put(Engine.HISTORY_UUID_KEY, UUID.randomUUID().toString()); writer.setLiveCommitData(commitData.entrySet()); @@ -198,12 +212,28 @@ private EngineConfig buildDFAEngineConfig( return buildDFAEngineConfig(store, translogPath, externalListeners, internalListeners, IndexModule.TieringState.HOT.name()); } + /** Builds a HOT-tier DFA {@link EngineConfig} carrying the given {@link DocumentLookupProvider}. */ + private EngineConfig buildDFAEngineConfig(Store store, Path translogPath, DocumentLookupProvider documentLookupProvider) { + return buildDFAEngineConfig(store, translogPath, List.of(), List.of(), IndexModule.TieringState.HOT.name(), documentLookupProvider); + } + private EngineConfig buildDFAEngineConfig( Store store, Path translogPath, List externalListeners, List internalListeners, String tieringState + ) { + return buildDFAEngineConfig(store, translogPath, externalListeners, internalListeners, tieringState, null); + } + + private EngineConfig buildDFAEngineConfig( + Store store, + Path translogPath, + List externalListeners, + List internalListeners, + String tieringState, + DocumentLookupProvider documentLookupProvider ) { IndexSettings indexSettings = IndexSettingsModule.newIndexSettings( "test", @@ -254,6 +284,7 @@ private EngineConfig buildDFAEngineConfig( public void onFailedEngine(String reason, Exception e) {} }) .mapperService(mapperService) + .documentLookupProvider(documentLookupProvider) .build(); } @@ -283,9 +314,67 @@ private Engine.Index indexOp(ParsedDocument doc) { ); } + /** + * Append-only optimizable variant of {@link #indexOp}: a non-negative autoGeneratedIdTimestamp + * with isRetry=false makes {@link IndexingStrategyPlanner} take the optimizedAppendOnly path + * (no safe-access enforcement). + */ + private Engine.Index appendOnlyOp(ParsedDocument doc) { + return new Engine.Index( + new Term(IdFieldMapper.NAME, Uid.encodeId(doc.id())), + doc, + SequenceNumbers.UNASSIGNED_SEQ_NO, + primaryTerm.get(), + Versions.MATCH_ANY, + VersionType.INTERNAL, + Engine.Operation.Origin.PRIMARY, + System.nanoTime(), + System.currentTimeMillis(), + false, + SequenceNumbers.UNASSIGNED_SEQ_NO, + 0 + ); + } + + /** Explicit-id index with a caller-supplied version/versionType (for version-conflict tests). */ + private Engine.Index indexOpWithVersion(ParsedDocument doc, long version, VersionType versionType) { + return new Engine.Index( + new Term(IdFieldMapper.NAME, Uid.encodeId(doc.id())), + doc, + SequenceNumbers.UNASSIGNED_SEQ_NO, + primaryTerm.get(), + version, + versionType, + Engine.Operation.Origin.PRIMARY, + System.nanoTime(), + -1, + false, + SequenceNumbers.UNASSIGNED_SEQ_NO, + 0 + ); + } + + /** Explicit-id index with compare-and-set ifSeqNo/ifPrimaryTerm (for optimistic-concurrency tests). */ + private Engine.Index indexOpWithIfSeqNo(ParsedDocument doc, long ifSeqNo, long ifPrimaryTerm) { + return new Engine.Index( + new Term(IdFieldMapper.NAME, Uid.encodeId(doc.id())), + doc, + SequenceNumbers.UNASSIGNED_SEQ_NO, + primaryTerm.get(), + Versions.MATCH_ANY, + VersionType.INTERNAL, + Engine.Operation.Origin.PRIMARY, + System.nanoTime(), + -1, + false, + ifSeqNo, + ifPrimaryTerm + ); + } + /** * Wraps {@link EngineTestCase#createParsedDoc(String, String)} to attach a - * {@link MockDocumentInput}. {@link DataFormatAwareEngine#indexIntoEngine} requires a + * {@link MockDocumentInput}. {@link DataFormatAwareEngine#index} requires a * non-null {@code DocumentInput} on every doc (it calls {@code addField} for version, * seqNo, primaryTerm), but the base helper leaves that field null because production * code (e.g., {@code IndexShard.applyIndexOperation}) populates it via @@ -327,6 +416,64 @@ public void testSequenceNumbersAssignedOnPrimary() throws IOException { } } + /** + * {@link DataFormatAwareEngine#acquireHistoryRetentionLock()} must return a lock that pins a translog + * generation in the deletion policy for as long as it is held, and releases that generation when closed. + * Peer recovery / primary relocation relies on this to keep translog history available for the duration + * of recovery. + */ + public void testAcquireHistoryRetentionLockPinsTranslogGeneration() throws IOException { + try (DataFormatAwareEngine engine = createDFAEngine(store, createTempDir())) { + int numDocs = randomIntBetween(1, 20); + for (int i = 0; i < numDocs; i++) { + engine.index(indexOp(createParsedDocWithInput(Integer.toString(i), null))); + } + + // The retention lock pins a generation in the translog's deletion policy. + TranslogDeletionPolicy deletionPolicy = ((InternalTranslogManager) engine.translogManager()).getTranslog().getDeletionPolicy(); + // No retention locks are held before acquiring one. + deletionPolicy.assertNoOpenTranslogRefs(); + + Closeable retentionLock = engine.acquireHistoryRetentionLock(); + assertThat(retentionLock, notNullValue()); + // While the lock is held there is an open translog reference pinning a generation. + expectThrows(AssertionError.class, deletionPolicy::assertNoOpenTranslogRefs); + + // Releasing the lock releases the pinned generation. + retentionLock.close(); + deletionPolicy.assertNoOpenTranslogRefs(); + } + } + + /** + * {@link DataFormatAwareEngine#countNumberOfHistoryOperations(String, long, long)} must count only the + * operations whose seqNo falls within the requested {@code [fromSeqNo, toSeqNo]} range, de-duplicated -- + * i.e. the operations actually yielded by the changes snapshot for that range, not the raw operation + * count of the underlying translog generations. + */ + public void testCountNumberOfHistoryOperationsRespectsSeqNoRange() throws IOException { + try (DataFormatAwareEngine engine = createDFAEngine(store, createTempDir())) { + int numDocs = 10; + for (int i = 0; i < numDocs; i++) { + Engine.IndexResult result = engine.index(indexOp(createParsedDocWithInput(Integer.toString(i), null))); + assertThat(result.getSeqNo(), equalTo((long) i)); + } + // Ensure the buffered translog operations are durable and readable by the changes snapshot. + engine.translogManager().syncTranslog(); + + // Full range counts every operation. + assertThat(engine.countNumberOfHistoryOperations("test", 0, Long.MAX_VALUE), equalTo(numDocs)); + + // Sub-ranges count only the operations whose seqNo lies within [fromSeqNo, toSeqNo]. + assertThat(engine.countNumberOfHistoryOperations("test", 5, 9), equalTo(5)); + assertThat(engine.countNumberOfHistoryOperations("test", 0, 4), equalTo(5)); + assertThat(engine.countNumberOfHistoryOperations("test", 3, 6), equalTo(4)); + assertThat(engine.countNumberOfHistoryOperations("test", 7, Long.MAX_VALUE), equalTo(3)); + // A range above the highest seqNo contains no operations. + assertThat(engine.countNumberOfHistoryOperations("test", numDocs, Long.MAX_VALUE), equalTo(0)); + } + } + public void testLocalCheckpointAdvancesCorrectly() throws IOException { try (DataFormatAwareEngine engine = createDFAEngine(store, createTempDir())) { int numDocs = randomIntBetween(5, 15); @@ -2014,8 +2161,9 @@ public void testUnreferencedFileCleanUpsPerformed() throws IOException { /** * Covers {@code DataFormatAwareEngine.applyMergeChanges}: a forceMerge over two * previously-refreshed segments must (1) replace the source segments in the catalog - * with a single merged segment, (2) invoke beforeRefresh/afterRefresh exactly once - * each on registered refresh listeners while holding the refresh lock, and + * with a single merged segment, (2) restrict its beforeRefresh/afterRefresh + * notifications to {@code RemoteStoreRefreshListener} instances only, so a plain + * refresh listener that fires on real refreshes is NOT invoked by the merge, and * (3) release the refresh lock on exit so a subsequent {@code refresh()} proceeds. * *

    The system-property gate on {@code MERGE_ENABLED_PROPERTY} applies only to @@ -2023,7 +2171,7 @@ public void testUnreferencedFileCleanUpsPerformed() throws IOException { * straight to {@code MergeScheduler.forceMerge} and does not consult it, so this * test drives the merge end-to-end without touching system properties. */ - public void testApplyMergeChangesUpdatesCatalogAndNotifiesListeners() throws Exception { + public void testApplyMergeChangesUpdatesCatalogAndSkipsNonRemoteStoreListeners() throws Exception { AtomicInteger beforeCalls = new AtomicInteger(); AtomicInteger afterCalls = new AtomicInteger(); // Records call order: 'B' for beforeRefresh, 'A' for afterRefresh. @@ -2087,12 +2235,17 @@ public void afterRefresh(boolean didRefresh) { } }, 10, java.util.concurrent.TimeUnit.SECONDS); - // applyMergeChanges must have invoked the listeners exactly once each, in order. - assertThat("beforeRefresh must fire exactly once for the merge", beforeCalls.get() - beforeAfterSeed, equalTo(1)); - assertThat("afterRefresh must fire exactly once for the merge", afterCalls.get() - afterAfterSeed, equalTo(1)); + // applyMergeChanges must notify only RemoteStoreRefreshListener instances. The plain + // listener registered here is not one, so the merge must NOT invoke it. + assertThat( + "merge must not invoke beforeRefresh on a non-remote-store listener", + beforeCalls.get() - beforeAfterSeed, + equalTo(0) + ); + assertThat("merge must not invoke afterRefresh on a non-remote-store listener", afterCalls.get() - afterAfterSeed, equalTo(0)); synchronized (callOrder) { - // Seed cycles contribute "BABA"; the merge must append exactly "BA". - assertThat("call order must be before-then-after for every cycle", callOrder.toString(), equalTo("BABABA")); + // Seed cycles contribute "BABA"; the merge must append nothing for this listener. + assertThat("merge must not append before/after for a non-remote-store listener", callOrder.toString(), equalTo("BABA")); } // Sanity: the refreshLock must have been released. A follow-up refresh must @@ -3553,4 +3706,790 @@ public void testOnSettingsChangedUnrecognizedTieringValueNotFrozen() throws IOEx assertEquals(Engine.Result.Type.SUCCESS, result.getResultType()); } } + + public void testDocCountLimitRejectsIndexingAboveMax() throws Exception { + final int maxDocs = randomIntBetween(1, 20); + MockIndexingExecutionEngine limitedEngine = new MockIndexingExecutionEngine(mockDataFormat) { + @Override + public long maxIndexableDocs() { + return maxDocs; + } + }; + MockDataFormatPlugin limitedPlugin = new MockDataFormatPlugin(mockDataFormat) { + @Override + public IndexingExecutionEngine indexingEngine(IndexingEngineConfig settings) { + return limitedEngine; + } + }; + + EngineConfig config = buildFailingEngineConfig(limitedPlugin, new Engine.EventListener() { + @Override + public void onFailedEngine(String reason, Exception failure) {} + }); + try (DataFormatAwareEngine eng = new DataFormatAwareEngine(config)) { + int numDocs = between(maxDocs + 1, maxDocs * 2); + for (int i = 0; i < numDocs; i++) { + final long maxSeqNo = eng.getProcessedLocalCheckpoint(); + Engine.IndexResult result = eng.index(indexOp(createParsedDocWithInput(Integer.toString(i), null))); + if (i < maxDocs) { + assertThat("doc " + i + " should succeed", result.getResultType(), equalTo(Engine.Result.Type.SUCCESS)); + assertNull(result.getFailure()); + assertThat(result.getSeqNo(), greaterThanOrEqualTo(0L)); + } else { + assertThat("doc " + i + " should be rejected", result.getResultType(), equalTo(Engine.Result.Type.FAILURE)); + assertNotNull(result.getFailure()); + assertThat(result.getFailure(), instanceOf(IllegalArgumentException.class)); + assertThat( + result.getFailure().getMessage(), + containsString("Number of documents in shard " + shardId + " exceeds the limit of [" + maxDocs + "]") + ); + assertThat("seq no must not be assigned on rejection", result.getSeqNo(), equalTo(SequenceNumbers.UNASSIGNED_SEQ_NO)); + assertThat("local checkpoint must not advance on rejection", eng.getProcessedLocalCheckpoint(), equalTo(maxSeqNo)); + } + } + // Engine must remain open on primary even after rejections + eng.refresh("verify-still-open"); + } + } + + public void testConcurrentIndexAndRefreshDocCountNeverUnderCounts() throws Exception { + Path translogPath = createTempDir(); + try (DataFormatAwareEngine eng = createDFAEngine(store, translogPath)) { + int numThreads = 4; + int docsPerThread = 50; + int totalDocs = numThreads * docsPerThread; + CyclicBarrier barrier = new CyclicBarrier(numThreads + 1); // +1 for refresh thread + AtomicInteger successCount = new AtomicInteger(); + + Thread[] indexThreads = new Thread[numThreads]; + for (int t = 0; t < numThreads; t++) { + final int threadId = t; + indexThreads[t] = new Thread(() -> { + try { + barrier.await(); + for (int d = 0; d < docsPerThread; d++) { + Engine.IndexResult result = eng.index(indexOp(createParsedDocWithInput(threadId + "_" + d, null))); + if (result.getResultType() == Engine.Result.Type.SUCCESS) { + successCount.incrementAndGet(); + } + } + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + indexThreads[t].start(); + } + + // Refresh thread that runs concurrently with indexing + Thread refreshThread = new Thread(() -> { + try { + barrier.await(); + for (int i = 0; i < 10; i++) { + eng.refresh("concurrent-refresh-" + i); + Thread.sleep(5); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + refreshThread.start(); + + for (Thread t : indexThreads) + t.join(30_000); + refreshThread.join(30_000); + + // Final refresh to flush remaining buffered docs + eng.refresh("final"); + + // Verify: catalogSnapshot docs + pendingRowCount must equal successCount + try (GatedCloseable ref = eng.acquireSnapshot()) { + long catalogDocs = ref.get().getNumDocs(); + assertThat("catalog must contain all successfully indexed docs", catalogDocs, equalTo((long) successCount.get())); + } + } + } + + private DataFormatAwareEngine createDFAEngineWithLookupProvider(Store store, Path translogPath, DocumentLookupProvider provider) + throws IOException { + String uuid = Translog.createEmptyTranslog(translogPath, SequenceNumbers.NO_OPS_PERFORMED, shardId, primaryTerm.get()); + bootstrapStoreWithMetadata(store, uuid); + return new DataFormatAwareEngine(buildDFAEngineConfig(store, translogPath, provider)); + } + + private Engine.Get realtimeGet(String id) { + return new Engine.Get(true, true, id, new Term(IdFieldMapper.NAME, Uid.encodeId(id))); + } + + private DocumentLookupProvider mockLookupProvider() throws IOException { + DocumentLookupProvider provider = mock(DocumentLookupProvider.class); + when(provider.getById(any(), any(), any(), any())).thenReturn(DocumentLookupResult.notFound("")); + when(provider.getVersionMetadata(any(), any(), any(), any())).thenReturn(DocumentLookupResult.notFound("")); + return provider; + } + + public void testGetMaxSeqNoOfUpdatesOrDeletes() throws IOException { + try (DataFormatAwareEngine engine = createDFAEngine(store, createTempDir())) { + assertThat(engine.getMaxSeqNoOfUpdatesOrDeletes(), equalTo(SequenceNumbers.NO_OPS_PERFORMED)); + } + } + + public void testCurrentOngoingRefreshCheckpoint() throws IOException { + try (DataFormatAwareEngine engine = createDFAEngine(store, createTempDir())) { + assertThat(engine.currentOngoingRefreshCheckpoint(), equalTo(SequenceNumbers.NO_OPS_PERFORMED)); + for (int i = 0; i < 5; i++) { + engine.index(indexOp(createParsedDocWithInput(Integer.toString(i), null))); + } + engine.refresh("test"); + assertThat(engine.currentOngoingRefreshCheckpoint(), greaterThanOrEqualTo(0L)); + } + } + + /** Calls the new getById(Engine.Get, searcherFactory) and unwraps the pre-materialized lookup. + * DFA engines ignore the searcher factory, so a no-op factory is supplied. */ + private static DocumentLookupResult getByIdLookup(DataFormatAwareEngine engine, Engine.Get get) throws IOException { + Engine.GetResult result = engine.getById(get, (source, scope) -> null); + return result.exists() ? ((DocumentLookupResult.PreMaterialized) result).lookup() : DocumentLookupResult.notFound(get.id()); + } + + public void testGetByIdThrowsWhenNoProvider() throws IOException { + try (DataFormatAwareEngine engine = createDFAEngine(store, createTempDir())) { + Engine.Get get = realtimeGet("1"); + expectThrows(UnsupportedOperationException.class, () -> getByIdLookup(engine, get)); + } + } + + public void testGetByIdReturnsNotFoundWhenEmptyCatalog() throws IOException { + DocumentLookupProvider provider = mockLookupProvider(); + try (DataFormatAwareEngine engine = createDFAEngineWithLookupProvider(store, createTempDir(), provider)) { + Engine.Get get = realtimeGet("1"); + DocumentLookupResult result = getByIdLookup(engine, get); + assertFalse("should be not found on empty catalog", result.exists()); + } + } + + public void testGetByIdReturnsDocFromTranslog() throws IOException { + DocumentLookupProvider provider = mockLookupProvider(); + try (DataFormatAwareEngine engine = createDFAEngineWithLookupProvider(store, createTempDir(), provider)) { + engine.index(indexOp(createParsedDocWithInput("1", null))); + // Realtime get with readFromTranslog finds it in translog via versionMap location + Engine.Get get = realtimeGet("1"); + DocumentLookupResult result = getByIdLookup(engine, get); + assertTrue("doc should be found from translog", result.exists()); + assertThat(result.seqNo(), equalTo(0L)); + } + } + + public void testGetByIdReturnsDocFromParquetAfterRefresh() throws IOException { + DocumentLookupProvider provider = mock(DocumentLookupProvider.class); + when(provider.getById(any(), any(), any(), any())).thenReturn( + new DocumentLookupResult("1", 1L, true, null, 0L, 1L, Map.of(), Map.of()) + ); + try (DataFormatAwareEngine engine = createDFAEngineWithLookupProvider(store, createTempDir(), provider)) { + engine.index(indexOp(createParsedDocWithInput("1", null))); + engine.refresh("test"); + // Non-realtime get falls through to provider + Engine.Get get = new Engine.Get(false, false, "1", new Term(IdFieldMapper.NAME, Uid.encodeId("1"))); + DocumentLookupResult result = getByIdLookup(engine, get); + assertTrue("doc should be found from parquet", result.exists()); + } + } + + /** + * Covers resolveDocVersion falling back to DocumentLookupProvider when versionMap miss. + * Also covers incrementIndexVersionLookup (called inside resolveDocVersion on provider path). + */ + public void testResolveDocVersionFallsBackToProvider() throws IOException { + DocumentLookupProvider provider = mock(DocumentLookupProvider.class); + when(provider.getVersionMetadata(any(), any(), any(), any())).thenReturn( + new DocumentLookupResult("1", 5L, true, null, 3L, 1L, Map.of(), Map.of()) + ); + try (DataFormatAwareEngine engine = createDFAEngineWithLookupProvider(store, createTempDir(), provider)) { + engine.index(indexOp(createParsedDocWithInput("1", null))); + engine.refresh("test"); + // Re-index same doc — resolveDocVersion misses versionMap (cleared by refresh rotation), + // falls back to provider which returns version=5. MATCH_ANY accepts. + Engine.IndexResult result = engine.index(indexOp(createParsedDocWithInput("1", null))); + assertThat(result.getResultType(), equalTo(Engine.Result.Type.FAILURE)); + assertEquals(result.getFailure().getClass(), AppendOnlyIndexOperationRetryException.class); + } + } + + /** Reflectively obtains the engine's LiveVersionMap. resolveDocVersion/getVersionFromMap assert the + * per-uid keyed lock is held, so callers must wrap in {@code versionMap.acquireLock(uid)}. */ + @SuppressForbidden(reason = "test needs reflective access to the engine's versionMap field") + private LiveVersionMap versionMapOf(DataFormatAwareEngine engine) throws Exception { + java.lang.reflect.Field vmField = DataFormatAwareEngine.class.getDeclaredField("versionMap"); + vmField.setAccessible(true); + return (LiveVersionMap) vmField.get(engine); + } + + /** resolveDocVersion branch 1: versionMap miss with no provider returns null. */ + public void testResolveDocVersionReturnsNullWhenNoProvider() throws Exception { + try (DataFormatAwareEngine engine = createDFAEngine(store, createTempDir())) { + Engine.Index op = indexOp(createParsedDocWithInput("1", null)); + try (org.opensearch.common.lease.Releasable ignored = versionMapOf(engine).acquireLock(op.uid().bytes())) { + assertNull(engine.resolveDocVersion(op, true)); + } + } + } + + /** resolveDocVersion branch 2: versionMap miss with an empty catalog returns null (provider not consulted). */ + public void testResolveDocVersionReturnsNullOnEmptyCatalog() throws Exception { + try (DataFormatAwareEngine engine = createDFAEngineWithLookupProvider(store, createTempDir(), mockLookupProvider())) { + Engine.Index op = indexOp(createParsedDocWithInput("1", null)); + try (org.opensearch.common.lease.Releasable ignored = versionMapOf(engine).acquireLock(op.uid().bytes())) { + assertNull(engine.resolveDocVersion(op, true)); + } + } + } + + /** resolveDocVersion branch 4: versionMap miss, provider consulted, getById returns NOT_FOUND → null. */ + public void testResolveDocVersionReturnsNullWhenProviderNotFound() throws Exception { + try (DataFormatAwareEngine engine = createDFAEngineWithLookupProvider(store, createTempDir(), mockLookupProvider())) { + engine.index(indexOp(createParsedDocWithInput("1", null))); + engine.refresh("test"); + Engine.Index op = indexOp(createParsedDocWithInput("2", null)); + try (org.opensearch.common.lease.Releasable ignored = versionMapOf(engine).acquireLock(op.uid().bytes())) { + assertNull(engine.resolveDocVersion(op, true)); + } + } + } + + /** resolveDocVersion branch 6: versionMap hit returns the stored IndexVersionValue. */ + public void testResolveDocVersionReturnsVersionMapHit() throws Exception { + try (DataFormatAwareEngine engine = createDFAEngine(store, createTempDir())) { + engine.index(indexOp(createParsedDocWithInput("1", null))); + Engine.Index op = indexOp(createParsedDocWithInput("1", null)); + VersionValue vv; + try (org.opensearch.common.lease.Releasable ignored = versionMapOf(engine).acquireLock(op.uid().bytes())) { + vv = engine.resolveDocVersion(op, true); + } + assertNotNull(vv); + assertThat(vv.version, equalTo(1L)); + assertThat(vv.seqNo, equalTo(0L)); + } + } + + /** + * Covers restoreVersionMapAndCheckpointTracker + compareOpToVersionMapOnSeqNo. + * The provider returns docs for seqNos 0-3 so checkpoint advances contiguously. + */ + public void testRestoreVersionMapAndCheckpointTracker() throws IOException { + Path translogPath = createTempDir(); + DocumentLookupProvider provider = mock(DocumentLookupProvider.class); + when(provider.getDocsAboveSeqNo(anyLong(), any(), any(), any())).thenReturn( + List.of( + new DocumentLookupResult("a", 1L, true, null, 0L, 1L, Map.of(), Map.of()), + new DocumentLookupResult("b", 1L, true, null, 1L, 1L, Map.of(), Map.of()), + new DocumentLookupResult("c", 1L, true, null, 2L, 1L, Map.of(), Map.of()), + new DocumentLookupResult("d", 1L, true, null, 3L, 1L, Map.of(), Map.of()) + ) + ); + when(provider.getVersionMetadata(any(), any(), any(), any())).thenReturn(DocumentLookupResult.notFound("x")); + + // Bootstrap with maxSeqNo=3, localCheckpoint=-1 (triggers restore) + String uuid = Translog.createEmptyTranslog(translogPath, SequenceNumbers.NO_OPS_PERFORMED, shardId, primaryTerm.get()); + bootstrapStoreWithMetadata(store, uuid, 3L); + + EngineConfig config = buildDFAEngineConfig(store, translogPath, provider); + try (DataFormatAwareEngine engine = new DataFormatAwareEngine(config)) { + // All seqNos 0-3 marked → checkpoint advances to 3 + assertThat(engine.getProcessedLocalCheckpoint(), equalTo(3L)); + } + } + + /** + * Covers compareOpToVersionMapOnSeqNo returning OP_STALE_OR_EQUAL. + * Provider returns entries for id "x" at seqNo=2 and seqNo=5, plus a duplicate at seqNo=5. + * The stale entry (seqNo=2) is overwritten by OP_NEWER, the duplicate (same seqNo=5) is skipped. + */ + public void testRestoreVersionMapSkipsStaleEntries() throws IOException { + Path translogPath = createTempDir(); + DocumentLookupProvider provider = mock(DocumentLookupProvider.class); + // Return all seqNos 0-5 so checkpoint advances, with duplicate id "x" at seqNo 2 and 5 + when(provider.getDocsAboveSeqNo(anyLong(), any(), any(), any())).thenReturn( + List.of( + new DocumentLookupResult("a", 1L, true, null, 0L, 1L, Map.of(), Map.of()), + new DocumentLookupResult("b", 1L, true, null, 1L, 1L, Map.of(), Map.of()), + new DocumentLookupResult("x", 1L, true, null, 2L, 1L, Map.of(), Map.of()), + new DocumentLookupResult("c", 1L, true, null, 3L, 1L, Map.of(), Map.of()), + new DocumentLookupResult("d", 1L, true, null, 4L, 1L, Map.of(), Map.of()), + new DocumentLookupResult("x", 2L, true, null, 5L, 1L, Map.of(), Map.of()), + // Duplicate id "x" at same seqNo=5 → hits seqNo == versionValue.seqNo → OP_STALE_OR_EQUAL + new DocumentLookupResult("x", 2L, true, null, 5L, 1L, Map.of(), Map.of()) + ) + ); + when(provider.getVersionMetadata(any(), any(), any(), any())).thenReturn(DocumentLookupResult.notFound("x")); + + String uuid = Translog.createEmptyTranslog(translogPath, SequenceNumbers.NO_OPS_PERFORMED, shardId, primaryTerm.get()); + bootstrapStoreWithMetadata(store, uuid, 5L); + + EngineConfig config = buildDFAEngineConfig(store, translogPath, provider); + try (DataFormatAwareEngine engine = new DataFormatAwareEngine(config)) { + // All seqNos 0-5 contiguous → checkpoint = 5 + assertThat(engine.getProcessedLocalCheckpoint(), equalTo(5L)); + // Index "x" again — should succeed (versionMap has version=2 from seqNo=5 entry) + Engine.IndexResult result = engine.index(indexOp(createParsedDocWithInput("x", null))); + assertThat(result.getResultType(), equalTo(Engine.Result.Type.FAILURE)); + assertEquals(result.getFailure().getClass(), AppendOnlyIndexOperationRetryException.class); + } + } + + public void testRestoreVersionMapThrowsOnIOException() throws IOException { + Path translogPath = createTempDir(); + DocumentLookupProvider provider = mock(DocumentLookupProvider.class); + when(provider.getDocsAboveSeqNo(anyLong(), any(), any(), any())).thenThrow(new IOException("simulated read failure")); + + String uuid = Translog.createEmptyTranslog(translogPath, SequenceNumbers.NO_OPS_PERFORMED, shardId, primaryTerm.get()); + bootstrapStoreWithMetadata(store, uuid, 3L); + + EngineConfig config = buildDFAEngineConfig(store, translogPath, provider); + EngineCreationFailureException ex = expectThrows(EngineCreationFailureException.class, () -> new DataFormatAwareEngine(config)); + assertThat(ex.getMessage(), containsString("failed to restore version map")); + assertThat(ex.getCause(), instanceOf(IOException.class)); + } + + /** + * Covers getVersionFromMap unsafe path: versionMap.isUnsafe() triggers refresh + enforceSafeAccess. + */ + @SuppressForbidden(reason = "test needs reflective access to mark versionMap as unsafe") + public void testGetVersionFromMapUnsafePathTriggersRefresh() throws Exception { + DocumentLookupProvider provider = mockLookupProvider(); + try (DataFormatAwareEngine engine = createDFAEngineWithLookupProvider(store, createTempDir(), provider)) { + engine.index(indexOp(createParsedDocWithInput("1", null))); + + java.lang.reflect.Field vmField = DataFormatAwareEngine.class.getDeclaredField("versionMap"); + vmField.setAccessible(true); + LiveVersionMap versionMap = (LiveVersionMap) vmField.get(engine); + java.lang.reflect.Field mapsField = LiveVersionMap.class.getDeclaredField("maps"); + mapsField.setAccessible(true); + Object maps = mapsField.get(versionMap); + java.lang.reflect.Field currentField = maps.getClass().getDeclaredField("current"); + currentField.setAccessible(true); + Object current = currentField.get(maps); + java.lang.reflect.Method markUnsafe = current.getClass().getDeclaredMethod("markAsUnsafe"); + markUnsafe.setAccessible(true); + markUnsafe.invoke(current); + + assertTrue("versionMap should be unsafe", versionMap.isUnsafe()); + + long genBefore; + try (GatedCloseable ref = engine.acquireSnapshot()) { + genBefore = ref.get().getGeneration(); + } + + getByIdLookup(engine, realtimeGet("1")); + + long genAfter; + try (GatedCloseable ref = engine.acquireSnapshot()) { + genAfter = ref.get().getGeneration(); + } + assertThat("refresh should have been triggered by unsafe versionMap", genAfter, greaterThan(genBefore)); + assertFalse("versionMap should be safe after refresh", versionMap.isUnsafe()); + } + } + + /** + * DFAE port of InternalEngineTests.testVersionMapAfterAutoIDDocument (delete step dropped — + * engine.delete() is unsupported in this harness). Verifies LiveVersionMap safe-access + * transitions: optimized append-only skips the map; an explicit-id index enforces safe access + * and stores the entry; safe access is carried over across refresh. + */ + @SuppressForbidden(reason = "test needs reflective access to the engine's versionMap field") + public void testVersionMapAfterAutoIDDocument() throws Exception { + try (DataFormatAwareEngine engine = createDFAEngine(store, createTempDir())) { + java.lang.reflect.Field vmField = DataFormatAwareEngine.class.getDeclaredField("versionMap"); + vmField.setAccessible(true); + LiveVersionMap versionMap = (LiveVersionMap) vmField.get(engine); + + // Optimized append-only: no safe access enforced, nothing stored in the current map. + engine.index(appendOnlyOp(createParsedDocWithInput("1", null))); + assertFalse(versionMap.isSafeAccessRequired()); + assertTrue(versionMap.getAllCurrent().isEmpty()); + + // Explicit-id index of the same id: enforces safe access and stores the entry. + engine.index(indexOp(createParsedDocWithInput("1", null))); + assertTrue(versionMap.isSafeAccessRequired()); + assertEquals(1, versionMap.getAllCurrent().size()); + + // Refresh rotates the map; safe access is carried over. + engine.refresh("test"); + assertTrue(versionMap.isSafeAccessRequired()); + + // Append-only under active safe access still stores the entry. + engine.index(appendOnlyOp(createParsedDocWithInput("2", null))); + assertEquals(1, versionMap.getAllCurrent().size()); + } + } + + /** + * A: write-path version conflict resolved from the versionMap. EXTERNAL version=5 then a stale + * EXTERNAL version=3 hits isVersionConflictForWrites (IndexingStrategyPlanner) → conflict. + */ + public void testIndexVersionConflictFromVersionMap() throws IOException { + try (DataFormatAwareEngine engine = createDFAEngine(store, createTempDir())) { + Engine.IndexResult first = engine.index(indexOpWithVersion(createParsedDocWithInput("1", null), 5L, VersionType.EXTERNAL)); + assertThat(first.getResultType(), equalTo(Engine.Result.Type.SUCCESS)); + Engine.IndexResult stale = engine.index(indexOpWithVersion(createParsedDocWithInput("1", null), 3L, VersionType.EXTERNAL)); + assertThat(stale.getResultType(), equalTo(Engine.Result.Type.FAILURE)); + assertThat(stale.getFailure(), instanceOf(VersionConflictEngineException.class)); + } + } + + /** + * B: optimistic-concurrency conflict on the write path. After indexing id "1" (seqNo 0), an index + * with a non-matching ifSeqNo hits the seqNo/term conflict branch (IndexingStrategyPlanner) → conflict. + */ + public void testIndexIfSeqNoConflict() throws IOException { + try (DataFormatAwareEngine engine = createDFAEngine(store, createTempDir())) { + engine.index(indexOp(createParsedDocWithInput("1", null))); + Engine.IndexResult result = engine.index(indexOpWithIfSeqNo(createParsedDocWithInput("1", null), 99L, primaryTerm.get())); + assertThat(result.getResultType(), equalTo(Engine.Result.Type.FAILURE)); + assertThat(result.getFailure(), instanceOf(VersionConflictEngineException.class)); + } + } + + /** + * C: optimized append-only ops never enter safe-access mode — they leave the current map empty + * and mark it unsafe (LiveVersionMap.maybePutIndexUnderLock else-branch). + */ + @SuppressForbidden(reason = "test needs reflective access to the engine's versionMap field") + public void testAppendOnlyMarksVersionMapUnsafe() throws Exception { + try (DataFormatAwareEngine engine = createDFAEngine(store, createTempDir())) { + java.lang.reflect.Field vmField = DataFormatAwareEngine.class.getDeclaredField("versionMap"); + vmField.setAccessible(true); + LiveVersionMap versionMap = (LiveVersionMap) vmField.get(engine); + + int n = randomIntBetween(2, 5); + for (int i = 0; i < n; i++) { + engine.index(appendOnlyOp(createParsedDocWithInput(Integer.toString(i), null))); + } + assertFalse(versionMap.isSafeAccessRequired()); + assertTrue(versionMap.getAllCurrent().isEmpty()); + assertTrue(versionMap.isUnsafe()); + } + } + + /** + * Covers getById paths: delete check, version conflict, seqNo/primaryTerm conflict. + * Uses reflection to inject a DeleteVersionValue into versionMap since engine.delete() is unsupported. + */ + @SuppressForbidden(reason = "test needs reflective access to inject DeleteVersionValue into versionMap") + public void testGetByIdDeleteAndConflictPaths() throws Exception { + DocumentLookupProvider provider = mockLookupProvider(); + try (DataFormatAwareEngine engine = createDFAEngineWithLookupProvider(store, createTempDir(), provider)) { + // Index a doc so versionMap has an IndexVersionValue + engine.index(indexOp(createParsedDocWithInput("1", null))); + + // --- Path 1: versionValue.isDelete() --- + // Inject a DeleteVersionValue into versionMap via reflection + java.lang.reflect.Field vmField = DataFormatAwareEngine.class.getDeclaredField("versionMap"); + vmField.setAccessible(true); + LiveVersionMap versionMap = (LiveVersionMap) vmField.get(engine); + org.apache.lucene.util.BytesRef uid = new Term(IdFieldMapper.NAME, Uid.encodeId("1")).bytes(); + try (org.opensearch.common.lease.Releasable ignored = versionMap.acquireLock(uid)) { + versionMap.putDeleteUnderLock(uid, new DeleteVersionValue(2L, 1L, 1L, System.currentTimeMillis())); + } + + Engine.Get getForDelete = realtimeGet("1"); + DocumentLookupResult deleteResult = getByIdLookup(engine, getForDelete); + assertFalse("deleted doc should return not found", deleteResult.exists()); + + // --- Path 2: version conflict for reads --- + // Put back an IndexVersionValue so we can test version conflict + try (org.opensearch.common.lease.Releasable ignored = versionMap.acquireLock(uid)) { + versionMap.putIndexUnderLock(uid, new IndexVersionValue(null, 5L, 0L, 1L)); + } + + Engine.Get getWithConflict = realtimeGet("1").version(3L).versionType(VersionType.EXTERNAL); + expectThrows(VersionConflictEngineException.class, () -> getByIdLookup(engine, getWithConflict)); + + // --- Path 3: seqNo/primaryTerm conflict (seqNo matches, primaryTerm doesn't) --- + Engine.Get getWithSeqNoConflict = realtimeGet("1").setIfSeqNo(99L).setIfPrimaryTerm(1L); + expectThrows(VersionConflictEngineException.class, () -> getByIdLookup(engine, getWithSeqNoConflict)); + + // --- Path 4: primaryTerm mismatch (seqNo matches but primaryTerm doesn't) --- + // versionMap has seqNo=0, primaryTerm=1 from the IndexVersionValue above + try (org.opensearch.common.lease.Releasable ignored = versionMap.acquireLock(uid)) { + versionMap.putIndexUnderLock(uid, new IndexVersionValue(null, 5L, 0L, 1L)); + } + Engine.Get getWithPtConflict = realtimeGet("1").setIfSeqNo(0L).setIfPrimaryTerm(999L); + expectThrows(VersionConflictEngineException.class, () -> getByIdLookup(engine, getWithPtConflict)); + + // --- Path 5: seqNo and primaryTerm both match — no conflict, falls through --- + Engine.Get getNoConflict = realtimeGet("1").setIfSeqNo(0L).setIfPrimaryTerm(1L); + DocumentLookupResult noConflictResult = getByIdLookup(engine, getNoConflict); + assertNotNull("should not throw when seqNo and primaryTerm match", noConflictResult); + + // --- Path 6: GC deletes in resolveDocVersion --- + // Inject an expired DeleteVersionValue (time far in the past) so gc_deletes nullifies it + try (org.opensearch.common.lease.Releasable ignored = versionMap.acquireLock(uid)) { + versionMap.putDeleteUnderLock(uid, new DeleteVersionValue(3L, 2L, 1L, 1L)); // time=1ms (epoch start, expired) + } + // Index same doc again — resolveDocVersion finds expired DeleteVersionValue, nullifies it, + // treats as new doc (version NOT_FOUND) + Engine.IndexResult gcResult = engine.index(indexOp(createParsedDocWithInput("1", null))); + assertThat(gcResult.getResultType(), equalTo(Engine.Result.Type.SUCCESS)); + + // --- Path 7: translog readOperation returns null (bogus location) --- + // Covers the negative path: if (operation != null) is false, falls through to refreshIfNeeded + Translog.Location bogusLocation = new Translog.Location(999L, 0L, 1); + try (org.opensearch.common.lease.Releasable ignored = versionMap.acquireLock(uid)) { + versionMap.putIndexUnderLock(uid, new IndexVersionValue(bogusLocation, 6L, 3L, 1L)); + } + Engine.Get getWithBadTranslog = realtimeGet("1"); + DocumentLookupResult translogNullResult = getByIdLookup(engine, getWithBadTranslog); + assertNotNull("should fall through when translog returns null", translogNullResult); + } + } + + /** + * Tests that engine close is graceful when concurrent index, refresh, and flush operations + * are in flight. Verifies no unhandled exceptions escape and the engine transitions to closed. + */ + public void testGracefulCloseUnderConcurrentLoad() throws Exception { + try (Store store = createStore()) { + DataFormatAwareEngine engine = createDFAEngine(store, createTempDir()); + + final AtomicBoolean stop = new AtomicBoolean(false); + final AtomicReference failure = new AtomicReference<>(); + final CountDownLatch started = new CountDownLatch(3); + + // Thread 1: continuous indexing + Thread indexThread = new Thread(() -> { + started.countDown(); + int i = 0; + while (stop.get() == false) { + try { + engine.index(indexOp(createParsedDocWithInput(Integer.toString(i++), null))); + } catch (AlreadyClosedException e) { + break; // expected during close + } catch (Exception e) { + if (stop.get() == false) { + failure.compareAndSet(null, e); + } + break; + } + } + }); + + // Thread 2: continuous refresh + Thread refreshThread = new Thread(() -> { + started.countDown(); + while (stop.get() == false) { + try { + engine.refresh("concurrent-test"); + } catch (AlreadyClosedException e) { + break; + } catch (Exception e) { + if (stop.get() == false) { + failure.compareAndSet(null, e); + } + break; + } + } + }); + + // Thread 3: periodic flush + Thread flushThread = new Thread(() -> { + started.countDown(); + while (stop.get() == false) { + try { + engine.flush(false, true); + Thread.sleep(10); + } catch (AlreadyClosedException e) { + break; + } catch (FlushFailedEngineException e) { + // flush may be disabled during translog recovery or after close + break; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } catch (Exception e) { + if (stop.get() == false) { + failure.compareAndSet(null, e); + } + break; + } + } + }); + + indexThread.start(); + refreshThread.start(); + flushThread.start(); + + // Wait for all threads to start + assertTrue(started.await(5, TimeUnit.SECONDS)); + + // Let them run briefly + Thread.sleep(200); + + // Close the engine while operations are in-flight + stop.set(true); + engine.close(); + + // Wait for threads to finish + indexThread.join(10_000); + refreshThread.join(10_000); + flushThread.join(10_000); + + assertFalse("Index thread should have stopped", indexThread.isAlive()); + assertFalse("Refresh thread should have stopped", refreshThread.isAlive()); + assertFalse("Flush thread should have stopped", flushThread.isAlive()); + + // No unexpected exceptions + if (failure.get() != null) { + throw new AssertionError("Unexpected exception during concurrent close", failure.get()); + } + } + } + + /** + * Reproduces the composite checkpoint-inflation data-loss bug (the one behind the + * doc-count gap observed after a primary relocation under sustained ingest). + * + *

    Mechanism: {@code flush()} calls {@code refresh("flush")} — which snapshots the + * writers frozen at {@code checkoutAll} — and then commits + * {@code LOCAL_CHECKPOINT = getProcessedCheckpoint()} read after that refresh. If a + * document is processed during the flush (after the snapshot is frozen but before the + * checkpoint is read) it lands in a NEW writer that is not in the snapshot, yet it advances + * the processed checkpoint. The commit therefore records a checkpoint that is AHEAD of what + * the durable snapshot actually contains. On recovery, replay starts at {@code committed+1} + * and the band in between is seeded as "already processed", so it is never replayed — the + * acknowledged docs are silently lost. + * + *

    We inject the in-window document via an {@code afterRefresh} listener (it fires + * synchronously at the end of {@code refresh()}, i.e. after the snapshot is built but, when + * called from inside {@code flush()}, before {@code flush()} reads the checkpoint). We then + * assert the engine-level invariant the fix must restore: the committed + * {@code LOCAL_CHECKPOINT} must NOT exceed the highest seqno durably persisted in the + * snapshot. + */ + public void testFlushMustNotCommitCheckpointAheadOfPersistedSnapshot() throws Exception { + Path translogPath = createTempDir(); + String uuid = Translog.createEmptyTranslog(translogPath, SequenceNumbers.NO_OPS_PERFORMED, shardId, primaryTerm.get()); + bootstrapStoreWithMetadata(store, uuid); + InMemoryCommitter committer = new InMemoryCommitter(store); + + final AtomicReference engineRef = new AtomicReference<>(); + final AtomicBoolean armed = new AtomicBoolean(false); + final AtomicBoolean fired = new AtomicBoolean(false); + final List injectedSeqNos = new ArrayList<>(); + + // Fires at the END of refresh() — after the catalog snapshot has been built, but + // (when called from inside flush()) before flush() reads getProcessedCheckpoint(). + ReferenceManager.RefreshListener injector = new ReferenceManager.RefreshListener() { + @Override + public void beforeRefresh() {} + + @Override + public void afterRefresh(boolean didRefresh) { + if (armed.get() && fired.compareAndSet(false, true)) { + DataFormatAwareEngine eng = engineRef.get(); + try { + for (int i = 10; i <= 12; i++) { + Engine.IndexResult r = eng.index(indexOp(createParsedDocWithInput(Integer.toString(i), null))); + injectedSeqNos.add(r.getSeqNo()); + } + } catch (IOException e) { + throw new java.io.UncheckedIOException(e); + } + } + } + }; + + IndexSettings indexSettings = IndexSettingsModule.newIndexSettings( + "test", + Settings.builder() + .put(IndexMetadata.SETTING_VERSION_CREATED, Version.CURRENT) + .put(IndexSettings.INDEX_SOFT_DELETES_SETTING.getKey(), true) + .put(IndexSettings.PLUGGABLE_DATAFORMAT_ENABLED_SETTING.getKey(), true) + .put(IndexSettings.PLUGGABLE_DATAFORMAT_VALUE_SETTING.getKey(), mockDataFormat.name()) + .build() + ); + TranslogConfig translogConfig = new TranslogConfig( + shardId, + translogPath, + indexSettings, + BigArrays.NON_RECYCLING_INSTANCE, + "", + false + ); + MapperService mapperService = mock(MapperService.class); + when(mapperService.getIndexSettings()).thenReturn(indexSettings); + DocumentMapper documentMapper = mock(DocumentMapper.class); + when(documentMapper.getVersion()).thenReturn(1L); + when(mapperService.documentMapper()).thenReturn(documentMapper); + EngineConfig config = new EngineConfig.Builder().shardId(shardId) + .threadPool(threadPool) + .indexSettings(indexSettings) + .store(store) + .mergePolicy(NoMergePolicy.INSTANCE) + .translogConfig(translogConfig) + .flushMergesAfter(TimeValue.timeValueMinutes(5)) + .externalRefreshListener(List.of(injector)) + .internalRefreshListener(List.of()) + .globalCheckpointSupplier(() -> SequenceNumbers.NO_OPS_PERFORMED) + .retentionLeasesSupplier(() -> RetentionLeases.EMPTY) + .primaryTermSupplier(primaryTerm::get) + .tombstoneDocSupplier(tombstoneDocSupplier()) + .dataFormatRegistry(createMockRegistry()) + .committerFactory(c -> committer) + .eventListener(new Engine.EventListener() { + @Override + public void onFailedEngine(String reason, Exception e) {} + }) + .mapperService(mapperService) + .build(); + + try (DataFormatAwareEngine engine = new DataFormatAwareEngine(config)) { + engineRef.set(engine); + engine.translogManager().recoverFromTranslog(ignore -> 0, engine.getProcessedLocalCheckpoint(), Long.MAX_VALUE); + + // Pre-flush docs: seqnos 0..9 — captured by the flush's refresh snapshot. + for (int i = 0; i < 10; i++) { + engine.index(indexOp(createParsedDocWithInput(Integer.toString(i), null))); + } + assertThat("processed checkpoint before flush", engine.getProcessedLocalCheckpoint(), equalTo(9L)); + + // Arm the injector: during flush()'s refresh, seqnos 10,11,12 get indexed into a NEW + // writer (after the snapshot is frozen) but before the commit reads the checkpoint. + armed.set(true); + engine.flush(false, true); + + assertThat("injector ran inside the flush window", injectedSeqNos, equalTo(List.of(10L, 11L, 12L))); + assertThat("all 13 ops were processed/acked", engine.getProcessedLocalCheckpoint(), equalTo(12L)); + + // What the commit DURABLY persisted: only seqnos 0..9 made it into the snapshot. + long persistedRows; + try (GatedCloseable ref = engine.acquireSnapshot()) { + persistedRows = ref.get() + .getSegments() + .stream() + .mapToLong(s -> s.dfGroupedSearchableFiles().get(mockDataFormat.name()).numRows()) + .sum(); + } + assertThat("snapshot durably persisted only the pre-flush docs [0..9]", persistedRows, equalTo(10L)); + final long persistedMaxSeqNo = persistedRows - 1; // contiguous 0..9 -> max seqno 9 + + // What the commit CLAIMS is durable. + long committedCheckpoint = Long.parseLong(committer.getLastCommittedData().get(SequenceNumbers.LOCAL_CHECKPOINT_KEY)); + + // INVARIANT the fix must restore: committed LOCAL_CHECKPOINT must not exceed the max + // seqno actually present in the committed snapshot. Otherwise recovery replays from + // committedCheckpoint+1 and silently drops (persistedMaxSeqNo, committedCheckpoint]. + assertThat( + "committed LOCAL_CHECKPOINT (" + + committedCheckpoint + + ") must not exceed the max seqno durably persisted in the snapshot (" + + persistedMaxSeqNo + + "); recovery would otherwise skip and lose seqnos " + + (persistedMaxSeqNo + 1) + + ".." + + committedCheckpoint, + committedCheckpoint, + lessThanOrEqualTo(persistedMaxSeqNo) + ); + } + } } diff --git a/server/src/test/java/org/opensearch/index/engine/DataFormatAwareReadOnlyEngineTests.java b/server/src/test/java/org/opensearch/index/engine/DataFormatAwareReadOnlyEngineTests.java index 9ebba95a6b7e8..a6a0e709f217d 100644 --- a/server/src/test/java/org/opensearch/index/engine/DataFormatAwareReadOnlyEngineTests.java +++ b/server/src/test/java/org/opensearch/index/engine/DataFormatAwareReadOnlyEngineTests.java @@ -36,6 +36,7 @@ import org.opensearch.index.engine.exec.coord.CatalogSnapshot; import org.opensearch.index.engine.exec.coord.CatalogSnapshotManager; import org.opensearch.index.engine.exec.coord.DataformatAwareCatalogSnapshot; +import org.opensearch.index.get.DocumentLookupResult; import org.opensearch.index.seqno.RetentionLeases; import org.opensearch.index.seqno.SequenceNumbers; import org.opensearch.index.shard.ShardPath; @@ -43,6 +44,7 @@ import org.opensearch.index.store.Store; import org.opensearch.index.translog.Translog; import org.opensearch.index.translog.TranslogConfig; +import org.opensearch.plugins.DocumentLookupProvider; import org.opensearch.plugins.PluginsService; import org.opensearch.plugins.SearchBackEndPlugin; import org.opensearch.test.DummyShardLock; @@ -69,6 +71,7 @@ import static org.opensearch.index.engine.EngineTestCase.tombstoneDocSupplier; import static org.hamcrest.Matchers.instanceOf; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -844,4 +847,138 @@ public void testNoOpDeletionPolicyNeverDeletesSnapshots() { assertTrue("onInit with empty list should return empty", policy.onInit(List.of()).isEmpty()); assertTrue("onCommit with empty list should return empty", policy.onCommit(List.of()).isEmpty()); } + + // ---------- getById Tests ---------- + + private Engine.Get realtimeGet(String id) { + return new Engine.Get(true, true, id, new org.apache.lucene.index.Term("_id", org.opensearch.index.mapper.Uid.encodeId(id))); + } + + private DataFormatAwareReadOnlyEngine createReadOnlyEngineWithProvider(org.opensearch.plugins.DocumentLookupProvider provider) + throws IOException { + String translogUUID = UUID.randomUUID().toString(); + String historyUUID = UUID.randomUUID().toString(); + bootstrapStoreWithMetadata(store, translogUUID, historyUUID); + Path translogPath = createTempDir().resolve("translog"); + TranslogConfig translogConfig = new TranslogConfig( + shardId, + translogPath, + warmIndexSettings(), + BigArrays.NON_RECYCLING_INSTANCE, + "", + false + ); + DataFormatRegistry registry = createMockRegistry(); + CommitterFactory committerFactory = config -> new InMemoryCommitter(store) { + @Override + public List listCommittedSnapshots() { + Map userData = new HashMap<>(); + userData.put(SequenceNumbers.LOCAL_CHECKPOINT_KEY, Long.toString(SequenceNumbers.NO_OPS_PERFORMED)); + userData.put(SequenceNumbers.MAX_SEQ_NO, Long.toString(SequenceNumbers.NO_OPS_PERFORMED)); + userData.put(Translog.TRANSLOG_UUID_KEY, UUID.randomUUID().toString()); + userData.put(Engine.HISTORY_UUID_KEY, UUID.randomUUID().toString()); + DataformatAwareCatalogSnapshot snapshot = (DataformatAwareCatalogSnapshot) CatalogSnapshotManager.createInitialSnapshot( + 0L, + 0L, + 0L, + List.of(new Segment(1L, Map.of())), + -1L, + userData + ); + snapshot.setLastCommitInfo("segments_1", 1L, 0L); + return List.of(snapshot); + } + }; + EngineConfig config = new EngineConfig.Builder().shardId(shardId) + .threadPool(threadPool) + .indexSettings(warmIndexSettings()) + .store(store) + .mergePolicy(org.apache.lucene.index.NoMergePolicy.INSTANCE) + .translogConfig(translogConfig) + .flushMergesAfter(TimeValue.timeValueMinutes(5)) + .externalRefreshListener(List.of()) + .internalRefreshListener(List.of()) + .globalCheckpointSupplier(() -> SequenceNumbers.NO_OPS_PERFORMED) + .retentionLeasesSupplier(() -> RetentionLeases.EMPTY) + .primaryTermSupplier(primaryTerm::get) + .tombstoneDocSupplier(tombstoneDocSupplier()) + .dataFormatRegistry(registry) + .committerFactory(committerFactory) + .readOnlyReplica(false) + .build(); + return new DataFormatAwareReadOnlyEngine(config, provider); + } + + /** Calls the new getById(Engine.Get, searcherFactory) and unwraps the pre-materialized lookup. + * DFA engines ignore the searcher factory, so a no-op factory is supplied. */ + private static DocumentLookupResult getByIdLookup(DataFormatAwareReadOnlyEngine engine, Engine.Get get) throws IOException { + Engine.GetResult result = engine.getById(get, (source, scope) -> null); + return result.exists() ? ((DocumentLookupResult.PreMaterialized) result).lookup() : DocumentLookupResult.notFound(get.id()); + } + + public void testGetByIdThrowsWhenNoProvider() throws IOException { + try (DataFormatAwareReadOnlyEngine engine = createReadOnlyEngine()) { + Engine.Get get = realtimeGet("1"); + expectThrows(UnsupportedOperationException.class, () -> getByIdLookup(engine, get)); + } + } + + public void testGetByIdReturnsNotFoundOnEmptyCatalog() throws IOException { + DocumentLookupProvider provider = mock(DocumentLookupProvider.class); + String translogUUID = UUID.randomUUID().toString(); + String historyUUID = UUID.randomUUID().toString(); + bootstrapStoreWithMetadata(store, translogUUID, historyUUID); + EngineConfig config = buildConfig(store, warmIndexSettings(), null); + try (DataFormatAwareReadOnlyEngine engine = new DataFormatAwareReadOnlyEngine(config, provider)) { + Engine.Get get = realtimeGet("1"); + DocumentLookupResult result = getByIdLookup(engine, get); + assertFalse("should return not found when catalog has no segments", result.exists()); + } + } + + public void testGetByIdReturnsDocWhenNoConflict() throws IOException { + DocumentLookupProvider provider = mock(DocumentLookupProvider.class); + when(provider.getById(any(), any(), any(), any())).thenReturn( + new DocumentLookupResult("1", 1L, true, null, 0L, 1L, Map.of(), Map.of()) + ); + try (DataFormatAwareReadOnlyEngine engine = createReadOnlyEngineWithProvider(provider)) { + Engine.Get get = realtimeGet("1"); + DocumentLookupResult result = getByIdLookup(engine, get); + assertTrue("doc should be found", result.exists()); + assertEquals(1L, result.version()); + } + } + + public void testGetByIdThrowsVersionConflictForReads() throws IOException { + DocumentLookupProvider provider = mock(DocumentLookupProvider.class); + when(provider.getById(any(), any(), any(), any())).thenReturn( + new DocumentLookupResult("1", 5L, true, null, 0L, 1L, Map.of(), Map.of()) + ); + try (DataFormatAwareReadOnlyEngine engine = createReadOnlyEngineWithProvider(provider)) { + Engine.Get get = realtimeGet("1").version(3L).versionType(org.opensearch.index.VersionType.EXTERNAL); + expectThrows(VersionConflictEngineException.class, () -> getByIdLookup(engine, get)); + } + } + + public void testGetByIdThrowsOnSeqNoMismatch() throws IOException { + DocumentLookupProvider provider = mock(DocumentLookupProvider.class); + when(provider.getById(any(), any(), any(), any())).thenReturn( + new DocumentLookupResult("1", 1L, true, null, 5L, 1L, Map.of(), Map.of()) + ); + try (DataFormatAwareReadOnlyEngine engine = createReadOnlyEngineWithProvider(provider)) { + Engine.Get get = realtimeGet("1").setIfSeqNo(99L).setIfPrimaryTerm(1L); + expectThrows(VersionConflictEngineException.class, () -> getByIdLookup(engine, get)); + } + } + + public void testGetByIdThrowsOnPrimaryTermMismatch() throws IOException { + DocumentLookupProvider provider = mock(DocumentLookupProvider.class); + when(provider.getById(any(), any(), any(), any())).thenReturn( + new DocumentLookupResult("1", 1L, true, null, 5L, 1L, Map.of(), Map.of()) + ); + try (DataFormatAwareReadOnlyEngine engine = createReadOnlyEngineWithProvider(provider)) { + Engine.Get get = realtimeGet("1").setIfSeqNo(5L).setIfPrimaryTerm(99L); + expectThrows(VersionConflictEngineException.class, () -> getByIdLookup(engine, get)); + } + } } diff --git a/server/src/test/java/org/opensearch/index/engine/DocumentCountTrackerTests.java b/server/src/test/java/org/opensearch/index/engine/DocumentCountTrackerTests.java new file mode 100644 index 0000000000000..2c0fea8f7bbf5 --- /dev/null +++ b/server/src/test/java/org/opensearch/index/engine/DocumentCountTrackerTests.java @@ -0,0 +1,84 @@ +/* + * 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.index.engine; + +import org.opensearch.core.index.shard.ShardId; +import org.opensearch.index.engine.Engine.Operation; +import org.opensearch.index.seqno.SequenceNumbers; +import org.opensearch.test.OpenSearchTestCase; + +import java.util.concurrent.atomic.AtomicLong; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class DocumentCountTrackerTests extends OpenSearchTestCase { + + private static final ShardId SHARD_ID = new ShardId("test", "_na_", 0); + + private Operation mockPrimaryOp() { + Operation op = mock(Operation.class); + when(op.origin()).thenReturn(Operation.Origin.PRIMARY); + when(op.seqNo()).thenReturn(SequenceNumbers.UNASSIGNED_SEQ_NO); + return op; + } + + public void testAcquireWithinLimitReturnsNull() { + DocumentCountTracker tracker = new DocumentCountTracker(SHARD_ID, () -> 0L, 100); + assertNull(tracker.tryAcquireInFlightDocs(mockPrimaryOp(), 10)); + assertEquals(10, tracker.getInFlightDocCount()); + } + + public void testAcquireExceedingLimitReturnsException() { + DocumentCountTracker tracker = new DocumentCountTracker(SHARD_ID, () -> 90L, 100); + Exception ex = tracker.tryAcquireInFlightDocs(mockPrimaryOp(), 20); + assertNotNull(ex); + assertTrue(ex instanceof IllegalArgumentException); + assertTrue(ex.getMessage().contains("exceeds the limit")); + // Should have rolled back + assertEquals(0, tracker.getInFlightDocCount()); + } + + public void testAcquireExactlyAtLimitSucceeds() { + DocumentCountTracker tracker = new DocumentCountTracker(SHARD_ID, () -> 90L, 100); + assertNull(tracker.tryAcquireInFlightDocs(mockPrimaryOp(), 10)); + assertEquals(10, tracker.getInFlightDocCount()); + } + + public void testReleaseDecrementsCount() { + DocumentCountTracker tracker = new DocumentCountTracker(SHARD_ID, () -> 0L, 100); + tracker.tryAcquireInFlightDocs(mockPrimaryOp(), 5); + assertEquals(5, tracker.getInFlightDocCount()); + tracker.releaseInFlightDocs(3); + assertEquals(2, tracker.getInFlightDocCount()); + } + + public void testMultipleAcquiresAccumulate() { + DocumentCountTracker tracker = new DocumentCountTracker(SHARD_ID, () -> 0L, 100); + assertNull(tracker.tryAcquireInFlightDocs(mockPrimaryOp(), 30)); + assertNull(tracker.tryAcquireInFlightDocs(mockPrimaryOp(), 30)); + assertNull(tracker.tryAcquireInFlightDocs(mockPrimaryOp(), 30)); + assertEquals(90, tracker.getInFlightDocCount()); + // Next one should fail (90 in-flight + 0 indexed + 20 = 110 > 100) + Exception ex = tracker.tryAcquireInFlightDocs(mockPrimaryOp(), 20); + assertNotNull(ex); + assertEquals(90, tracker.getInFlightDocCount()); + } + + public void testIndexedDocsCountedAgainstLimit() { + AtomicLong indexedDocs = new AtomicLong(50); + DocumentCountTracker tracker = new DocumentCountTracker(SHARD_ID, indexedDocs::get, 100); + // 50 indexed + 40 in-flight = 90, OK + assertNull(tracker.tryAcquireInFlightDocs(mockPrimaryOp(), 40)); + // 50 indexed + 40 in-flight + 20 = 110, exceeds + Exception ex = tracker.tryAcquireInFlightDocs(mockPrimaryOp(), 20); + assertNotNull(ex); + assertEquals(40, tracker.getInFlightDocCount()); + } +} diff --git a/server/src/test/java/org/opensearch/index/engine/dataformat/merge/MergeHandlerTests.java b/server/src/test/java/org/opensearch/index/engine/dataformat/merge/MergeHandlerTests.java index 35ac62fee0803..ba9e8947074cd 100644 --- a/server/src/test/java/org/opensearch/index/engine/dataformat/merge/MergeHandlerTests.java +++ b/server/src/test/java/org/opensearch/index/engine/dataformat/merge/MergeHandlerTests.java @@ -224,4 +224,91 @@ public void testDoMerge_DelegatesToMerger() throws Exception { assertSame(result, actual); verify(merger).merge(any(MergeInput.class)); } + + /** + * Verifies that {@code findForceMerges} excludes segments that are already being merged + * by a background merge. Without this fix, a concurrent force merge + background merge + * could pick the same segments, causing the Lucene secondary to merge fewer docs than + * parquet (row count mismatch). + */ + public void testFindForceMergesExcludesAlreadyMergingSegments() throws Exception { + Segment s1 = seg(1); + Segment s2 = seg(2); + Segment s3 = seg(3); + Segment s4 = seg(4); + + MergeHandler handler = newHandler(snapshotWith(s1, s2, s3, s4)); + + // Simulate merge policy returning all segments as force merge candidates + when(mergePolicy.findForceMergeCandidates(any(), any(Integer.class))).thenReturn(List.of(List.of(s1, s2, s3, s4))); + + // Register a background merge for segments [s1, s2] — marks them as currently merging + OneMerge backgroundMerge = new OneMerge(List.of(s1, s2)); + handler.registerMerge(backgroundMerge); + + // Now findForceMerges should exclude s1 and s2 since they're already merging + var forceMerges = handler.findForceMerges(1); + + // The force merge group [s1, s2, s3, s4] contains merging segments → should be filtered out + assertTrue("Force merge should not pick segments already being merged by background merge", forceMerges.isEmpty()); + } + + /** + * Verifies that {@code findForceMerges} returns candidates when no segments overlap + * with currently merging segments. + */ + public void testFindForceMergesReturnsNonConflictingCandidates() throws Exception { + Segment s1 = seg(1); + Segment s2 = seg(2); + Segment s3 = seg(3); + Segment s4 = seg(4); + + MergeHandler handler = newHandler(snapshotWith(s1, s2, s3, s4)); + + // Merge policy returns two groups: [s1, s2] and [s3, s4] + when(mergePolicy.findForceMergeCandidates(any(), any(Integer.class))).thenReturn(List.of(List.of(s1, s2), List.of(s3, s4))); + + // Register background merge for [s1, s2] + OneMerge backgroundMerge = new OneMerge(List.of(s1, s2)); + handler.registerMerge(backgroundMerge); + + // findForceMerges should return only the non-conflicting group [s3, s4] + var forceMerges = handler.findForceMerges(1); + + assertEquals("Should return 1 non-conflicting merge group", 1, forceMerges.size()); + OneMerge remaining = forceMerges.iterator().next(); + assertEquals(2, remaining.getSegmentsToMerge().size()); + assertTrue(remaining.getSegmentsToMerge().contains(s3)); + assertTrue(remaining.getSegmentsToMerge().contains(s4)); + } + + /** + * Verifies that {@code findForceMerges} does NOT register conflicting merge groups + * in currentlyMergingSegments. Only non-conflicting groups should be registered. + */ + public void testFindForceMergesOnlyRegistersNonConflictingGroups() throws Exception { + Segment s1 = seg(1); + Segment s2 = seg(2); + Segment s3 = seg(3); + + MergeHandler handler = newHandler(snapshotWith(s1, s2, s3)); + + // Merge policy returns two groups: [s1, s2] (will conflict) and [s3] (won't conflict) + when(mergePolicy.findForceMergeCandidates(any(), any(Integer.class))).thenReturn(List.of(List.of(s1, s2), List.of(s3))); + + // Register background merge for s1 — makes [s1, s2] group conflicting + handler.registerMerge(new OneMerge(List.of(s1))); + + // findForceMerges should only return and register [s3] + var forceMerges = handler.findForceMerges(1); + assertEquals(1, forceMerges.size()); + + // s2 should NOT be stuck in currentlyMergingSegments + // Verify by registering a merge containing s2 — should succeed + OneMerge s2Merge = new OneMerge(List.of(s2)); + handler.registerMerge(s2Merge); + // background s1 is pending (from registerMerge), force s3 is NOT pending (only in currentlyMerging), + // newly registered s2 is pending → 2 pending total + assertEquals(2, handler.getPendingMergeCount()); + } } diff --git a/server/src/test/java/org/opensearch/index/engine/dataformat/merge/MergeSchedulerOnDrainedTests.java b/server/src/test/java/org/opensearch/index/engine/dataformat/merge/MergeSchedulerOnDrainedTests.java index 27709d31f1a9f..b9f1bb64cb4f8 100644 --- a/server/src/test/java/org/opensearch/index/engine/dataformat/merge/MergeSchedulerOnDrainedTests.java +++ b/server/src/test/java/org/opensearch/index/engine/dataformat/merge/MergeSchedulerOnDrainedTests.java @@ -14,6 +14,7 @@ import org.opensearch.core.index.shard.ShardId; import org.opensearch.index.IndexSettings; import org.opensearch.index.engine.dataformat.MergeResult; +import org.opensearch.index.engine.exec.Segment; import org.opensearch.storage.action.tiering.MergeDrainTimeoutException; import org.opensearch.test.IndexSettingsModule; import org.opensearch.test.OpenSearchTestCase; @@ -570,4 +571,112 @@ public void testConcurrentOnDrained_AllListenersFireWhenAlreadyDrained() throws assertNull("concurrent onDrained() must not throw", error.get()); assertEquals("every listener must fire inline when already drained", numThreads, fired.get()); } + + /** + * Verifies that an in-flight force merge blocks {@code onDrained} from firing immediately. + * The drain listener must only fire after the force merge completes. This tests the fix for + * the tiering race condition where {@code onMergesDrained} would fire immediately during + * a running force merge because {@code activeMerges} was never incremented. + */ + public void testOnDrained_BlockedByInFlightForceMerge() throws Exception { + MergeHandler mockHandler = mock(MergeHandler.class); + when(mockHandler.hasPendingMerges()).thenReturn(false); + + Segment s1 = new Segment(1L, Collections.emptyMap()); + OneMerge oneMerge = new OneMerge(Collections.singletonList(s1)); + when(mockHandler.findForceMerges(1)).thenReturn(Collections.singletonList(oneMerge)); + + // doMerge blocks until we release the latch — simulates a long-running merge + CountDownLatch mergeStarted = new CountDownLatch(1); + CountDownLatch mergeCanProceed = new CountDownLatch(1); + when(mockHandler.doMerge(oneMerge)).thenAnswer(invocation -> { + mergeStarted.countDown(); + mergeCanProceed.await(10, TimeUnit.SECONDS); + return new MergeResult(Collections.emptyMap()); + }); + + IndexSettings indexSettings = IndexSettingsModule.newIndexSettings("test", Settings.EMPTY); + ShardId testShardId = new ShardId(indexSettings.getIndex(), 0); + MergeScheduler scheduler = new MergeScheduler(mockHandler, (result, merge) -> {}, () -> {}, testShardId, indexSettings, threadPool); + + // Start force merge on a FORCE_MERGE-named thread (required by assertion) + AtomicReference forceMergeError = new AtomicReference<>(); + Thread forceMergeThread = new Thread(() -> { + try { + scheduler.forceMerge(1); + } catch (Exception e) { + forceMergeError.set(e); + } + }, ThreadPool.Names.FORCE_MERGE + "-test"); + forceMergeThread.setDaemon(true); + forceMergeThread.start(); + + // Wait for merge to actually start + assertTrue("Force merge should have started", mergeStarted.await(5, TimeUnit.SECONDS)); + + // Now freeze and try to drain — should NOT fire immediately + scheduler.freeze(); + AtomicBoolean drainListenerFired = new AtomicBoolean(false); + scheduler.onDrained(() -> drainListenerFired.set(true)); + + // Drain listener must NOT have fired — force merge is still running + assertFalse("onDrained listener must NOT fire while force merge is in-flight", drainListenerFired.get()); + + // Verify activeMerges reflects the running force merge + assertEquals("activeMerges should be 1 during force merge", 1, scheduler.getActiveMergeCount()); + + // Now let the merge complete + mergeCanProceed.countDown(); + forceMergeThread.join(10_000); + assertNull("forceMerge should complete without error", forceMergeError.get()); + + // After force merge completes, drain listener should have fired + assertTrue("onDrained listener must fire after force merge completes", drainListenerFired.get()); + assertEquals("activeMerges should be 0 after force merge", 0, scheduler.getActiveMergeCount()); + } + + /** + * Verifies that {@code getActiveMergeCount()} correctly includes a running force merge. + * Before the fix, force merges never incremented {@code activeMerges}, making them invisible + * to the tiering drain mechanism. + */ + public void testGetActiveMergeCount_IncludesForceMerge() throws Exception { + MergeHandler mockHandler = mock(MergeHandler.class); + when(mockHandler.hasPendingMerges()).thenReturn(false); + + Segment s1 = new Segment(1L, Collections.emptyMap()); + OneMerge oneMerge = new OneMerge(Collections.singletonList(s1)); + when(mockHandler.findForceMerges(1)).thenReturn(Collections.singletonList(oneMerge)); + + CountDownLatch mergeStarted = new CountDownLatch(1); + CountDownLatch mergeCanProceed = new CountDownLatch(1); + when(mockHandler.doMerge(oneMerge)).thenAnswer(invocation -> { + mergeStarted.countDown(); + mergeCanProceed.await(10, TimeUnit.SECONDS); + return new MergeResult(Collections.emptyMap()); + }); + + IndexSettings indexSettings = IndexSettingsModule.newIndexSettings("test", Settings.EMPTY); + ShardId testShardId = new ShardId(indexSettings.getIndex(), 0); + MergeScheduler scheduler = new MergeScheduler(mockHandler, (result, merge) -> {}, () -> {}, testShardId, indexSettings, threadPool); + + assertEquals("activeMerges should be 0 before force merge", 0, scheduler.getActiveMergeCount()); + + Thread forceMergeThread = new Thread(() -> { + try { + scheduler.forceMerge(1); + } catch (Exception e) { + // ignore + } + }, ThreadPool.Names.FORCE_MERGE + "-test"); + forceMergeThread.setDaemon(true); + forceMergeThread.start(); + + assertTrue("Merge should have started", mergeStarted.await(5, TimeUnit.SECONDS)); + assertEquals("activeMerges should be 1 during force merge", 1, scheduler.getActiveMergeCount()); + + mergeCanProceed.countDown(); + forceMergeThread.join(10_000); + assertEquals("activeMerges should be 0 after force merge", 0, scheduler.getActiveMergeCount()); + } } diff --git a/server/src/test/java/org/opensearch/index/engine/dataformat/merge/MergeSchedulerTests.java b/server/src/test/java/org/opensearch/index/engine/dataformat/merge/MergeSchedulerTests.java index 51713660a24c6..a5939653303f2 100644 --- a/server/src/test/java/org/opensearch/index/engine/dataformat/merge/MergeSchedulerTests.java +++ b/server/src/test/java/org/opensearch/index/engine/dataformat/merge/MergeSchedulerTests.java @@ -15,11 +15,18 @@ import org.opensearch.core.index.shard.ShardId; import org.opensearch.index.IndexModule; import org.opensearch.index.IndexSettings; +import org.opensearch.index.engine.dataformat.MergeResult; +import org.opensearch.index.engine.exec.Segment; import org.opensearch.test.IndexSettingsModule; import org.opensearch.test.OpenSearchTestCase; import org.opensearch.threadpool.TestThreadPool; import org.opensearch.threadpool.ThreadPool; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -114,4 +121,57 @@ public void testIsNotFrozenForHotTieringState() { MergeScheduler scheduler = newScheduler(mergeHandler, IndexModule.TieringState.HOT); assertFalse("HOT tiering state must not report frozen", scheduler.isFrozen()); } + + public void testForceMergeSkipsAfterShutdown() throws IOException { + MergeHandler mergeHandler = mock(MergeHandler.class); + MergeScheduler scheduler = newScheduler(mergeHandler, IndexModule.TieringState.HOT); + + scheduler.shutdown(); + + String oldName = Thread.currentThread().getName(); + Thread.currentThread().setName("TEST-" + ThreadPool.Names.FORCE_MERGE + "-0"); + try { + scheduler.forceMerge(1); + } finally { + Thread.currentThread().setName(oldName); + } + + verify(mergeHandler, never()).findForceMerges(anyInt()); + } + + public void testForceMergeAbortsRemainingMergesOnShutdown() throws Exception { + MergeHandler mergeHandler = mock(MergeHandler.class); + + Segment s1 = new Segment(1L, Map.of()); + Segment s2 = new Segment(2L, Map.of()); + OneMerge merge1 = new OneMerge(List.of(s1)); + OneMerge merge2 = new OneMerge(List.of(s2)); + + when(mergeHandler.findForceMerges(1)).thenReturn(List.of(merge1, merge2)); + when(mergeHandler.doMerge(merge1)).thenReturn(new MergeResult(Map.of())); + + final java.util.concurrent.atomic.AtomicReference schedulerRef = + new java.util.concurrent.atomic.AtomicReference<>(); + + MergeScheduler scheduler = new MergeScheduler( + mergeHandler, + (result, merge) -> { schedulerRef.get().shutdown(); }, + () -> {}, + shardId, + indexSettings(IndexModule.TieringState.HOT), + threadPool + ); + schedulerRef.set(scheduler); + + String oldName = Thread.currentThread().getName(); + Thread.currentThread().setName("TEST-" + ThreadPool.Names.FORCE_MERGE + "-0"); + try { + scheduler.forceMerge(1); + } finally { + Thread.currentThread().setName(oldName); + } + + verify(mergeHandler).doMerge(merge1); + verify(mergeHandler, never()).doMerge(merge2); + } } diff --git a/server/src/test/java/org/opensearch/index/engine/exec/coord/DataformatAwareCatalogSnapshotTests.java b/server/src/test/java/org/opensearch/index/engine/exec/coord/DataformatAwareCatalogSnapshotTests.java index 8a1c94256ebea..4b3d02da99604 100644 --- a/server/src/test/java/org/opensearch/index/engine/exec/coord/DataformatAwareCatalogSnapshotTests.java +++ b/server/src/test/java/org/opensearch/index/engine/exec/coord/DataformatAwareCatalogSnapshotTests.java @@ -406,6 +406,47 @@ public void testClonePreservesUserData() { assertEquals(5L, cloned.getVersion()); } + // --- findFileSet --- + + public void testFindFileSet_matchesGeneration() { + WriterFileSet pset = new WriterFileSet("/tmp/pq", 5L, Set.of("f.parquet"), 100, 0L); + DataformatAwareCatalogSnapshot snapshot = new DataformatAwareCatalogSnapshot( + 1L, + 1L, + 1L, + List.of(new Segment(5L, Map.of("parquet", pset))), + 0L, + Map.of() + ); + assertEquals(pset, snapshot.findFileSet("parquet", 5L)); + } + + public void testFindFileSet_noMatchingGeneration_returnsNull() { + WriterFileSet pset = new WriterFileSet("/tmp/pq", 5L, Set.of("f.parquet"), 100, 0L); + DataformatAwareCatalogSnapshot snapshot = new DataformatAwareCatalogSnapshot( + 1L, + 1L, + 1L, + List.of(new Segment(5L, Map.of("parquet", pset))), + 0L, + Map.of() + ); + assertNull(snapshot.findFileSet("parquet", 99L)); + } + + public void testFindFileSet_skipsEmptyFiles_returnsNull() { + WriterFileSet empty = new WriterFileSet("/tmp/pq", 5L, Set.of(), 0, 0L); + DataformatAwareCatalogSnapshot snapshot = new DataformatAwareCatalogSnapshot( + 1L, + 1L, + 1L, + List.of(new Segment(5L, Map.of("parquet", empty))), + 0L, + Map.of() + ); + assertNull(snapshot.findFileSet("parquet", 5L)); + } + // --- helpers --- private WriterFileSet randomWriterFileSet(String format) { diff --git a/server/src/test/java/org/opensearch/index/fielddomain/FieldDomainParserRegistryTests.java b/server/src/test/java/org/opensearch/index/fielddomain/FieldDomainParserRegistryTests.java new file mode 100644 index 0000000000000..d194f06f8a4d4 --- /dev/null +++ b/server/src/test/java/org/opensearch/index/fielddomain/FieldDomainParserRegistryTests.java @@ -0,0 +1,256 @@ +/* + * 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.index.fielddomain; + +import org.opensearch.test.OpenSearchTestCase; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.sameInstance; + +public class FieldDomainParserRegistryTests extends OpenSearchTestCase { + public void testDefaultRegistryContainsDateRangeParser() { + assertTrue(FieldDomainParserRegistry.defaultRegistry().contains(DateRangeFieldDomain.TYPE)); + } + + public void testReturnsCorrectType() { + FieldDomainParserRegistry registry = new FieldDomainParserRegistry( + List.of(FieldDomainParserRegistry.entry(FieldDomain.class, new TestParser("test"))) + ); + + assertTrue(registry.contains("test")); + } + + public void testReturnsEmptyForUnknownType() { + FieldDomainParserRegistry registry = new FieldDomainParserRegistry( + List.of(FieldDomainParserRegistry.entry(FieldDomain.class, new TestParser("test"))) + ); + + assertFalse(registry.contains("unknown")); + assertTrue(registry.fromCustomData("unknown", "field", Map.of(), "fields.field.").isEmpty()); + } + + public void testFromCustomDataReturnsEmptyWhenRegisteredParserReturnsEmpty() { + FieldDomainParserRegistry registry = new FieldDomainParserRegistry( + List.of(FieldDomainParserRegistry.entry(FieldDomain.class, new TestParser("test"))) + ); + + assertTrue(registry.fromCustomData("test", "field", Map.of(), "fields.field.").isEmpty()); + } + + public void testRejectsDuplicateParserTypes() { + IllegalArgumentException exception = expectThrows( + IllegalArgumentException.class, + () -> new FieldDomainParserRegistry( + List.of( + FieldDomainParserRegistry.entry(FieldDomain.class, new TestParser("test")), + FieldDomainParserRegistry.entry(FieldDomain.class, new TestParser("test")) + ) + ) + ); + + assertThat(exception.getMessage(), equalTo("field domain parser [test] is already registered")); + } + + public void testRejectsNullParserListOrParser() { + expectThrows(NullPointerException.class, () -> new FieldDomainParserRegistry(null)); + expectThrows(NullPointerException.class, () -> new FieldDomainParserRegistry(java.util.Collections.singletonList(null))); + } + + public void testEntryRejectsNullDomainClassOrParser() { + expectThrows(NullPointerException.class, () -> FieldDomainParserRegistry.entry(null, new TypedTestParser("test"))); + expectThrows(NullPointerException.class, () -> FieldDomainParserRegistry.entry(TypedTestDomain.class, null)); + } + + public void testFromCustomDataDelegatesToRegisteredEntry() { + TypedTestDomain domain = new TypedTestDomain("event.ingested", "test"); + FieldDomainParserRegistry registry = new FieldDomainParserRegistry( + List.of(FieldDomainParserRegistry.entry(TypedTestDomain.class, new TypedTestParser("test", Optional.of(domain)))) + ); + + Optional maybeDomain = registry.fromCustomData( + "test", + "event.ingested", + Map.of("fields.event.ingested.value", "value"), + "fields.event.ingested." + ); + + assertTrue(maybeDomain.isPresent()); + assertThat(maybeDomain.get(), sameInstance(domain)); + } + + public void testWriteToCustomDataDelegatesToRegisteredEntry() { + FieldDomainParserRegistry registry = new FieldDomainParserRegistry( + List.of(FieldDomainParserRegistry.entry(TypedTestDomain.class, new TypedTestParser("test"))) + ); + Map targetCustomData = new HashMap<>(); + + registry.writeToCustomData(new TypedTestDomain("event.ingested", "test"), targetCustomData, "fields.event.ingested."); + + assertThat(targetCustomData.get("fields.event.ingested.written"), equalTo("event.ingested")); + } + + public void testWriteToCustomDataRejectsUnknownType() { + FieldDomainParserRegistry registry = new FieldDomainParserRegistry(List.of()); + + IllegalArgumentException exception = expectThrows( + IllegalArgumentException.class, + () -> registry.writeToCustomData(new TypedTestDomain("event.ingested", "unknown"), new HashMap<>(), "fields.event.ingested.") + ); + + assertThat(exception.getMessage(), equalTo("unsupported field domain type [unknown]")); + } + + public void testWriteToCustomDataRejectsMismatchedDomainClass() { + FieldDomainParserRegistry registry = new FieldDomainParserRegistry( + List.of(FieldDomainParserRegistry.entry(TypedTestDomain.class, new TypedTestParser("test"))) + ); + + IllegalArgumentException exception = expectThrows( + IllegalArgumentException.class, + () -> registry.writeToCustomData(new OtherTestDomain("event.ingested", "test"), new HashMap<>(), "fields.event.ingested.") + ); + + assertThat(exception.getMessage(), containsString("is not supported by parser [test]")); + } + + public void testRemoveFieldKeysDelegatesToAllRegisteredParsers() { + FieldDomainParserRegistry registry = new FieldDomainParserRegistry( + List.of( + FieldDomainParserRegistry.entry(FieldDomain.class, new TestParser("alpha")), + FieldDomainParserRegistry.entry(FieldDomain.class, new TestParser("beta")) + ) + ); + Map target = new HashMap<>(); + target.put("fields.event.alpha.owned", "removed"); + target.put("fields.event.beta.owned", "removed"); + target.put("fields.event.unowned", "preserved"); + + registry.removeFieldKeys(target, "fields.event."); + + assertFalse(target.containsKey("fields.event.alpha.owned")); + assertFalse(target.containsKey("fields.event.beta.owned")); + assertThat(target.get("fields.event.unowned"), equalTo("preserved")); + } + + private static final class TestParser implements FieldDomainParser { + private final String type; + + private TestParser(String type) { + this.type = type; + } + + @Override + public String type() { + return type; + } + + @Override + public Optional fromCustomData(String field, Map customData, String prefix) { + return Optional.empty(); + } + + @Override + public void writeToCustomData(FieldDomain domain, Map targetCustomData, String prefix) {} + + @Override + public void removeFieldKeys(Map target, String prefix) { + target.remove(prefix + type + ".owned"); + } + } + + private static final class TypedTestParser implements FieldDomainParser { + private final String type; + private final Optional domain; + + private TypedTestParser(String type) { + this(type, Optional.empty()); + } + + private TypedTestParser(String type, Optional domain) { + this.type = type; + this.domain = domain; + } + + @Override + public String type() { + return type; + } + + @Override + public Optional fromCustomData(String field, Map customData, String prefix) { + return domain; + } + + @Override + public void writeToCustomData(TypedTestDomain domain, Map targetCustomData, String prefix) { + targetCustomData.put(prefix + "written", domain.field()); + } + + @Override + public void removeFieldKeys(Map target, String prefix) { + target.remove(prefix + "written"); + } + } + + private static final class TypedTestDomain implements FieldDomain { + private final String field; + private final String type; + + private TypedTestDomain(String field, String type) { + this.field = field; + this.type = type; + } + + @Override + public String field() { + return field; + } + + @Override + public String type() { + return type; + } + + @Override + public boolean finalized() { + return true; + } + } + + private static final class OtherTestDomain implements FieldDomain { + private final String field; + private final String type; + + private OtherTestDomain(String field, String type) { + this.field = field; + this.type = type; + } + + @Override + public String field() { + return field; + } + + @Override + public String type() { + return type; + } + + @Override + public boolean finalized() { + return true; + } + } +} diff --git a/server/src/test/java/org/opensearch/index/fielddomain/IndexFieldDomainMetadataTests.java b/server/src/test/java/org/opensearch/index/fielddomain/IndexFieldDomainMetadataTests.java new file mode 100644 index 0000000000000..96dc7a99b5e87 --- /dev/null +++ b/server/src/test/java/org/opensearch/index/fielddomain/IndexFieldDomainMetadataTests.java @@ -0,0 +1,379 @@ +/* + * 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.index.fielddomain; + +import org.opensearch.Version; +import org.opensearch.cluster.metadata.IndexMetadata; +import org.opensearch.common.settings.Settings; +import org.opensearch.test.OpenSearchTestCase; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.instanceOf; + +public class IndexFieldDomainMetadataTests extends OpenSearchTestCase { + private static final IndexFieldDomainMetadata METADATA = IndexFieldDomainMetadata.getInstance(); + + public void testCustomMetadataKeyIsStable() { + assertThat(IndexFieldDomainMetadata.CUSTOM_KEY, equalTo("index_field_domains")); + } + + public void testFromCustomDataReadsDateRangeDomain() { + Map customData = Map.of( + "fields.event.ingested.type", + "date_range", + "fields.event.ingested.min", + "1714521600000", + "fields.event.ingested.max", + "1717200000000", + "fields.event.ingested.finalized", + "true", + "fields.event.ingested.source", + "test_producer", + "fields.event.ingested.format", + "strict_date_optional_time", + "fields.event.ingested.resolution", + "milliseconds" + ); + + Optional maybeDomain = METADATA.fromCustomData(customData, "event.ingested"); + + assertTrue(maybeDomain.isPresent()); + assertThat(maybeDomain.get(), instanceOf(DateRangeFieldDomain.class)); + + DateRangeFieldDomain domain = (DateRangeFieldDomain) maybeDomain.get(); + assertThat(domain.field(), equalTo("event.ingested")); + assertThat(domain.type(), equalTo(DateRangeFieldDomain.TYPE)); + assertThat(domain.min(), equalTo("1714521600000")); + assertThat(domain.max(), equalTo("1717200000000")); + assertTrue(domain.finalized()); + assertThat(domain.source(), equalTo("test_producer")); + assertThat(domain.format(), equalTo("strict_date_optional_time")); + assertThat(domain.resolution(), equalTo("milliseconds")); + } + + public void testFromCustomDataReturnsEmptyForMissingUnknownOrMalformedMetadata() { + assertTrue(METADATA.fromCustomData(null, "@timestamp").isEmpty()); + assertTrue(METADATA.fromCustomData(Map.of(), "@timestamp").isEmpty()); + assertTrue(METADATA.fromCustomData(Map.of("fields.@timestamp.type", "unknown"), "@timestamp").isEmpty()); + assertTrue( + METADATA.fromCustomData( + Map.of( + "fields.@timestamp.type", + "date_range", + "fields.@timestamp.min", + "100", + "fields.@timestamp.max", + "200", + "fields.@timestamp.finalized", + "not-a-boolean", + "fields.@timestamp.resolution", + "milliseconds" + ), + "@timestamp" + ).isEmpty() + ); + } + + public void testFromCustomDataReturnsEmptyWhenDateRangeRequiredKeysAreMissing() { + assertTrue( + METADATA.fromCustomData( + Map.of("fields.@timestamp.type", "date_range", "fields.@timestamp.max", "200", "fields.@timestamp.finalized", "true"), + "@timestamp" + ).isEmpty() + ); + assertTrue( + METADATA.fromCustomData( + Map.of("fields.@timestamp.type", "date_range", "fields.@timestamp.min", "100", "fields.@timestamp.finalized", "true"), + "@timestamp" + ).isEmpty() + ); + assertTrue( + METADATA.fromCustomData( + Map.of("fields.@timestamp.type", "date_range", "fields.@timestamp.min", "100", "fields.@timestamp.max", "200"), + "@timestamp" + ).isEmpty() + ); + } + + public void testFromCustomDataReturnsEmptyWhenRequestedFieldIsMissing() { + Map customData = Map.of( + "fields.event.created.type", + "date_range", + "fields.event.created.min", + "100", + "fields.event.created.max", + "200", + "fields.event.created.finalized", + "true" + ); + + assertTrue(METADATA.fromCustomData(customData, "event.ingested").isEmpty()); + } + + public void testFromCustomDataReadsDateRangeDomainWithOptionalSourceAndFormatOmitted() { + Optional maybeDomain = METADATA.fromCustomData( + Map.of( + "fields.@timestamp.type", + "date_range", + "fields.@timestamp.min", + "100", + "fields.@timestamp.max", + "200", + "fields.@timestamp.finalized", + "true", + "fields.@timestamp.resolution", + "milliseconds" + ), + "@timestamp" + ); + + assertTrue(maybeDomain.isPresent()); + DateRangeFieldDomain domain = (DateRangeFieldDomain) maybeDomain.get(); + assertNull(domain.source()); + assertNull(domain.format()); + assertThat(domain.resolution(), equalTo("milliseconds")); + } + + public void testFromCustomDataReturnsEmptyForInvalidDateRangeValues() { + assertTrue( + METADATA.fromCustomData( + Map.of( + "fields.@timestamp.type", + "date_range", + "fields.@timestamp.min", + "bad", + "fields.@timestamp.max", + "200", + "fields.@timestamp.finalized", + "true", + "fields.@timestamp.resolution", + "milliseconds" + ), + "@timestamp" + ).isEmpty() + ); + assertTrue( + METADATA.fromCustomData( + Map.of( + "fields.@timestamp.type", + "date_range", + "fields.@timestamp.min", + "200", + "fields.@timestamp.max", + "100", + "fields.@timestamp.finalized", + "true", + "fields.@timestamp.resolution", + "milliseconds" + ), + "@timestamp" + ).isEmpty() + ); + assertTrue( + METADATA.fromCustomData( + Map.of( + "fields.@timestamp.type", + "date_range", + "fields.@timestamp.min", + "100", + "fields.@timestamp.max", + "200", + "fields.@timestamp.finalized", + "true", + "fields.@timestamp.resolution", + "unsupported" + ), + "@timestamp" + ).isEmpty() + ); + assertTrue( + METADATA.fromCustomData( + Map.of( + "fields.@timestamp.type", + "date_range", + "fields.@timestamp.min", + "100", + "fields.@timestamp.max", + "200", + "fields.@timestamp.finalized", + "true" + ), + "@timestamp" + ).isEmpty() + ); + } + + public void testToCustomDataWritesDateRangeDomain() { + Map customData = METADATA.toCustomData( + new DateRangeFieldDomain("@timestamp", "100", "200", true, "test", null, "milliseconds") + ); + + assertThat(customData.get("fields.@timestamp.type"), equalTo("date_range")); + assertThat(customData.get("fields.@timestamp.min"), equalTo("100")); + assertThat(customData.get("fields.@timestamp.max"), equalTo("200")); + assertThat(customData.get("fields.@timestamp.finalized"), equalTo("true")); + assertThat(customData.get("fields.@timestamp.source"), equalTo("test")); + assertFalse(customData.containsKey("fields.@timestamp.format")); + assertThat(customData.get("fields.@timestamp.resolution"), equalTo("milliseconds")); + } + + public void testToCustomDataReturnsImmutableMap() { + Map customData = METADATA.toCustomData(new DateRangeFieldDomain("@timestamp", 100L, 200L, true, "test")); + + expectThrows(UnsupportedOperationException.class, () -> customData.put("fields.@timestamp.min", "300")); + } + + public void testToCustomDataRejectsUnsupportedDomainType() { + IllegalArgumentException exception = expectThrows( + IllegalArgumentException.class, + () -> METADATA.toCustomData(new UnsupportedFieldDomain("host.name")) + ); + + assertThat(exception.getMessage(), equalTo("unsupported field domain type [unsupported]")); + } + + public void testPutFieldDomainCreatesCustomDataWhenMissing() { + IndexMetadata metadata = indexMetadataBuilder("logs-000001").build(); + + IndexMetadata updated = METADATA.putFieldDomain(metadata, new DateRangeFieldDomain("@timestamp", 100L, 200L, true, "test")); + + Map customData = updated.getCustomData(IndexFieldDomainMetadata.CUSTOM_KEY); + assertThat(customData.get("fields.@timestamp.type"), equalTo("date_range")); + assertThat(customData.get("fields.@timestamp.min"), equalTo("100")); + assertThat(customData.get("fields.@timestamp.max"), equalTo("200")); + assertThat(customData.get("fields.@timestamp.finalized"), equalTo("true")); + assertThat(customData.get("fields.@timestamp.resolution"), equalTo("milliseconds")); + } + + public void testPutFieldDomainReplacesOnlyTargetFieldMetadata() { + Map existing = new HashMap<>(); + existing.put("fields.@timestamp.type", "date_range"); + existing.put("fields.@timestamp.min", "1"); + existing.put("fields.@timestamp.max", "2"); + existing.put("fields.@timestamp.finalized", "true"); + existing.put("fields.host.name.type", "term_set"); + existing.put("fields.host.name.value", "server-a"); + existing.put("producer", "existing"); + + IndexMetadata metadata = indexMetadataBuilder("logs-000001").putCustom(IndexFieldDomainMetadata.CUSTOM_KEY, existing).build(); + + IndexMetadata updated = METADATA.putFieldDomain(metadata, new DateRangeFieldDomain("@timestamp", 100L, 200L, true, "ism_rollover")); + + Map customData = updated.getCustomData(IndexFieldDomainMetadata.CUSTOM_KEY); + assertThat(customData.get("fields.@timestamp.type"), equalTo("date_range")); + assertThat(customData.get("fields.@timestamp.min"), equalTo("100")); + assertThat(customData.get("fields.@timestamp.max"), equalTo("200")); + assertThat(customData.get("fields.@timestamp.finalized"), equalTo("true")); + assertThat(customData.get("fields.@timestamp.source"), equalTo("ism_rollover")); + assertThat(customData.get("fields.host.name.type"), equalTo("term_set")); + assertThat(customData.get("fields.host.name.value"), equalTo("server-a")); + assertThat(customData.get("producer"), equalTo("existing")); + } + + public void testPutFieldDomainPreservesFieldsWithSharedPrefixes() { + Map existing = new HashMap<>(); + existing.put("fields.event.type", "keyword_set"); + existing.put("fields.event.value", "audit"); + existing.put("fields.event.ingested.type", "date_range"); + existing.put("fields.event.ingested.min", "1"); + existing.put("fields.event.ingested.max", "2"); + existing.put("fields.event.ingested.finalized", "true"); + existing.put("fields.event.ingested.raw.type", "keyword_set"); + existing.put("fields.event.ingested.raw.value", "2024-01-01"); + + IndexMetadata metadata = indexMetadataBuilder("logs-000001").putCustom(IndexFieldDomainMetadata.CUSTOM_KEY, existing).build(); + + IndexMetadata updated = METADATA.putFieldDomain(metadata, new DateRangeFieldDomain("event.ingested", 100L, 200L, true, "test")); + + Map customData = updated.getCustomData(IndexFieldDomainMetadata.CUSTOM_KEY); + assertThat(customData.get("fields.event.type"), equalTo("keyword_set")); + assertThat(customData.get("fields.event.value"), equalTo("audit")); + assertThat(customData.get("fields.event.ingested.type"), equalTo("date_range")); + assertThat(customData.get("fields.event.ingested.min"), equalTo("100")); + assertThat(customData.get("fields.event.ingested.max"), equalTo("200")); + assertThat(customData.get("fields.event.ingested.raw.type"), equalTo("keyword_set")); + assertThat(customData.get("fields.event.ingested.raw.value"), equalTo("2024-01-01")); + } + + public void testPutFieldDomainRemovesStaleKnownKeysForTargetField() { + Map existing = new HashMap<>(); + existing.put("fields.@timestamp.type", "date_range"); + existing.put("fields.@timestamp.min", "1"); + existing.put("fields.@timestamp.max", "2"); + existing.put("fields.@timestamp.finalized", "true"); + existing.put("fields.@timestamp.source", "old_source"); + existing.put("fields.@timestamp.format", "strict_date_optional_time"); + existing.put("fields.@timestamp.resolution", "nanoseconds"); + existing.put("fields.@timestamp.custom", "preserved"); + + IndexMetadata metadata = indexMetadataBuilder("logs-000001").putCustom(IndexFieldDomainMetadata.CUSTOM_KEY, existing).build(); + + IndexMetadata updated = METADATA.putFieldDomain( + metadata, + new DateRangeFieldDomain("@timestamp", "100", "200", true, null, null, "milliseconds") + ); + + Map customData = updated.getCustomData(IndexFieldDomainMetadata.CUSTOM_KEY); + assertThat(customData.get("fields.@timestamp.min"), equalTo("100")); + assertThat(customData.get("fields.@timestamp.max"), equalTo("200")); + assertFalse(customData.containsKey("fields.@timestamp.source")); + assertFalse(customData.containsKey("fields.@timestamp.format")); + assertThat(customData.get("fields.@timestamp.resolution"), equalTo("milliseconds")); + assertThat(customData.get("fields.@timestamp.custom"), equalTo("preserved")); + } + + public void testPutFieldDomainRejectsNullInputs() { + IndexMetadata metadata = indexMetadataBuilder("logs-000001").build(); + + expectThrows( + NullPointerException.class, + () -> METADATA.putFieldDomain(null, new DateRangeFieldDomain("@timestamp", 1L, 2L, true, "test")) + ); + expectThrows(NullPointerException.class, () -> METADATA.putFieldDomain(metadata, null)); + } + + private static IndexMetadata.Builder indexMetadataBuilder(String index) { + return IndexMetadata.builder(index) + .settings( + Settings.builder() + .put(IndexMetadata.SETTING_VERSION_CREATED, Version.CURRENT) + .put(IndexMetadata.SETTING_INDEX_UUID, "_na_") + .build() + ) + .numberOfShards(1) + .numberOfReplicas(0); + } + + private static final class UnsupportedFieldDomain implements FieldDomain { + private final String field; + + private UnsupportedFieldDomain(String field) { + this.field = field; + } + + @Override + public String field() { + return field; + } + + @Override + public String type() { + return "unsupported"; + } + + @Override + public boolean finalized() { + return true; + } + } +} diff --git a/server/src/test/java/org/opensearch/index/get/DocumentLookupResultTests.java b/server/src/test/java/org/opensearch/index/get/DocumentLookupResultTests.java new file mode 100644 index 0000000000000..7a77e25959cef --- /dev/null +++ b/server/src/test/java/org/opensearch/index/get/DocumentLookupResultTests.java @@ -0,0 +1,68 @@ +/* + * 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.index.get; + +import org.opensearch.common.lucene.uid.Versions; +import org.opensearch.core.common.bytes.BytesArray; +import org.opensearch.core.common.bytes.BytesReference; +import org.opensearch.index.engine.Engine; +import org.opensearch.index.seqno.SequenceNumbers; +import org.opensearch.test.OpenSearchTestCase; + +import java.util.Map; + +public class DocumentLookupResultTests extends OpenSearchTestCase { + + public void testNotFound() { + DocumentLookupResult r = DocumentLookupResult.notFound("x"); + assertFalse(r.exists()); + assertEquals("x", r.id()); + assertEquals(Versions.NOT_FOUND, r.version()); + assertEquals(SequenceNumbers.UNASSIGNED_SEQ_NO, r.seqNo()); + assertEquals(SequenceNumbers.UNASSIGNED_PRIMARY_TERM, r.primaryTerm()); + assertNull(r.source()); + assertTrue(r.documentFields().isEmpty()); + assertTrue(r.metadataFields().isEmpty()); + } + + public void testNullFieldMapsNormalizedToEmpty() { + DocumentLookupResult r = new DocumentLookupResult("1", 1L, true, null, 0L, 1L, null, null); + assertNotNull(r.documentFields()); + assertNotNull(r.metadataFields()); + assertTrue(r.documentFields().isEmpty()); + assertTrue(r.metadataFields().isEmpty()); + } + + public void testEqualsAndHashCode() { + BytesReference src = new BytesArray("{\"f\":1}"); + DocumentLookupResult a = new DocumentLookupResult("1", 2L, true, src, 3L, 1L, Map.of(), Map.of()); + DocumentLookupResult b = new DocumentLookupResult("1", 2L, true, new BytesArray("{\"f\":1}"), 3L, 1L, Map.of(), Map.of()); + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + + DocumentLookupResult differentVersion = new DocumentLookupResult("1", 9L, true, src, 3L, 1L, Map.of(), Map.of()); + assertNotEquals(a, differentVersion); + } + + public void testPreMaterializedShape() { + DocumentLookupResult r = new DocumentLookupResult("1", 5L, true, null, 7L, 2L, Map.of(), Map.of()); + Engine.GetResult gr = r.toGetResult(); + + assertTrue(gr instanceof DocumentLookupResult.PreMaterialized); + assertTrue(gr.exists()); + assertEquals(5L, gr.version()); + assertSame(r, ((DocumentLookupResult.PreMaterialized) gr).lookup()); + + // PreMaterialized has no Lucene searcher/docId — these must fail fast, not be dereferenced. + expectThrows(UnsupportedOperationException.class, gr::searcher); + expectThrows(UnsupportedOperationException.class, gr::docIdAndVersion); + + gr.close(); // no-op, must not throw + } +} diff --git a/server/src/test/java/org/opensearch/index/mapper/extrasource/ExtraFieldValueTests.java b/server/src/test/java/org/opensearch/index/mapper/extrasource/ExtraFieldValueTests.java index 1db5388afe8bc..56352d5656101 100644 --- a/server/src/test/java/org/opensearch/index/mapper/extrasource/ExtraFieldValueTests.java +++ b/server/src/test/java/org/opensearch/index/mapper/extrasource/ExtraFieldValueTests.java @@ -14,6 +14,9 @@ import org.opensearch.core.common.io.stream.StreamInput; import org.opensearch.test.OpenSearchTestCase; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; @@ -39,7 +42,7 @@ public void testBytesValueWriteReadRoundTrip() throws Exception { } public void testFloatArrayWriteReadRoundTrip() throws Exception { - PrimitiveFloatArray v = new PrimitiveFloatArray(new float[] { 10.5f, -2.25f }); + FloatArrayValue v = FloatArrayValue.fromFloatArray(new float[] { 10.5f, -2.25f }); BytesStreamOutput out = new BytesStreamOutput(); v.writeTo(out); @@ -56,6 +59,114 @@ public void testFloatArrayWriteReadRoundTrip() throws Exception { assertEquals(-2.25f, fav.get(1), 0.0f); } + public void testIntArrayWriteReadRoundTrip() throws Exception { + IntArrayValue v = IntArrayValue.fromIntArray(new int[] { 10, -2 }); + + BytesStreamOutput out = new BytesStreamOutput(); + v.writeTo(out); + + StreamInput in = out.bytes().streamInput(); + ExtraFieldValue read = ExtraFieldValue.readFrom(in); + + assertThat(read, instanceOf(IntArrayValue.class)); + IntArrayValue iav = (IntArrayValue) read; + + assertThat(iav.type(), is(ExtraFieldValue.Type.INT_ARRAY)); + assertThat(iav.dimension(), is(2)); + assertThat(iav.get(0), is(10)); + assertThat(iav.get(1), is(-2)); + } + + public void testLongArrayWriteReadRoundTrip() throws Exception { + LongArrayValue v = LongArrayValue.fromLongArray(new long[] { 10L, -2L }); + + BytesStreamOutput out = new BytesStreamOutput(); + v.writeTo(out); + + StreamInput in = out.bytes().streamInput(); + ExtraFieldValue read = ExtraFieldValue.readFrom(in); + + assertThat(read, instanceOf(LongArrayValue.class)); + LongArrayValue lav = (LongArrayValue) read; + + assertThat(lav.type(), is(ExtraFieldValue.Type.LONG_ARRAY)); + assertThat(lav.dimension(), is(2)); + assertThat(lav.get(0), is(10L)); + assertThat(lav.get(1), is(-2L)); + } + + public void testDoubleArrayWriteReadRoundTrip() throws Exception { + DoubleArrayValue v = DoubleArrayValue.fromDoubleArray(new double[] { 10.5d, -2.25d }); + + BytesStreamOutput out = new BytesStreamOutput(); + v.writeTo(out); + + StreamInput in = out.bytes().streamInput(); + ExtraFieldValue read = ExtraFieldValue.readFrom(in); + + assertThat(read, instanceOf(DoubleArrayValue.class)); + DoubleArrayValue dav = (DoubleArrayValue) read; + + assertThat(dav.type(), is(ExtraFieldValue.Type.DOUBLE_ARRAY)); + assertThat(dav.dimension(), is(2)); + assertEquals(10.5d, dav.get(0), 0.0d); + assertEquals(-2.25d, dav.get(1), 0.0d); + } + + public void testPackedIntArrayWriteReadRoundTrip() throws Exception { + int[] vals = new int[] { 10, -2 }; + IntArrayValue v = IntArrayValue.fromPackedArray(packIntLE(vals), vals.length); + + BytesStreamOutput out = new BytesStreamOutput(); + v.writeTo(out); + + StreamInput in = out.bytes().streamInput(); + ExtraFieldValue read = ExtraFieldValue.readFrom(in); + + assertThat(read, instanceOf(IntArrayValue.class)); + IntArrayValue iav = (IntArrayValue) read; + + assertThat(iav.type(), is(ExtraFieldValue.Type.INT_ARRAY)); + assertThat(iav.isPackedLE(), is(true)); + assertArrayEquals(vals, iav.asIntArray()); + } + + public void testPackedLongArrayWriteReadRoundTrip() throws Exception { + long[] vals = new long[] { 10L, -2L }; + LongArrayValue v = LongArrayValue.fromPackedArray(packLongLE(vals), vals.length); + + BytesStreamOutput out = new BytesStreamOutput(); + v.writeTo(out); + + StreamInput in = out.bytes().streamInput(); + ExtraFieldValue read = ExtraFieldValue.readFrom(in); + + assertThat(read, instanceOf(LongArrayValue.class)); + LongArrayValue lav = (LongArrayValue) read; + + assertThat(lav.type(), is(ExtraFieldValue.Type.LONG_ARRAY)); + assertThat(lav.isPackedLE(), is(true)); + assertArrayEquals(vals, lav.asLongArray()); + } + + public void testPackedDoubleArrayWriteReadRoundTrip() throws Exception { + double[] vals = new double[] { 10.5d, -2.25d }; + DoubleArrayValue v = DoubleArrayValue.fromPackedArray(packDoubleLE(vals), vals.length); + + BytesStreamOutput out = new BytesStreamOutput(); + v.writeTo(out); + + StreamInput in = out.bytes().streamInput(); + ExtraFieldValue read = ExtraFieldValue.readFrom(in); + + assertThat(read, instanceOf(DoubleArrayValue.class)); + DoubleArrayValue dav = (DoubleArrayValue) read; + + assertThat(dav.type(), is(ExtraFieldValue.Type.DOUBLE_ARRAY)); + assertThat(dav.isPackedLE(), is(true)); + assertArrayEquals(vals, dav.asDoubleArray(), 0.0d); + } + public void testUnknownTypeIdThrows() throws Exception { BytesStreamOutput out = new BytesStreamOutput(); @@ -67,4 +178,28 @@ public void testUnknownTypeIdThrows() throws Exception { IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> ExtraFieldValue.readFrom(in)); assertThat(e.getMessage(), containsString("Unknown ExtraFieldValue.Type id")); } + + private static byte[] packIntLE(int[] vals) { + ByteBuffer buffer = ByteBuffer.allocate(vals.length * Integer.BYTES).order(ByteOrder.LITTLE_ENDIAN); + for (int value : vals) { + buffer.putInt(value); + } + return buffer.array(); + } + + private static byte[] packLongLE(long[] vals) { + ByteBuffer buffer = ByteBuffer.allocate(vals.length * Long.BYTES).order(ByteOrder.LITTLE_ENDIAN); + for (long value : vals) { + buffer.putLong(value); + } + return buffer.array(); + } + + private static byte[] packDoubleLE(double[] vals) { + ByteBuffer buffer = ByteBuffer.allocate(vals.length * Double.BYTES).order(ByteOrder.LITTLE_ENDIAN); + for (double value : vals) { + buffer.putDouble(value); + } + return buffer.array(); + } } diff --git a/server/src/test/java/org/opensearch/index/mapper/extrasource/ExtraFieldValuesIngestTests.java b/server/src/test/java/org/opensearch/index/mapper/extrasource/ExtraFieldValuesIngestTests.java index 2e439964051dd..08aadeb41523e 100644 --- a/server/src/test/java/org/opensearch/index/mapper/extrasource/ExtraFieldValuesIngestTests.java +++ b/server/src/test/java/org/opensearch/index/mapper/extrasource/ExtraFieldValuesIngestTests.java @@ -81,7 +81,7 @@ public void testExtraFieldValues_floatArray_nestedObjectPath() throws Exception var source = source(b -> b.field("other", "x")); - ExtraFieldValues efv = new ExtraFieldValues(Map.of("obj.vec", new PrimitiveFloatArray(new float[] { 10.5f, 20.25f }))); + ExtraFieldValues efv = new ExtraFieldValues(Map.of("obj.vec", FloatArrayValue.fromFloatArray(new float[] { 10.5f, 20.25f }))); SourceToParse stp = new SourceToParse( "test", @@ -99,6 +99,62 @@ public void testExtraFieldValues_floatArray_nestedObjectPath() throws Exception assertThat(doc.rootDoc().getField("obj.vec_f0").numericValue().floatValue(), is(10.5f)); } + public void testExtraFieldValues_numericArrayTypes() throws Exception { + DocumentMapper dm = createDocumentMapper(mapping(b -> { + b.startObject("int_field"); + { + b.field("type", ExtraFieldValuesMapperPlugin.EXTRA_FIELDS_TEST); + } + b.endObject(); + b.startObject("long_field"); + { + b.field("type", ExtraFieldValuesMapperPlugin.EXTRA_FIELDS_TEST); + } + b.endObject(); + b.startObject("double_field"); + { + b.field("type", ExtraFieldValuesMapperPlugin.EXTRA_FIELDS_TEST); + } + b.endObject(); + })); + + var source = source(b -> b.field("other", "x")); + + ExtraFieldValues efv = new ExtraFieldValues( + Map.of( + "int_field", + IntArrayValue.fromIntArray(new int[] { 10, 20 }), + "long_field", + LongArrayValue.fromLongArray(new long[] { 100L, 200L }), + "double_field", + DoubleArrayValue.fromDoubleArray(new double[] { 1.5d, 2.5d }) + ) + ); + + SourceToParse stp = new SourceToParse( + "test", + "1", + source.source(), + MediaType.fromMediaType(source.getMediaType().mediaType()), + null, + efv + ); + + ParsedDocument doc = dm.parse(stp); + + assertThat(doc.rootDoc().getField("int_field_type").binaryValue().utf8ToString(), is("INT_ARRAY")); + assertThat(doc.rootDoc().getField("int_field_dim").numericValue().intValue(), is(2)); + assertThat(doc.rootDoc().getField("int_field_i0").numericValue().intValue(), is(10)); + + assertThat(doc.rootDoc().getField("long_field_type").binaryValue().utf8ToString(), is("LONG_ARRAY")); + assertThat(doc.rootDoc().getField("long_field_dim").numericValue().intValue(), is(2)); + assertThat(doc.rootDoc().getField("long_field_l0").numericValue().longValue(), is(100L)); + + assertThat(doc.rootDoc().getField("double_field_type").binaryValue().utf8ToString(), is("DOUBLE_ARRAY")); + assertThat(doc.rootDoc().getField("double_field_dim").numericValue().intValue(), is(2)); + assertThat(doc.rootDoc().getField("double_field_d0").numericValue().doubleValue(), is(1.5d)); + } + public void testExtraFieldValues_throwsIfNoMapper() throws Exception { DocumentMapper dm = createDocumentMapper(mapping(b -> { b.startObject("obj"); diff --git a/server/src/test/java/org/opensearch/index/mapper/extrasource/ExtraFieldValuesTests.java b/server/src/test/java/org/opensearch/index/mapper/extrasource/ExtraFieldValuesTests.java index 97ee81fb1e609..9a1a094a83a2f 100644 --- a/server/src/test/java/org/opensearch/index/mapper/extrasource/ExtraFieldValuesTests.java +++ b/server/src/test/java/org/opensearch/index/mapper/extrasource/ExtraFieldValuesTests.java @@ -61,7 +61,13 @@ public void testWriteReadRoundTrip() throws Exception { "field_bytes", new BytesValue(new BytesArray(new byte[] { 9, 8, 7 })), "field_vec", - new PrimitiveFloatArray(new float[] { 1.25f, -3.75f }) + FloatArrayValue.fromFloatArray(new float[] { 1.25f, -3.75f }), + "field_ints", + IntArrayValue.fromIntArray(new int[] { 10, -20 }), + "field_longs", + LongArrayValue.fromLongArray(new long[] { 100L, -200L }), + "field_doubles", + DoubleArrayValue.fromDoubleArray(new double[] { 2.5d, -4.5d }) ) ); @@ -72,7 +78,7 @@ public void testWriteReadRoundTrip() throws Exception { ExtraFieldValues read = new ExtraFieldValues(in); assertThat(read.isEmpty(), is(false)); - assertThat(read.values().keySet(), containsInAnyOrder("field_bytes", "field_vec")); + assertThat(read.values().keySet(), containsInAnyOrder("field_bytes", "field_vec", "field_ints", "field_longs", "field_doubles")); ExtraFieldValue v1 = read.get("field_bytes"); assertThat(v1, instanceOf(BytesValue.class)); @@ -86,5 +92,29 @@ public void testWriteReadRoundTrip() throws Exception { assertThat(fav.dimension(), is(2)); assertEquals(1.25f, fav.get(0), 0.0f); assertEquals(-3.75f, fav.get(1), 0.0f); + + ExtraFieldValue v3 = read.get("field_ints"); + assertThat(v3, instanceOf(IntArrayValue.class)); + IntArrayValue iav = (IntArrayValue) v3; + assertThat(iav.type(), is(ExtraFieldValue.Type.INT_ARRAY)); + assertThat(iav.dimension(), is(2)); + assertThat(iav.get(0), is(10)); + assertThat(iav.get(1), is(-20)); + + ExtraFieldValue v4 = read.get("field_longs"); + assertThat(v4, instanceOf(LongArrayValue.class)); + LongArrayValue lav = (LongArrayValue) v4; + assertThat(lav.type(), is(ExtraFieldValue.Type.LONG_ARRAY)); + assertThat(lav.dimension(), is(2)); + assertThat(lav.get(0), is(100L)); + assertThat(lav.get(1), is(-200L)); + + ExtraFieldValue v5 = read.get("field_doubles"); + assertThat(v5, instanceOf(DoubleArrayValue.class)); + DoubleArrayValue dav = (DoubleArrayValue) v5; + assertThat(dav.type(), is(ExtraFieldValue.Type.DOUBLE_ARRAY)); + assertThat(dav.dimension(), is(2)); + assertEquals(2.5d, dav.get(0), 0.0d); + assertEquals(-4.5d, dav.get(1), 0.0d); } } diff --git a/server/src/test/java/org/opensearch/index/mapper/extrasource/ExtraTestFieldMapper.java b/server/src/test/java/org/opensearch/index/mapper/extrasource/ExtraTestFieldMapper.java index 5ca99de21aa4d..f891b51e52611 100644 --- a/server/src/test/java/org/opensearch/index/mapper/extrasource/ExtraTestFieldMapper.java +++ b/server/src/test/java/org/opensearch/index/mapper/extrasource/ExtraTestFieldMapper.java @@ -98,6 +98,21 @@ protected void parseCreateField(ParseContext context) { if (fav.dimension() > 0) { context.doc().add(new StoredField(fieldType().name() + "_f0", fav.get(0))); } + } else if (v instanceof IntArrayValue iav) { + context.doc().add(new StoredField(fieldType().name() + "_dim", iav.dimension())); + if (iav.dimension() > 0) { + context.doc().add(new StoredField(fieldType().name() + "_i0", iav.get(0))); + } + } else if (v instanceof LongArrayValue lav) { + context.doc().add(new StoredField(fieldType().name() + "_dim", lav.dimension())); + if (lav.dimension() > 0) { + context.doc().add(new StoredField(fieldType().name() + "_l0", lav.get(0))); + } + } else if (v instanceof DoubleArrayValue dav) { + context.doc().add(new StoredField(fieldType().name() + "_dim", dav.dimension())); + if (dav.dimension() > 0) { + context.doc().add(new StoredField(fieldType().name() + "_d0", dav.get(0))); + } } else { throw new MapperParsingException("Unsupported ExtraFieldValue impl: " + v.getClass().getName()); } diff --git a/server/src/test/java/org/opensearch/index/mapper/extrasource/NumericArrayValueTests.java b/server/src/test/java/org/opensearch/index/mapper/extrasource/NumericArrayValueTests.java new file mode 100644 index 0000000000000..5ad554c90dbcb --- /dev/null +++ b/server/src/test/java/org/opensearch/index/mapper/extrasource/NumericArrayValueTests.java @@ -0,0 +1,213 @@ +/* + * 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.index.mapper.extrasource; + +import org.opensearch.common.io.stream.BytesStreamOutput; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.test.OpenSearchTestCase; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.sameInstance; + +public class NumericArrayValueTests extends OpenSearchTestCase { + + public void testPrimitiveIntArray() throws Exception { + int[] vals = new int[] { 10, -2, 0, Integer.MIN_VALUE, Integer.MAX_VALUE }; + IntArrayValue array = IntArrayValue.fromIntArray(vals); + + assertThat(array.dimension(), is(vals.length)); + assertThat(array.isPackedLE(), is(false)); + assertThat(array.asIntArray(), sameInstance(vals)); + for (int i = 0; i < vals.length; i++) { + assertThat(array.get(i), is(vals[i])); + } + expectThrows(IllegalStateException.class, array::packedBytes); + + BytesStreamOutput out = new BytesStreamOutput(); + array.writePayloadTo(out); + + StreamInput in = out.bytes().streamInput(); + PrimitiveIntArray read = PrimitiveIntArray.readBodyFrom(in); + assertArrayEquals(vals, read.asIntArray()); + } + + public void testPackedIntArray() throws Exception { + int[] vals = new int[] { 10, -2, 0, Integer.MIN_VALUE, Integer.MAX_VALUE }; + IntArrayValue array = IntArrayValue.fromPackedArray(packIntLE(vals), vals.length); + + assertThat(array.dimension(), is(vals.length)); + assertThat(array.isPackedLE(), is(true)); + for (int i = 0; i < vals.length; i++) { + assertThat(array.get(i), is(vals[i])); + } + assertArrayEquals(vals, array.asIntArray()); + assertThat(array.asIntArray(), sameInstance(array.asIntArray())); + + BytesStreamOutput out = new BytesStreamOutput(); + array.writePayloadTo(out); + + StreamInput in = out.bytes().streamInput(); + assertThat(in.readVInt(), is(vals.length)); + PackedIntArray read = PackedIntArray.readBodyFrom(in, vals.length); + assertArrayEquals(vals, read.asIntArray()); + + IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> IntArrayValue.fromPackedArray(new byte[3], 1)); + assertThat(e.getMessage(), containsString("Bad packed int length")); + expectThrows(IndexOutOfBoundsException.class, () -> array.get(-1)); + expectThrows(IndexOutOfBoundsException.class, () -> array.get(vals.length)); + } + + public void testPrimitiveLongArray() throws Exception { + long[] vals = new long[] { 10L, -2L, 0L, Long.MIN_VALUE, Long.MAX_VALUE }; + LongArrayValue array = LongArrayValue.fromLongArray(vals); + + assertThat(array.dimension(), is(vals.length)); + assertThat(array.isPackedLE(), is(false)); + assertThat(array.asLongArray(), sameInstance(vals)); + for (int i = 0; i < vals.length; i++) { + assertThat(array.get(i), is(vals[i])); + } + expectThrows(IllegalStateException.class, array::packedBytes); + + BytesStreamOutput out = new BytesStreamOutput(); + array.writePayloadTo(out); + + StreamInput in = out.bytes().streamInput(); + PrimitiveLongArray read = PrimitiveLongArray.readBodyFrom(in); + assertArrayEquals(vals, read.asLongArray()); + } + + public void testPackedLongArray() throws Exception { + long[] vals = new long[] { 10L, -2L, 0L, Long.MIN_VALUE, Long.MAX_VALUE }; + LongArrayValue array = LongArrayValue.fromPackedArray(packLongLE(vals), vals.length); + + assertThat(array.dimension(), is(vals.length)); + assertThat(array.isPackedLE(), is(true)); + for (int i = 0; i < vals.length; i++) { + assertThat(array.get(i), is(vals[i])); + } + assertArrayEquals(vals, array.asLongArray()); + assertThat(array.asLongArray(), sameInstance(array.asLongArray())); + + BytesStreamOutput out = new BytesStreamOutput(); + array.writePayloadTo(out); + + StreamInput in = out.bytes().streamInput(); + assertThat(in.readVInt(), is(vals.length)); + PackedLongArray read = PackedLongArray.readBodyFrom(in, vals.length); + assertArrayEquals(vals, read.asLongArray()); + + IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> LongArrayValue.fromPackedArray(new byte[7], 1)); + assertThat(e.getMessage(), containsString("Bad packed long length")); + expectThrows(IndexOutOfBoundsException.class, () -> array.get(-1)); + expectThrows(IndexOutOfBoundsException.class, () -> array.get(vals.length)); + } + + public void testPrimitiveDoubleArray() throws Exception { + double[] vals = new double[] { 10.5d, -2.25d, 0d, Double.MIN_VALUE, Double.MAX_VALUE }; + DoubleArrayValue array = DoubleArrayValue.fromDoubleArray(vals); + + assertThat(array.dimension(), is(vals.length)); + assertThat(array.isPackedLE(), is(false)); + assertThat(array.asDoubleArray(), sameInstance(vals)); + for (int i = 0; i < vals.length; i++) { + assertEquals(vals[i], array.get(i), 0.0d); + } + expectThrows(IllegalStateException.class, array::packedBytes); + + BytesStreamOutput out = new BytesStreamOutput(); + array.writePayloadTo(out); + + StreamInput in = out.bytes().streamInput(); + PrimitiveDoubleArray read = PrimitiveDoubleArray.readBodyFrom(in); + assertArrayEquals(vals, read.asDoubleArray(), 0.0d); + } + + public void testPackedDoubleArray() throws Exception { + double[] vals = new double[] { 10.5d, -2.25d, 0d, Double.MIN_VALUE, Double.MAX_VALUE }; + DoubleArrayValue array = DoubleArrayValue.fromPackedArray(packDoubleLE(vals), vals.length); + + assertThat(array.dimension(), is(vals.length)); + assertThat(array.isPackedLE(), is(true)); + for (int i = 0; i < vals.length; i++) { + assertEquals(vals[i], array.get(i), 0.0d); + } + assertArrayEquals(vals, array.asDoubleArray(), 0.0d); + assertThat(array.asDoubleArray(), sameInstance(array.asDoubleArray())); + + BytesStreamOutput out = new BytesStreamOutput(); + array.writePayloadTo(out); + + StreamInput in = out.bytes().streamInput(); + assertThat(in.readVInt(), is(vals.length)); + PackedDoubleArray read = PackedDoubleArray.readBodyFrom(in, vals.length); + assertArrayEquals(vals, read.asDoubleArray(), 0.0d); + + IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> DoubleArrayValue.fromPackedArray(new byte[7], 1)); + assertThat(e.getMessage(), containsString("Bad packed double length")); + expectThrows(IndexOutOfBoundsException.class, () -> array.get(-1)); + expectThrows(IndexOutOfBoundsException.class, () -> array.get(vals.length)); + } + + public void testPrimitiveArrayFactoriesRejectNull() { + NullPointerException floatError = expectThrows(NullPointerException.class, () -> FloatArrayValue.fromFloatArray(null)); + assertThat(floatError.getMessage(), containsString("values must not be null")); + + NullPointerException intError = expectThrows(NullPointerException.class, () -> IntArrayValue.fromIntArray(null)); + assertThat(intError.getMessage(), containsString("values must not be null")); + + NullPointerException longError = expectThrows(NullPointerException.class, () -> LongArrayValue.fromLongArray(null)); + assertThat(longError.getMessage(), containsString("values must not be null")); + + NullPointerException doubleError = expectThrows(NullPointerException.class, () -> DoubleArrayValue.fromDoubleArray(null)); + assertThat(doubleError.getMessage(), containsString("values must not be null")); + } + + public void testPackedArrayFactoriesRejectNull() { + NullPointerException floatError = expectThrows(NullPointerException.class, () -> FloatArrayValue.fromPackedArray(null, 1)); + assertThat(floatError.getMessage(), containsString("packedArray must not be null")); + + NullPointerException intError = expectThrows(NullPointerException.class, () -> IntArrayValue.fromPackedArray(null, 1)); + assertThat(intError.getMessage(), containsString("packedArray must not be null")); + + NullPointerException longError = expectThrows(NullPointerException.class, () -> LongArrayValue.fromPackedArray(null, 1)); + assertThat(longError.getMessage(), containsString("packedArray must not be null")); + + NullPointerException doubleError = expectThrows(NullPointerException.class, () -> DoubleArrayValue.fromPackedArray(null, 1)); + assertThat(doubleError.getMessage(), containsString("packedArray must not be null")); + } + + private static byte[] packIntLE(int[] vals) { + ByteBuffer buffer = ByteBuffer.allocate(vals.length * Integer.BYTES).order(ByteOrder.LITTLE_ENDIAN); + for (int value : vals) { + buffer.putInt(value); + } + return buffer.array(); + } + + private static byte[] packLongLE(long[] vals) { + ByteBuffer buffer = ByteBuffer.allocate(vals.length * Long.BYTES).order(ByteOrder.LITTLE_ENDIAN); + for (long value : vals) { + buffer.putLong(value); + } + return buffer.array(); + } + + private static byte[] packDoubleLE(double[] vals) { + ByteBuffer buffer = ByteBuffer.allocate(vals.length * Double.BYTES).order(ByteOrder.LITTLE_ENDIAN); + for (double value : vals) { + buffer.putDouble(value); + } + return buffer.array(); + } +} diff --git a/server/src/test/java/org/opensearch/index/query/NestedQueryBuilderTests.java b/server/src/test/java/org/opensearch/index/query/NestedQueryBuilderTests.java index 435e529325646..79a18d4319135 100644 --- a/server/src/test/java/org/opensearch/index/query/NestedQueryBuilderTests.java +++ b/server/src/test/java/org/opensearch/index/query/NestedQueryBuilderTests.java @@ -585,4 +585,42 @@ public void testVisit() { assertEquals(2, visitedQueries.size()); } + + public void testVisitRecursivelyVisitsNestedChildren() { + BoolQueryBuilder boolQuery = new BoolQueryBuilder().must(new MatchAllQueryBuilder()).filter(new TermQueryBuilder("field", "value")); + NestedQueryBuilder builder = new NestedQueryBuilder("path", boolQuery, ScoreMode.None); + + List visitedQueries = new ArrayList<>(); + builder.visit(createTestVisitor(visitedQueries)); + + // Should visit: NestedQueryBuilder, BoolQueryBuilder, MatchAllQueryBuilder, TermQueryBuilder + assertEquals(4, visitedQueries.size()); + assertThat(visitedQueries.get(0), instanceOf(NestedQueryBuilder.class)); + assertThat(visitedQueries.get(1), instanceOf(BoolQueryBuilder.class)); + assertThat(visitedQueries.get(2), instanceOf(MatchAllQueryBuilder.class)); + assertThat(visitedQueries.get(3), instanceOf(TermQueryBuilder.class)); + } + + public void testVisitWithNullChildVisitor() { + NestedQueryBuilder builder = new NestedQueryBuilder("path", new MatchAllQueryBuilder(), ScoreMode.None); + + List visitedQueries = new ArrayList<>(); + QueryBuilderVisitor visitor = new QueryBuilderVisitor() { + @Override + public void accept(QueryBuilder qb) { + visitedQueries.add(qb); + } + + @Override + public QueryBuilderVisitor getChildVisitor(BooleanClause.Occur occur) { + return null; + } + }; + builder.visit(visitor); + + // Should only visit the NestedQueryBuilder itself, not recurse into children + assertEquals(1, visitedQueries.size()); + assertThat(visitedQueries.get(0), instanceOf(NestedQueryBuilder.class)); + } + } diff --git a/server/src/test/java/org/opensearch/index/shard/IndexShardTests.java b/server/src/test/java/org/opensearch/index/shard/IndexShardTests.java index 80f0124aef0e3..48baf75b4625a 100644 --- a/server/src/test/java/org/opensearch/index/shard/IndexShardTests.java +++ b/server/src/test/java/org/opensearch/index/shard/IndexShardTests.java @@ -98,6 +98,7 @@ import org.opensearch.core.xcontent.XContentBuilder; import org.opensearch.env.NodeEnvironment; import org.opensearch.index.IndexSettings; +import org.opensearch.index.SegmentReplicationShardStats; import org.opensearch.index.codec.CodecService; import org.opensearch.index.engine.CommitStats; import org.opensearch.index.engine.DocIdSeqNoAndSource; @@ -230,7 +231,12 @@ import static org.hamcrest.Matchers.nullValue; import static org.hamcrest.Matchers.oneOf; import static org.hamcrest.Matchers.sameInstance; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; /** * Simple unit-test IndexShard related operations. @@ -5623,4 +5629,117 @@ public void testOnMergesDrained_OnReplica_TripsPrimaryAssertion() throws IOExcep closeShards(replica); } } + + /** + * Verifies {@code waitForReplicaSync} returns immediately on a non-segment-replication index + * (the method is a no-op when segment replication is not enabled). + */ + public void testWaitForReplicaSync_NonSegRepIndex_ReturnsImmediately() throws IOException { + IndexShard shard = newStartedShard(true); + try { + // Should not throw — returns immediately for non-seg-rep indices + shard.waitForReplicaSync(TimeValue.timeValueSeconds(1)); + } finally { + closeShards(shard); + } + } + + /** + * Verifies {@code waitForReplicaSync} returns immediately when replicas are already in sync. + */ + public void testWaitForReplicaSync_AlreadyInSync_ReturnsImmediately() throws IOException { + Settings segRepSettings = Settings.builder() + .put(IndexMetadata.SETTING_REPLICATION_TYPE, ReplicationType.SEGMENT.toString()) + .build(); + IndexShard shard = newStartedShard(true, segRepSettings); + try { + // Spy on the shard to simulate a replica that's already in sync + IndexShard spyShard = spy(shard); + SegmentReplicationShardStats inSyncStat = mock(SegmentReplicationShardStats.class); + when(inSyncStat.getCheckpointsBehindCount()).thenReturn(0L); + when(inSyncStat.getBytesBehindCount()).thenReturn(0L); + when(inSyncStat.getCurrentReplicationTimeMillis()).thenReturn(0L); + doReturn(Set.of(inSyncStat)).when(spyShard).getReplicationStatsForTrackedReplicas(); + + spyShard.waitForReplicaSync(TimeValue.timeValueSeconds(10)); + + // Should have checked stats exactly once and returned — no polling loop + verify(spyShard, times(1)).getReplicationStatsForTrackedReplicas(); + } finally { + closeShards(shard); + } + } + + /** + * Verifies {@code waitForReplicaSync} throws IOException when replicas fail to sync within timeout. + */ + public void testWaitForReplicaSync_Timeout_ThrowsIOException() throws IOException { + Settings segRepSettings = Settings.builder() + .put(IndexMetadata.SETTING_REPLICATION_TYPE, ReplicationType.SEGMENT.toString()) + .build(); + IndexShard shard = newStartedShard(true, segRepSettings); + try { + // Spy on the shard to simulate a replica that's always behind + IndexShard spyShard = spy(shard); + SegmentReplicationShardStats behindStat = mock(SegmentReplicationShardStats.class); + when(behindStat.getCheckpointsBehindCount()).thenReturn(3L); + doReturn(Set.of(behindStat)).when(spyShard).getReplicationStatsForTrackedReplicas(); + + // Use a very short timeout so the test doesn't take 30s + IOException ex = expectThrows(IOException.class, () -> spyShard.waitForReplicaSync(TimeValue.timeValueMillis(600))); + assertThat(ex.getMessage(), containsString("replicas failed to sync within")); + assertThat(ex.getMessage(), containsString("max checkpoints behind: 3")); + } finally { + closeShards(shard); + } + } + + /** + * Verifies {@code waitForReplicaSync} does NOT return immediately when bytesBehind is non-zero, + * even if checkpointsBehindCount is zero. This catches the DFA metadata mismatch scenario. + */ + public void testWaitForReplicaSync_BytesBehindNonZero_TimesOut() throws IOException { + Settings segRepSettings = Settings.builder() + .put(IndexMetadata.SETTING_REPLICATION_TYPE, ReplicationType.SEGMENT.toString()) + .build(); + IndexShard shard = newStartedShard(true, segRepSettings); + try { + IndexShard spyShard = spy(shard); + SegmentReplicationShardStats behindStat = mock(SegmentReplicationShardStats.class); + when(behindStat.getCheckpointsBehindCount()).thenReturn(0L); + when(behindStat.getBytesBehindCount()).thenReturn(26600000000L); + when(behindStat.getCurrentReplicationTimeMillis()).thenReturn(0L); + doReturn(Set.of(behindStat)).when(spyShard).getReplicationStatsForTrackedReplicas(); + + IOException ex = expectThrows(IOException.class, () -> spyShard.waitForReplicaSync(TimeValue.timeValueMillis(600))); + assertThat(ex.getMessage(), containsString(IndexShard.REPLICA_SYNC_TIMEOUT_MARKER)); + assertThat(ex.getMessage(), containsString("max bytes behind:")); + } finally { + closeShards(shard); + } + } + + /** + * Verifies {@code waitForReplicaSync} does NOT return immediately when replication is in progress + * (currentReplicationTimeMillis > 0), even if checkpoints and bytes behind are zero. + */ + public void testWaitForReplicaSync_ReplicationInProgress_TimesOut() throws IOException { + Settings segRepSettings = Settings.builder() + .put(IndexMetadata.SETTING_REPLICATION_TYPE, ReplicationType.SEGMENT.toString()) + .build(); + IndexShard shard = newStartedShard(true, segRepSettings); + try { + IndexShard spyShard = spy(shard); + SegmentReplicationShardStats replicatingStat = mock(SegmentReplicationShardStats.class); + when(replicatingStat.getCheckpointsBehindCount()).thenReturn(0L); + when(replicatingStat.getBytesBehindCount()).thenReturn(0L); + when(replicatingStat.getCurrentReplicationTimeMillis()).thenReturn(5000L); + doReturn(Set.of(replicatingStat)).when(spyShard).getReplicationStatsForTrackedReplicas(); + + IOException ex = expectThrows(IOException.class, () -> spyShard.waitForReplicaSync(TimeValue.timeValueMillis(600))); + assertThat(ex.getMessage(), containsString(IndexShard.REPLICA_SYNC_TIMEOUT_MARKER)); + } finally { + closeShards(shard); + } + } } diff --git a/server/src/test/java/org/opensearch/index/shard/SegmentReplicationIndexShardTests.java b/server/src/test/java/org/opensearch/index/shard/SegmentReplicationIndexShardTests.java index dd668d9515579..273702c160256 100644 --- a/server/src/test/java/org/opensearch/index/shard/SegmentReplicationIndexShardTests.java +++ b/server/src/test/java/org/opensearch/index/shard/SegmentReplicationIndexShardTests.java @@ -389,7 +389,7 @@ public void testSegmentInfosAndReplicationCheckpointTuple() throws Exception { } // We use compareTo here instead of equals because we ignore segments gen with replicas performing their own commits. // However infos version we expect to be equal. - assertEquals(1, primary.getLatestReplicationCheckpoint().compareTo(replica.getLatestReplicationCheckpoint())); + assertEquals(0, primary.getLatestReplicationCheckpoint().compareTo(replica.getLatestReplicationCheckpoint())); // index and copy segments to replica. int numDocs = randomIntBetween(10, 20); @@ -411,7 +411,7 @@ public void testSegmentInfosAndReplicationCheckpointTuple() throws Exception { try (final GatedCloseable gatedCloseable = replicaTuple.v1()) { assertReplicationCheckpoint(replica, gatedCloseable.get(), replicaTuple.v2()); } - assertEquals(1, primary.getLatestReplicationCheckpoint().compareTo(replica.getLatestReplicationCheckpoint())); + assertEquals(0, primary.getLatestReplicationCheckpoint().compareTo(replica.getLatestReplicationCheckpoint())); } } diff --git a/server/src/test/java/org/opensearch/index/shard/ShardGetServiceTests.java b/server/src/test/java/org/opensearch/index/shard/ShardGetServiceTests.java index e261a244742cc..97fa0cfc7253b 100644 --- a/server/src/test/java/org/opensearch/index/shard/ShardGetServiceTests.java +++ b/server/src/test/java/org/opensearch/index/shard/ShardGetServiceTests.java @@ -33,19 +33,28 @@ import org.opensearch.Version; import org.opensearch.cluster.metadata.IndexMetadata; +import org.opensearch.common.SuppressForbidden; +import org.opensearch.common.document.DocumentField; import org.opensearch.common.settings.Settings; +import org.opensearch.core.common.bytes.BytesArray; +import org.opensearch.core.common.bytes.BytesReference; import org.opensearch.core.index.shard.ShardId; import org.opensearch.core.xcontent.MediaTypeRegistry; import org.opensearch.index.VersionType; import org.opensearch.index.engine.Engine; import org.opensearch.index.engine.VersionConflictEngineException; +import org.opensearch.index.get.DocumentLookupResult; import org.opensearch.index.get.GetResult; +import org.opensearch.index.get.ShardGetService; import org.opensearch.index.mapper.MapperService; import org.opensearch.index.mapper.RoutingFieldMapper; import org.opensearch.search.fetch.subphase.FetchSourceContext; import java.io.IOException; +import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Map; import static org.opensearch.index.seqno.SequenceNumbers.UNASSIGNED_PRIMARY_TERM; import static org.opensearch.index.seqno.SequenceNumbers.UNASSIGNED_SEQ_NO; @@ -217,4 +226,55 @@ public void testTypelessGetForUpdate() throws IOException { closeShards(shard); } + + @SuppressForbidden(reason = "reflective access to test private buildFromLookup method") + public void testBuildFromLookupSourceFiltering() throws Exception { + Settings settings = Settings.builder() + .put(IndexMetadata.SETTING_VERSION_CREATED, Version.CURRENT) + .put(IndexMetadata.SETTING_NUMBER_OF_REPLICAS, 1) + .put(IndexMetadata.SETTING_NUMBER_OF_SHARDS, 1) + .build(); + IndexMetadata metadata = IndexMetadata.builder("test") + .putMapping("{ \"properties\": { \"foo\": { \"type\": \"text\"}}}") + .settings(settings) + .primaryTerm(0, 1) + .build(); + IndexShard primary = newShard(new ShardId(metadata.getIndex(), 0), true, "n1", metadata, null); + recoverShardFromStore(primary); + + ShardGetService getService = primary.getService(); + Method buildFromLookup = ShardGetService.class.getDeclaredMethod( + "buildFromLookup", + DocumentLookupResult.class, + FetchSourceContext.class + ); + buildFromLookup.setAccessible(true); + + BytesReference source = new BytesArray("{\"foo\":\"bar\",\"baz\":\"qux\"}"); + DocumentField docField = new DocumentField("foo", Collections.singletonList("bar")); + DocumentLookupResult lookup = new DocumentLookupResult("1", 3L, true, source, 5L, 1L, Map.of("foo", docField), Map.of()); + + // Case 1: FETCH_SOURCE — source passes through unfiltered + GetResult result = (GetResult) buildFromLookup.invoke(getService, lookup, FetchSourceContext.FETCH_SOURCE); + assertTrue(result.isExists()); + assertEquals("1", result.getId()); + assertEquals(3L, result.getVersion()); + assertEquals(5L, result.getSeqNo()); + assertEquals(1L, result.getPrimaryTerm()); + assertEquals("{\"foo\":\"bar\",\"baz\":\"qux\"}", new String(result.source(), StandardCharsets.UTF_8)); + assertEquals("bar", result.getFields().get("foo").getValue()); + + // Case 2: DO_NOT_FETCH_SOURCE — source is null + GetResult noSourceResult = (GetResult) buildFromLookup.invoke(getService, lookup, FetchSourceContext.DO_NOT_FETCH_SOURCE); + assertNull(noSourceResult.source()); + assertTrue(noSourceResult.isExists()); + assertEquals("1", noSourceResult.getId()); + + // Case 3: includes filter — only matching fields in source + FetchSourceContext includesOnly = new FetchSourceContext(true, new String[] { "foo" }, new String[0]); + GetResult filteredResult = (GetResult) buildFromLookup.invoke(getService, lookup, includesOnly); + assertEquals("{\"foo\":\"bar\"}", new String(filteredResult.source(), StandardCharsets.UTF_8)); + + closeShards(primary); + } } diff --git a/server/src/test/java/org/opensearch/index/store/remote/DataFormatAwareRemoteDirectoryTests.java b/server/src/test/java/org/opensearch/index/store/remote/DataFormatAwareRemoteDirectoryTests.java index 1ca9e9f137605..876b426a91586 100644 --- a/server/src/test/java/org/opensearch/index/store/remote/DataFormatAwareRemoteDirectoryTests.java +++ b/server/src/test/java/org/opensearch/index/store/remote/DataFormatAwareRemoteDirectoryTests.java @@ -1269,4 +1269,176 @@ public void testOpenBlockInput_InvalidPosition_Throws() { public void testOpenBlockInput_LengthExceedsFileLength_Throws() { expectThrows(IllegalArgumentException.class, () -> directory.openBlockInput("_0.cfe__UUID1", 5, 10, 10, IOContext.DEFAULT)); } + + // ═══════════════════════════════════════════════════════════════ + // IndexInput ref-count exhaustion Tests + // ═══════════════════════════════════════════════════════════════ + + /** + * Helper: builds an AsyncDir backed by an AsyncMultiStreamBlobContainer that captures the + * WriteContext and completion listener without performing any real upload. + */ + private record AsyncDirFixture(DataFormatAwareRemoteDirectory dir, AsyncMultiStreamBlobContainer container, AtomicReference< + WriteContext> writeCtx, AtomicReference> listener, java.util.concurrent.CountDownLatch captureLatch) { + } + + private AsyncDirFixture buildAsyncFixture() throws Exception { + AsyncMultiStreamBlobContainer container = mock(AsyncMultiStreamBlobContainer.class); + when(container.remoteIntegrityCheckSupported()).thenReturn(true); + when(container.path()).thenReturn(baseBlobPath); + + BlobStore store = mock(BlobStore.class); + when(store.blobContainer(baseBlobPath)).thenReturn(container); + + DataFormatAwareRemoteDirectory dir = new DataFormatAwareRemoteDirectory( + store, + baseBlobPath, + UnaryOperator.identity(), + UnaryOperator.identity(), + UnaryOperator.identity(), + UnaryOperator.identity(), + new HashMap<>(), + logger, + null, + null + ); + + AtomicReference writeCtx = new AtomicReference<>(); + AtomicReference> listener = new AtomicReference<>(); + java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1); + + Mockito.doAnswer(inv -> { + writeCtx.set(inv.getArgument(0)); + listener.set(inv.getArgument(1)); + latch.countDown(); + return null; + }).when(container).asyncBlobUpload(any(WriteContext.class), any()); + + return new AsyncDirFixture(dir, container, writeCtx, listener, latch); + } + + private Directory buildFileDir(String filename, int dataBytes) throws IOException { + Directory d = newDirectory(); + IndexOutput out = d.createOutput(filename, IOContext.DEFAULT); + out.writeBytes(new byte[dataBytes], dataBytes); + CodecUtil.writeFooter(out); + out.close(); + d.sync(List.of(filename)); + return d; + } + // ═══════════════════════════════════════════════════════════════ + // slice() isolation tests + // ═══════════════════════════════════════════════════════════════ + + /** + * Regression: closing one part's stream must NOT prevent other parts from being served. + * + * Root cause: clone() passed the master's segments[] array by reference to MultiSegmentImpl. + * When any clone closed, Arrays.fill(segments, null) corrupted the shared array, causing + * AlreadyClosedException on all subsequent provideStream() calls during seek() in the + * OffsetRangeIndexInputStream constructor. + * + * Fix: slice() calls ArrayUtil.copyOfSubArray() for non-full-range slices, giving each part + * its own independent segments[] copy. Closing one part only nullifies its own array. + */ + public void testIndexInputRefCount_StaysOpenWhileProvideStreamInProgress() throws Exception { + AsyncMultiStreamBlobContainer asyncContainer = mock(AsyncMultiStreamBlobContainer.class); + when(asyncContainer.remoteIntegrityCheckSupported()).thenReturn(true); + when(asyncContainer.path()).thenReturn(baseBlobPath); + + BlobStore asyncBlobStore = mock(BlobStore.class); + when(asyncBlobStore.blobContainer(baseBlobPath)).thenReturn(asyncContainer); + + DataFormatAwareRemoteDirectory asyncDir = new DataFormatAwareRemoteDirectory( + asyncBlobStore, + baseBlobPath, + UnaryOperator.identity(), + UnaryOperator.identity(), + UnaryOperator.identity(), + UnaryOperator.identity(), + new HashMap<>(), + logger, + null, + null + ); + + // Write a real file with codec footer (required for checksum computation) + Directory storeDirectory = newDirectory(); + String filename = "_segment.bin"; + IndexOutput indexOutput = storeDirectory.createOutput(filename, IOContext.DEFAULT); + // Write enough bytes to ensure multipart upload (content > 0) + byte[] content = new byte[1024]; + for (int i = 0; i < content.length; i++) + content[i] = (byte) i; + indexOutput.writeBytes(content, content.length); + CodecUtil.writeFooter(indexOutput); + indexOutput.close(); + storeDirectory.sync(List.of(filename)); + + // Capture the WriteContext so we can manually call provideStream() on a different thread, + // simulating the SizeBasedBlockingQ consumer running long after asyncBlobUpload() returns. + AtomicReference capturedWriteContext = new AtomicReference<>(); + AtomicReference> capturedListener = new AtomicReference<>(); + CountDownLatch uploadCaptureLatch = new CountDownLatch(1); + + Mockito.doAnswer(invocation -> { + capturedWriteContext.set(invocation.getArgument(0)); + capturedListener.set(invocation.getArgument(1)); + uploadCaptureLatch.countDown(); + return null; + }).when(asyncContainer).asyncBlobUpload(any(WriteContext.class), any()); + + CountDownLatch completionLatch = new CountDownLatch(1); + AtomicReference uploadFailure = new AtomicReference<>(); + + asyncDir.copyFrom(storeDirectory, filename, filename, IOContext.DEFAULT, () -> {}, new ActionListener<>() { + @Override + public void onResponse(Void unused) { + completionLatch.countDown(); + } + + @Override + public void onFailure(Exception e) { + uploadFailure.set(e); + completionLatch.countDown(); + } + }, false, null); + + assertTrue("asyncBlobUpload should be called", uploadCaptureLatch.await(5, TimeUnit.SECONDS)); + + // Get the StreamContext and serve 3 sequential part streams, closing each before the next. + // With the old clone() approach, closing stream0 would call Arrays.fill(segments, null) + // on the SHARED array, causing provideStream(1) to throw AlreadyClosedException in the + // OffsetRangeIndexInputStream constructor's seek() call. + // With slice(), each part has its own independent segments[] copy — closing one never + // affects others. + WriteContext writeContext = capturedWriteContext.get(); + long partSize = Math.max(1, writeContext.getFileSize() / 3); + org.opensearch.common.StreamContext streamContext = writeContext.getStreamProvider(partSize); + + org.opensearch.common.io.InputStreamContainer stream0 = streamContext.provideStream(0); + assertNotNull("Part 0 stream must be created", stream0); + stream0.getInputStream().close(); // closes part 0's independent slice — must NOT affect parts 1,2 + + try { + org.opensearch.common.io.InputStreamContainer stream1 = streamContext.provideStream(1); + assertNotNull("Part 1 stream must be created after part 0 closes — slice() fix", stream1); + stream1.getInputStream().close(); + + org.opensearch.common.io.InputStreamContainer stream2 = streamContext.provideStream(2); + assertNotNull("Part 2 stream must be created after parts 0,1 close — slice() fix", stream2); + stream2.getInputStream().close(); + } catch (org.apache.lucene.store.AlreadyClosedException e) { + fail( + "AlreadyClosedException must NOT be thrown after a prior part stream closes. " + + "This is the Arrays.fill(segments,null) shared-array corruption bug that " + + "slice() fixes. Exception: " + + e.getMessage() + ); + } + + capturedListener.get().onResponse(null); + assertTrue(completionLatch.await(5, TimeUnit.SECONDS)); + storeDirectory.close(); + } } diff --git a/server/src/test/java/org/opensearch/index/store/remote/filecache/NodeCacheServiceTests.java b/server/src/test/java/org/opensearch/index/store/remote/filecache/NodeCacheServiceTests.java index fbbd6157778f3..fdf5b23b2ce4b 100644 --- a/server/src/test/java/org/opensearch/index/store/remote/filecache/NodeCacheServiceTests.java +++ b/server/src/test/java/org/opensearch/index/store/remote/filecache/NodeCacheServiceTests.java @@ -13,6 +13,7 @@ import org.opensearch.plugins.BlockCache; import org.opensearch.plugins.BlockCacheProvider; import org.opensearch.plugins.BlockCacheStats; +import org.opensearch.plugins.BlockCacheTieredStats; import org.opensearch.test.OpenSearchTestCase; import java.util.Map; @@ -550,4 +551,58 @@ public void testCombinedBlockCacheStatsAggregatesAcrossMultipleCaches() { assertEquals(1536, combined.diskBytesUsed()); assertEquals(3072, combined.totalBytes()); } + + public void testCombinedBlockCacheStatsPropagatesTieredStatsFromTieredCache() { + FileCache fc = fileCacheWithStats(0L, 0L, 0L, 0L, 0L, 0L, 0L); + NodeCacheService orc = new NodeCacheService(fc, 0L); + + // Non-tiered cache (default mock → tieredStats() == null). + BlockCache singleTier = mock(BlockCache.class); + when(singleTier.stats()).thenReturn(new BlockCacheStats(10, 5, 0, 0, 0, 0, 0, 0, 0, 1024, 2048, 0)); + + // Tiered cache exposing a per-tier breakdown. + BlockCacheTieredStats tiered = sampleTieredStats(); + BlockCache tieredCache = mock(BlockCache.class); + when(tieredCache.stats()).thenReturn(new BlockCacheStats(20, 3, 0, 0, 0, 0, 0, 0, 0, 512, 1024, 0)); + when(tieredCache.tieredStats()).thenReturn(tiered); + + orc.addBlockCache(singleTier); + orc.addBlockCache(tieredCache); + + BlockCacheStats combined = orc.combinedBlockCacheStats(); + assertNotNull(combined); + assertEquals("tiered breakdown must propagate into the combined stats", tiered, combined.tieredStats()); + } + + public void testCombinedBlockCacheStatsNullTieredWhenNoCacheIsTiered() { + FileCache fc = fileCacheWithStats(0L, 0L, 0L, 0L, 0L, 0L, 0L); + NodeCacheService orc = new NodeCacheService(fc, 0L); + orc.addBlockCache(mockBlockCache(1, 0, 0, 0, 10L)); + orc.addBlockCache(mockBlockCache(2, 0, 0, 0, 20L)); + assertNull("combined tieredStats must be null when no cache is tiered", orc.combinedBlockCacheStats().tieredStats()); + } + + public void testCombinedBlockCacheStatsPicksFirstTieredWhenMultiplePresent() { + FileCache fc = fileCacheWithStats(0L, 0L, 0L, 0L, 0L, 0L, 0L); + NodeCacheService orc = new NodeCacheService(fc, 0L); + + // Two distinct (but equal-valued) instances — identity proves "first wins". + BlockCacheTieredStats first = sampleTieredStats(); + BlockCacheTieredStats second = sampleTieredStats(); + BlockCache bc1 = mock(BlockCache.class); + when(bc1.stats()).thenReturn(new BlockCacheStats(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1L, 0)); + when(bc1.tieredStats()).thenReturn(first); + BlockCache bc2 = mock(BlockCache.class); + when(bc2.stats()).thenReturn(new BlockCacheStats(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1L, 0)); + when(bc2.tieredStats()).thenReturn(second); + + orc.addBlockCache(bc1); + orc.addBlockCache(bc2); + + assertSame("first non-null tieredStats must win", first, orc.combinedBlockCacheStats().tieredStats()); + } + + private BlockCacheTieredStats sampleTieredStats() { + return new BlockCacheTieredStats(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22); + } } diff --git a/server/src/test/java/org/opensearch/index/translog/InternalTranslogManagerTests.java b/server/src/test/java/org/opensearch/index/translog/InternalTranslogManagerTests.java index 92c612f7271b7..237bbe1b7648c 100644 --- a/server/src/test/java/org/opensearch/index/translog/InternalTranslogManagerTests.java +++ b/server/src/test/java/org/opensearch/index/translog/InternalTranslogManagerTests.java @@ -16,6 +16,7 @@ import org.opensearch.index.seqno.SequenceNumbers; import org.opensearch.index.translog.listener.TranslogEventListener; +import java.io.Closeable; import java.io.IOException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -297,4 +298,68 @@ public void onAfterTranslogSync() { translogManager.close(); } } + + /** + * {@link InternalTranslogManager#acquireHistoryRetentionLock()} must return a lock backed by + * {@link Translog#acquireRetentionLock()} that pins a translog generation in the deletion policy while + * it is held, and releases that generation when closed. This is the translog-layer support that + * {@link org.opensearch.index.engine.DataFormatAwareEngine} relies on to keep history available for + * the duration of peer recovery / primary relocation. + */ + public void testAcquireHistoryRetentionLock() throws IOException { + final AtomicLong globalCheckpoint = new AtomicLong(SequenceNumbers.NO_OPS_PERFORMED); + final LocalCheckpointTracker tracker = new LocalCheckpointTracker(NO_OPS_PERFORMED, NO_OPS_PERFORMED); + // Hold a reference to the deletion policy so we can observe the retention locks it tracks. + final TranslogDeletionPolicy deletionPolicy = createTranslogDeletionPolicy(INDEX_SETTINGS); + InternalTranslogManager translogManager = null; + try { + translogManager = new InternalTranslogManager( + new TranslogConfig(shardId, primaryTranslogDir, INDEX_SETTINGS, BigArrays.NON_RECYCLING_INSTANCE, "", false), + primaryTerm, + globalCheckpoint::get, + deletionPolicy, + shardId, + new ReleasableLock(new ReentrantReadWriteLock().readLock()), + () -> tracker, + translogUUID, + TranslogEventListener.NOOP_TRANSLOG_EVENT_LISTENER, + () -> {}, + new InternalTranslogFactory(), + () -> Boolean.TRUE, + TranslogOperationHelper.DEFAULT + ); + + // Index a few operations spread over multiple generations. + final int docs = randomIntBetween(1, 10); + for (int i = 0; i < docs; i++) { + final ParsedDocument doc = testParsedDocument(Integer.toString(i), null, testDocumentWithTextField(), SOURCE, null); + final Engine.Index index = indexForDoc(doc); + final Engine.IndexResult indexResult = new Engine.IndexResult(index.version(), index.primaryTerm(), i, true); + tracker.markSeqNoAsProcessed(i); + translogManager.add(new Translog.Index(index, indexResult)); + translogManager.rollTranslogGeneration(); + } + + // The manager must expose the same deletion policy instance the lock pins against. + assertSame(deletionPolicy, translogManager.getTranslog().getDeletionPolicy()); + + // No retention locks are held before acquiring one. + assertEquals(0, deletionPolicy.pendingTranslogRefCount()); + deletionPolicy.assertNoOpenTranslogRefs(); + + // Acquiring the history retention lock pins a translog generation. + final Closeable retentionLock = translogManager.acquireHistoryRetentionLock(); + assertNotNull(retentionLock); + assertEquals(1, deletionPolicy.pendingTranslogRefCount()); + // While the lock is held there is an open translog reference. + expectThrows(AssertionError.class, deletionPolicy::assertNoOpenTranslogRefs); + + // Releasing the lock releases the pinned generation. + retentionLock.close(); + assertEquals(0, deletionPolicy.pendingTranslogRefCount()); + deletionPolicy.assertNoOpenTranslogRefs(); + } finally { + translogManager.close(); + } + } } diff --git a/server/src/test/java/org/opensearch/indices/replication/checkpoint/ReplicationCheckpointTests.java b/server/src/test/java/org/opensearch/indices/replication/checkpoint/ReplicationCheckpointTests.java new file mode 100644 index 0000000000000..8c1cde40652d4 --- /dev/null +++ b/server/src/test/java/org/opensearch/indices/replication/checkpoint/ReplicationCheckpointTests.java @@ -0,0 +1,74 @@ +/* + * 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.indices.replication.checkpoint; + +import org.apache.lucene.codecs.Codec; +import org.opensearch.core.index.Index; +import org.opensearch.core.index.shard.ShardId; +import org.opensearch.test.OpenSearchTestCase; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +public class ReplicationCheckpointTests extends OpenSearchTestCase { + + private static final ShardId SHARD_ID = new ShardId(new Index("index", "uuid"), 0); + private static final String CODEC = Codec.getDefault().getName(); + + private ReplicationCheckpoint checkpoint(long primaryTerm, long segmentsGen, long segmentInfosVersion) { + return new ReplicationCheckpoint(SHARD_ID, primaryTerm, segmentsGen, segmentInfosVersion, CODEC); + } + + /** + * Two equal checkpoints must compare as 0 in both directions (reflexive/antisymmetric), + * otherwise the {@link Comparable} contract is violated. + */ + public void testCompareToReturnsZeroForEqualCheckpoints() { + ReplicationCheckpoint a = checkpoint(20, 101, 5); + ReplicationCheckpoint b = checkpoint(20, 101, 5); + assertEquals(0, a.compareTo(b)); + assertEquals(0, b.compareTo(a)); + } + + /** + * The checkpoint that is ahead must sort first (natural order), and the relation must be + * antisymmetric. + */ + public void testCompareToOrdersByAheadness() { + ReplicationCheckpoint older = checkpoint(20, 101, 5); + ReplicationCheckpoint newerVersion = checkpoint(20, 101, 6); + ReplicationCheckpoint newerTerm = checkpoint(21, 101, 1); + + assertEquals(-1, newerVersion.compareTo(older)); + assertEquals(1, older.compareTo(newerVersion)); + assertEquals(-1, newerTerm.compareTo(newerVersion)); + assertEquals(1, newerVersion.compareTo(newerTerm)); + } + + /** + * Regression for the comparator-contract violation. Sorting a large list with many duplicates AND + * many distinct checkpoints (shuffled) forces TimSort onto its merge path, where a comparator that + * does not return 0 for equal elements throws + * "Comparison method violates its general contract!". Must sort cleanly with the fix. + */ + public void testSortManyCheckpointsHonorsComparatorContract() { + List checkpoints = new ArrayList<>(); + for (int i = 0; i < 500; i++) { + // small value range => lots of equal checkpoints interleaved with distinct ones + checkpoints.add(checkpoint(20, 101, randomLongBetween(1, 20))); + } + Collections.shuffle(checkpoints, random()); + checkpoints.sort(Comparator.nullsLast(Comparator.naturalOrder())); + for (int i = 1; i < checkpoints.size(); i++) { + assertTrue("list must be totally ordered", checkpoints.get(i - 1).compareTo(checkpoints.get(i)) <= 0); + } + } +} diff --git a/server/src/test/java/org/opensearch/lucene/util/CombinedBitSetTests.java b/server/src/test/java/org/opensearch/lucene/util/CombinedBitSetTests.java index 7b5b9cec41b09..9219adf6162a9 100644 --- a/server/src/test/java/org/opensearch/lucene/util/CombinedBitSetTests.java +++ b/server/src/test/java/org/opensearch/lucene/util/CombinedBitSetTests.java @@ -79,6 +79,7 @@ private void testCase(int numBits, float percent1, float percent2) { for (int i = 0; i < numBits; ++i) { assertEquals(expected.nextSetBit(i), actual.nextSetBit(i)); assertEquals(Integer.toString(i), expected.prevSetBit(i), actual.prevSetBit(i)); + assertEquals(Integer.toString(i), expected.nextClearBit(i, numBits), actual.nextClearBit(i, numBits)); } } diff --git a/server/src/test/java/org/opensearch/node/resource/tracker/NodeResourceUsageTrackerTests.java b/server/src/test/java/org/opensearch/node/resource/tracker/NodeResourceUsageTrackerTests.java index 5ddc8ebf0ff47..b8218f7af41ea 100644 --- a/server/src/test/java/org/opensearch/node/resource/tracker/NodeResourceUsageTrackerTests.java +++ b/server/src/test/java/org/opensearch/node/resource/tracker/NodeResourceUsageTrackerTests.java @@ -176,12 +176,12 @@ public void testDeriveNativeMemoryLimitDefaultFallbackPaths() { long ram = 8L * 1024 * 1024 * 1024; assertEquals("heap >= ram → unconfigured", "0b", ResourceTrackerSettings.deriveNativeMemoryLimitDefault(ram, ram)); assertEquals("heap > ram → unconfigured", "0b", ResourceTrackerSettings.deriveNativeMemoryLimitDefault(ram, ram + 1)); - // Happy path: 64 GB / 16 GB heap → 79% of (RAM - heap) = 79% of 48 GB. + // Happy path: 64 GB / 16 GB heap → 80% of (RAM - heap) = 79% of 48 GB. long sixtyFourGB = 64L * 1024 * 1024 * 1024; long sixteenGB = 16L * 1024 * 1024 * 1024; - long expected = (sixtyFourGB - sixteenGB) * 79 / 100; + long expected = (sixtyFourGB - sixteenGB) * 80 / 100; assertEquals( - "happy path → 79% of (ram - heap)", + "happy path → 80% of (ram - heap)", expected + "b", ResourceTrackerSettings.deriveNativeMemoryLimitDefault(sixtyFourGB, sixteenGB) ); diff --git a/server/src/test/java/org/opensearch/plugins/BlockCacheStatsTests.java b/server/src/test/java/org/opensearch/plugins/BlockCacheStatsTests.java index 37ade8c3ae457..aecbdbc7542f1 100644 --- a/server/src/test/java/org/opensearch/plugins/BlockCacheStatsTests.java +++ b/server/src/test/java/org/opensearch/plugins/BlockCacheStatsTests.java @@ -8,6 +8,7 @@ package org.opensearch.plugins; +import org.opensearch.Version; import org.opensearch.common.xcontent.XContentFactory; import org.opensearch.core.common.io.stream.Writeable; import org.opensearch.core.xcontent.ToXContent; @@ -20,6 +21,7 @@ public class BlockCacheStatsTests extends AbstractWireSerializingTestCase { + + @Override + protected BlockCacheTieredStats createTestInstance() { + return new BlockCacheTieredStats( + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong(), + randomNonNegativeLong() + ); + } + + @Override + protected Writeable.Reader instanceReader() { + return BlockCacheTieredStats::new; + } + + public void testToXContentRendersDataAndMetadataSections() throws IOException { + // Field order: data hits, misses, hitBytes, missBytes, evictions, evictionBytes, + // removed, removedBytes, used, capacity, active, then same 11 for metadata. + BlockCacheTieredStats stats = new BlockCacheTieredStats( + 100, + 10, + 5000, + 500, + 2, + 200, + 3, + 300, + 8000, + 10000, + 64, + 50, + 5, + 2500, + 250, + 1, + 100, + 2, + 150, + 4000, + 5000, + 32 + ); + XContentBuilder builder = XContentFactory.jsonBuilder().startObject(); + stats.toXContent(builder, ToXContent.EMPTY_PARAMS); + builder.endObject(); + String json = builder.toString(); + assertTrue(json.contains("\"data_cache_stats\"")); + assertTrue(json.contains("\"metadata_cache_stats\"")); + } + + public void testDataCacheFields() throws IOException { + BlockCacheTieredStats stats = new BlockCacheTieredStats( + 100, + 10, + 5000, + 500, + 2, + 200, + 3, + 300, + 8000, + 10000, + 64, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ); + XContentBuilder builder = XContentFactory.jsonBuilder().startObject(); + stats.toXContent(builder, ToXContent.EMPTY_PARAMS); + builder.endObject(); + String json = builder.toString(); + assertTrue(json.contains("\"hit_count\":100")); + assertTrue(json.contains("\"miss_count\":10")); + assertTrue(json.contains("\"hit_bytes\":5000")); + assertTrue(json.contains("\"miss_bytes\":500")); + assertTrue(json.contains("\"eviction_count\":2")); + assertTrue(json.contains("\"evictions_in_bytes\":200")); + assertTrue(json.contains("\"removed_count\":3")); + assertTrue(json.contains("\"removed_in_bytes\":300")); + assertTrue(json.contains("\"used_in_bytes\":8000")); + assertTrue(json.contains("\"capacity_in_bytes\":10000")); + assertTrue(json.contains("\"active_in_bytes\":64")); + } + + public void testMetadataCacheFields() throws IOException { + BlockCacheTieredStats stats = new BlockCacheTieredStats( + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 50, + 5, + 2500, + 250, + 1, + 100, + 2, + 150, + 4000, + 5000, + 32 + ); + XContentBuilder builder = XContentFactory.jsonBuilder().startObject(); + stats.toXContent(builder, ToXContent.EMPTY_PARAMS); + builder.endObject(); + String json = builder.toString(); + assertTrue(json.contains("\"metadata_cache_stats\"")); + assertTrue("metadata hit_count", json.contains("\"hit_count\":50")); + assertTrue("metadata capacity", json.contains("\"capacity_in_bytes\":5000")); + assertTrue("metadata removed_count", json.contains("\"removed_count\":2")); + assertTrue("metadata removed_in_bytes", json.contains("\"removed_in_bytes\":150")); + } +} diff --git a/server/src/test/java/org/opensearch/plugins/DocumentLookupProviderTests.java b/server/src/test/java/org/opensearch/plugins/DocumentLookupProviderTests.java new file mode 100644 index 0000000000000..593f60e6201cf --- /dev/null +++ b/server/src/test/java/org/opensearch/plugins/DocumentLookupProviderTests.java @@ -0,0 +1,34 @@ +/* + * 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.plugins; + +import org.opensearch.core.index.Index; +import org.opensearch.index.engine.exec.DocumentMetadataResolver; +import org.opensearch.index.get.DocumentLookupResult; +import org.opensearch.test.OpenSearchTestCase; + +import java.io.IOException; + +public class DocumentLookupProviderTests extends OpenSearchTestCase { + + private static final Index INDEX = new Index("idx", "uuid"); + + /** A provider that overrides only the abstract getById; getVersionMetadata/getDocsAboveSeqNo use the SPI defaults. */ + private static final DocumentLookupProvider DEFAULTS = (get, reader, index, resolver) -> DocumentLookupResult.notFound("x"); + + public void testGetVersionMetadataDefaultsToNotFound() throws IOException { + DocumentLookupResult result = DEFAULTS.getVersionMetadata("id", null, INDEX, DocumentMetadataResolver.NOOP); + assertFalse(result.exists()); + assertEquals("id", result.id()); + } + + public void testGetDocsAboveSeqNoDefaultsToEmpty() throws IOException { + assertTrue(DEFAULTS.getDocsAboveSeqNo(0L, null, INDEX, DocumentMetadataResolver.NOOP).isEmpty()); + } +} diff --git a/server/src/test/java/org/opensearch/ratelimitting/admissioncontrol/AdmissionControlServiceTests.java b/server/src/test/java/org/opensearch/ratelimitting/admissioncontrol/AdmissionControlServiceTests.java index 04753c4924b10..3974ed12fb2bf 100644 --- a/server/src/test/java/org/opensearch/ratelimitting/admissioncontrol/AdmissionControlServiceTests.java +++ b/server/src/test/java/org/opensearch/ratelimitting/admissioncontrol/AdmissionControlServiceTests.java @@ -51,7 +51,7 @@ public void tearDown() throws Exception { } public void testWhenAdmissionControllerRegistered() { - admissionControlService = new AdmissionControlService(Settings.EMPTY, clusterService, threadPool, null); + admissionControlService = new AdmissionControlService(Settings.EMPTY, clusterService, threadPool, null, null); if (Constants.LINUX) { assertEquals(admissionControlService.getAdmissionControllers().size(), 3); } else { @@ -61,7 +61,7 @@ public void testWhenAdmissionControllerRegistered() { public void testRegisterInvalidAdmissionController() { String test = "TEST"; - admissionControlService = new AdmissionControlService(Settings.EMPTY, clusterService, threadPool, null); + admissionControlService = new AdmissionControlService(Settings.EMPTY, clusterService, threadPool, null, null); if (Constants.LINUX) { assertEquals(admissionControlService.getAdmissionControllers().size(), 3); } else { @@ -75,7 +75,7 @@ public void testRegisterInvalidAdmissionController() { } public void testAdmissionControllerSettings() { - admissionControlService = new AdmissionControlService(Settings.EMPTY, clusterService, threadPool, null); + admissionControlService = new AdmissionControlService(Settings.EMPTY, clusterService, threadPool, null, null); AdmissionControlSettings admissionControlSettings = admissionControlService.admissionControlSettings; List admissionControllerList = admissionControlService.getAdmissionControllers(); if (Constants.LINUX) { @@ -122,7 +122,7 @@ public void testAdmissionControllerSettings() { public void testApplyAdmissionControllerDisabled() { this.action = "indices:data/write/bulk[s][p]"; - admissionControlService = new AdmissionControlService(Settings.EMPTY, clusterService, threadPool, null); + admissionControlService = new AdmissionControlService(Settings.EMPTY, clusterService, threadPool, null, null); admissionControlService.applyTransportAdmissionControl(this.action, null); List admissionControllerList = admissionControlService.getAdmissionControllers(); admissionControllerList.forEach(admissionController -> { @@ -132,7 +132,7 @@ public void testApplyAdmissionControllerDisabled() { public void testApplyAdmissionControllerEnabled() { this.action = "indices:data/write/bulk[s][p]"; - admissionControlService = new AdmissionControlService(Settings.EMPTY, clusterService, threadPool, null); + admissionControlService = new AdmissionControlService(Settings.EMPTY, clusterService, threadPool, null, null); admissionControlService.applyTransportAdmissionControl(this.action, null); assertEquals( admissionControlService.getAdmissionController(CpuBasedAdmissionController.CPU_BASED_ADMISSION_CONTROLLER) @@ -157,7 +157,7 @@ public void testApplyAdmissionControllerEnabled() { public void testApplyAdmissionControllerEnforced() { this.action = "indices:data/write/bulk[s][p]"; - admissionControlService = new AdmissionControlService(Settings.EMPTY, clusterService, threadPool, null); + admissionControlService = new AdmissionControlService(Settings.EMPTY, clusterService, threadPool, null, null); admissionControlService.applyTransportAdmissionControl(this.action, null); assertEquals( admissionControlService.getAdmissionController(CpuBasedAdmissionController.CPU_BASED_ADMISSION_CONTROLLER) @@ -182,7 +182,7 @@ public void testApplyAdmissionControllerEnforced() { public void testNativeMemoryBasedAdmissionControllerRegistered() { assumeTrue("native memory controller is Linux-only", Constants.LINUX); - admissionControlService = new AdmissionControlService(Settings.EMPTY, clusterService, threadPool, null); + admissionControlService = new AdmissionControlService(Settings.EMPTY, clusterService, threadPool, null, null); AdmissionController nativeMemoryController = admissionControlService.getAdmissionController( NativeMemoryBasedAdmissionController.NATIVE_MEMORY_BASED_ADMISSION_CONTROLLER ); @@ -192,7 +192,7 @@ public void testNativeMemoryBasedAdmissionControllerRegistered() { public void testNativeMemoryAdmissionControllerSettings() { assumeTrue("native memory controller is Linux-only", Constants.LINUX); - admissionControlService = new AdmissionControlService(Settings.EMPTY, clusterService, threadPool, null); + admissionControlService = new AdmissionControlService(Settings.EMPTY, clusterService, threadPool, null, null); NativeMemoryBasedAdmissionController nativeMemoryController = (NativeMemoryBasedAdmissionController) admissionControlService .getAdmissionController(NativeMemoryBasedAdmissionController.NATIVE_MEMORY_BASED_ADMISSION_CONTROLLER); assertNotNull(nativeMemoryController); @@ -216,7 +216,7 @@ public void testNativeMemoryAdmissionControllerSettings() { public void testApplyNativeMemoryAdmissionControllerDisabled() { assumeTrue("native memory controller is Linux-only", Constants.LINUX); this.action = "indices:data/write/bulk[s][p]"; - admissionControlService = new AdmissionControlService(Settings.EMPTY, clusterService, threadPool, null); + admissionControlService = new AdmissionControlService(Settings.EMPTY, clusterService, threadPool, null, null); admissionControlService.applyTransportAdmissionControl(this.action, null); assertEquals( admissionControlService.getAdmissionController(NativeMemoryBasedAdmissionController.NATIVE_MEMORY_BASED_ADMISSION_CONTROLLER) diff --git a/server/src/test/java/org/opensearch/ratelimitting/admissioncontrol/controllers/NativeMemoryBasedAdmissionControllerTests.java b/server/src/test/java/org/opensearch/ratelimitting/admissioncontrol/controllers/NativeMemoryBasedAdmissionControllerTests.java index e320daeb439df..253da7268389a 100644 --- a/server/src/test/java/org/opensearch/ratelimitting/admissioncontrol/controllers/NativeMemoryBasedAdmissionControllerTests.java +++ b/server/src/test/java/org/opensearch/ratelimitting/admissioncontrol/controllers/NativeMemoryBasedAdmissionControllerTests.java @@ -8,10 +8,12 @@ package org.opensearch.ratelimitting.admissioncontrol.controllers; +import org.opensearch.arrow.spi.PoolGroup; import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.settings.Settings; import org.opensearch.node.NodeResourceUsageStats; import org.opensearch.node.ResourceUsageCollectorService; +import org.opensearch.plugin.stats.NativeAllocatorPoolStats; import org.opensearch.ratelimitting.admissioncontrol.enums.AdmissionControlActionType; import org.opensearch.ratelimitting.admissioncontrol.enums.AdmissionControlMode; import org.opensearch.ratelimitting.admissioncontrol.settings.NativeMemoryBasedAdmissionControllerSettings; @@ -20,7 +22,9 @@ import org.opensearch.threadpool.TestThreadPool; import org.opensearch.threadpool.ThreadPool; +import java.util.List; import java.util.Optional; +import java.util.function.Supplier; import org.mockito.Mockito; @@ -51,7 +55,8 @@ public void testCheckDefaultParameters() { NativeMemoryBasedAdmissionController.NATIVE_MEMORY_BASED_ADMISSION_CONTROLLER, null, clusterService, - Settings.EMPTY + Settings.EMPTY, + null ); assertEquals(admissionController.getName(), NativeMemoryBasedAdmissionController.NATIVE_MEMORY_BASED_ADMISSION_CONTROLLER); assertEquals(admissionController.getRejectionCount(AdmissionControlActionType.INDEXING.getType()), 0); @@ -66,7 +71,8 @@ public void testCheckDefaultLimits() { NativeMemoryBasedAdmissionController.NATIVE_MEMORY_BASED_ADMISSION_CONTROLLER, null, clusterService, - Settings.EMPTY + Settings.EMPTY, + null ); assertEquals( admissionController.getSettings().getSearchNativeMemoryUsageLimit().longValue(), @@ -87,7 +93,8 @@ public void testCheckUpdateSettings() { NativeMemoryBasedAdmissionController.NATIVE_MEMORY_BASED_ADMISSION_CONTROLLER, null, clusterService, - Settings.EMPTY + Settings.EMPTY, + null ); Settings settings = Settings.builder() .put( @@ -109,7 +116,8 @@ public void testCheckUpdateLimitSettings() { NativeMemoryBasedAdmissionController.NATIVE_MEMORY_BASED_ADMISSION_CONTROLLER, null, clusterService, - Settings.EMPTY + Settings.EMPTY, + null ); Settings settings = Settings.builder() .put(NativeMemoryBasedAdmissionControllerSettings.SEARCH_NATIVE_MEMORY_USAGE_LIMIT.getKey(), 80) @@ -126,7 +134,8 @@ public void testApplyControllerWithDefaultSettings() { NativeMemoryBasedAdmissionController.NATIVE_MEMORY_BASED_ADMISSION_CONTROLLER, rs, clusterService, - Settings.EMPTY + Settings.EMPTY, + null ); assertEquals(admissionController.getRejectionCount(AdmissionControlActionType.INDEXING.getType()), 0); assertEquals(admissionController.getSettings().getTransportLayerAdmissionControllerMode(), AdmissionControlMode.DISABLED); @@ -147,7 +156,8 @@ public void testApplyControllerWhenSettingsEnabled() { NativeMemoryBasedAdmissionController.NATIVE_MEMORY_BASED_ADMISSION_CONTROLLER, rs, clusterService, - settings + settings, + null ); assertTrue( admissionController.isEnabledForTransportLayer(admissionController.getSettings().getTransportLayerAdmissionControllerMode()) @@ -172,7 +182,8 @@ public void testApplyControllerWhenMemoryUsageBreached() { NativeMemoryBasedAdmissionController.NATIVE_MEMORY_BASED_ADMISSION_CONTROLLER, rs, clusterService, - settings + settings, + null ); // Mock node stats with native memory usage above the threshold @@ -202,7 +213,8 @@ public void testApplyControllerWhenMemoryUsageNotBreached() { NativeMemoryBasedAdmissionController.NATIVE_MEMORY_BASED_ADMISSION_CONTROLLER, rs, clusterService, - settings + settings, + null ); // Mock node stats with native memory usage below the threshold @@ -229,7 +241,8 @@ public void testApplyControllerInMonitorMode() { NativeMemoryBasedAdmissionController.NATIVE_MEMORY_BASED_ADMISSION_CONTROLLER, rs, clusterService, - settings + settings, + null ); // Mock node stats with native memory usage above the threshold @@ -255,7 +268,8 @@ public void testRejectionCount() { NativeMemoryBasedAdmissionController.NATIVE_MEMORY_BASED_ADMISSION_CONTROLLER, rs, clusterService, - settings + settings, + null ); admissionController.addRejectionCount(AdmissionControlActionType.SEARCH.getType(), 1); admissionController.addRejectionCount(AdmissionControlActionType.INDEXING.getType(), 3); @@ -266,4 +280,147 @@ public void testRejectionCount() { assertEquals(admissionController.getRejectionCount(AdmissionControlActionType.SEARCH.getType()), 2); assertEquals(admissionController.getRejectionCount(AdmissionControlActionType.INDEXING.getType()), 5); } + + private static Supplier indexingPoolSupplier(long allocatedBytes, long limitBytes) { + return () -> new NativeAllocatorPoolStats( + -1, + -1, + List.of( + new NativeAllocatorPoolStats.PoolStats( + "ingest", + allocatedBytes, + allocatedBytes, + limitBytes, + PoolGroup.INDEXING.getName(), + 0 + ) + ) + ); + } + + public void testApplyControllerWhenIndexingPoolUsageBreached() { + Settings settings = Settings.builder() + .put( + NativeMemoryBasedAdmissionControllerSettings.NATIVE_MEMORY_BASED_ADMISSION_CONTROLLER_TRANSPORT_LAYER_MODE.getKey(), + AdmissionControlMode.ENFORCED.getMode() + ) + .put(NativeMemoryBasedAdmissionControllerSettings.INDEXING_NATIVE_MEMORY_POOL_USAGE_LIMIT.getKey(), 90) + .build(); + ResourceUsageCollectorService rs = Mockito.mock(ResourceUsageCollectorService.class); + // 95 / 100 = 95% > 90% limit + admissionController = new NativeMemoryBasedAdmissionController( + NativeMemoryBasedAdmissionController.NATIVE_MEMORY_BASED_ADMISSION_CONTROLLER, + rs, + clusterService, + settings, + indexingPoolSupplier(95, 100) + ); + action = "indices:data/write/bulk[s][p]"; + expectThrows( + org.opensearch.core.concurrency.OpenSearchRejectedExecutionException.class, + () -> admissionController.apply(action, AdmissionControlActionType.INDEXING) + ); + assertEquals(admissionController.getRejectionCount(AdmissionControlActionType.INDEXING.getType()), 1); + } + + public void testApplyControllerWhenIndexingPoolUsageNotBreached() { + Settings settings = Settings.builder() + .put( + NativeMemoryBasedAdmissionControllerSettings.NATIVE_MEMORY_BASED_ADMISSION_CONTROLLER_TRANSPORT_LAYER_MODE.getKey(), + AdmissionControlMode.ENFORCED.getMode() + ) + .put(NativeMemoryBasedAdmissionControllerSettings.INDEXING_NATIVE_MEMORY_POOL_USAGE_LIMIT.getKey(), 90) + .build(); + ResourceUsageCollectorService rs = Mockito.mock(ResourceUsageCollectorService.class); + // 50 / 100 = 50% < 90% limit + admissionController = new NativeMemoryBasedAdmissionController( + NativeMemoryBasedAdmissionController.NATIVE_MEMORY_BASED_ADMISSION_CONTROLLER, + rs, + clusterService, + settings, + indexingPoolSupplier(50, 100) + ); + action = "indices:data/write/bulk[s][p]"; + admissionController.apply(action, AdmissionControlActionType.INDEXING); + assertEquals(admissionController.getRejectionCount(AdmissionControlActionType.INDEXING.getType()), 0); + } + + public void testIndexingPoolCheckSkippedWhenSupplierNull() { + Settings settings = Settings.builder() + .put( + NativeMemoryBasedAdmissionControllerSettings.NATIVE_MEMORY_BASED_ADMISSION_CONTROLLER_TRANSPORT_LAYER_MODE.getKey(), + AdmissionControlMode.ENFORCED.getMode() + ) + .put(NativeMemoryBasedAdmissionControllerSettings.INDEXING_NATIVE_MEMORY_POOL_USAGE_LIMIT.getKey(), 90) + .build(); + ResourceUsageCollectorService rs = Mockito.mock(ResourceUsageCollectorService.class); + // No stats supplier installed -> indexing-pool check is skipped entirely (current behavior preserved) + admissionController = new NativeMemoryBasedAdmissionController( + NativeMemoryBasedAdmissionController.NATIVE_MEMORY_BASED_ADMISSION_CONTROLLER, + rs, + clusterService, + settings, + null + ); + action = "indices:data/write/bulk[s][p]"; + admissionController.apply(action, AdmissionControlActionType.INDEXING); + assertEquals(admissionController.getRejectionCount(AdmissionControlActionType.INDEXING.getType()), 0); + } + + public void testIndexingPoolCheckIgnoredForSearchAction() { + Settings settings = Settings.builder() + .put( + NativeMemoryBasedAdmissionControllerSettings.NATIVE_MEMORY_BASED_ADMISSION_CONTROLLER_TRANSPORT_LAYER_MODE.getKey(), + AdmissionControlMode.ENFORCED.getMode() + ) + .put(NativeMemoryBasedAdmissionControllerSettings.INDEXING_NATIVE_MEMORY_POOL_USAGE_LIMIT.getKey(), 90) + .build(); + ResourceUsageCollectorService rs = Mockito.mock(ResourceUsageCollectorService.class); + // Indexing pool is breached (95%) but the action is SEARCH, so the pool check must not apply + admissionController = new NativeMemoryBasedAdmissionController( + NativeMemoryBasedAdmissionController.NATIVE_MEMORY_BASED_ADMISSION_CONTROLLER, + rs, + clusterService, + settings, + indexingPoolSupplier(95, 100) + ); + action = "indices:data/read/search"; + admissionController.apply(action, AdmissionControlActionType.SEARCH); + assertEquals(admissionController.getRejectionCount(AdmissionControlActionType.SEARCH.getType()), 0); + } + + public void testIndexingPoolCheckInMonitorModeCountsButDoesNotThrow() { + Settings settings = Settings.builder() + .put( + NativeMemoryBasedAdmissionControllerSettings.NATIVE_MEMORY_BASED_ADMISSION_CONTROLLER_TRANSPORT_LAYER_MODE.getKey(), + AdmissionControlMode.MONITOR.getMode() + ) + .put(NativeMemoryBasedAdmissionControllerSettings.INDEXING_NATIVE_MEMORY_POOL_USAGE_LIMIT.getKey(), 90) + .build(); + ResourceUsageCollectorService rs = Mockito.mock(ResourceUsageCollectorService.class); + admissionController = new NativeMemoryBasedAdmissionController( + NativeMemoryBasedAdmissionController.NATIVE_MEMORY_BASED_ADMISSION_CONTROLLER, + rs, + clusterService, + settings, + indexingPoolSupplier(95, 100) + ); + action = "indices:data/write/bulk[s][p]"; + admissionController.apply(action, AdmissionControlActionType.INDEXING); + assertEquals(admissionController.getRejectionCount(AdmissionControlActionType.INDEXING.getType()), 1); + } + + public void testCheckDefaultIndexingPoolUsageLimit() { + admissionController = new NativeMemoryBasedAdmissionController( + NativeMemoryBasedAdmissionController.NATIVE_MEMORY_BASED_ADMISSION_CONTROLLER, + null, + clusterService, + Settings.EMPTY, + null + ); + assertEquals( + admissionController.getSettings().getIndexingNativeMemoryPoolUsageLimit().longValue(), + NativeMemoryBasedAdmissionControllerSettings.Defaults.INDEXING_NATIVE_MEMORY_POOL_USAGE_LIMIT + ); + } } diff --git a/server/src/test/java/org/opensearch/repositories/fs/FsRepositoryTests.java b/server/src/test/java/org/opensearch/repositories/fs/FsRepositoryTests.java index 25bf485de80f8..60ac878aac79c 100644 --- a/server/src/test/java/org/opensearch/repositories/fs/FsRepositoryTests.java +++ b/server/src/test/java/org/opensearch/repositories/fs/FsRepositoryTests.java @@ -97,6 +97,7 @@ import static java.util.Collections.emptyMap; import static java.util.Collections.emptySet; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.is; public class FsRepositoryTests extends OpenSearchTestCase { @@ -265,6 +266,58 @@ public void testRestrictedSettingsDefault() { assertTrue(restrictedSettings.contains(FsRepository.LOCATION_SETTING)); } + public void testBasePathEscapingPathRepoIsRejected() { + final Path repo = createTempDir(); + final List maliciousBasePaths = List.of( + repo.toAbsolutePath().toString(), // absolute path: Path.resolve discards the validated location + "/usr/share/opensearch/data/nodes/0", // absolute path (the reported POC payload) + "..", // parent-directory traversal + "../escape", + "nested/../../escape", + "foo/../bar", // interior '..' is rejected by the strict string validator + "a/b/../c" + ); + for (String basePath : maliciousBasePaths) { + final Settings settings = Settings.builder() + .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toAbsolutePath()) + .put(Environment.PATH_REPO_SETTING.getKey(), repo.toAbsolutePath()) + .put("location", repo) + .put(FsRepository.BASE_PATH_SETTING.getKey(), basePath) + .build(); + final RepositoryMetadata metadata = new RepositoryMetadata("test", "fs", settings); + final RuntimeException e = expectThrows( + RuntimeException.class, + () -> new FsRepository( + metadata, + new Environment(settings, null), + NamedXContentRegistry.EMPTY, + BlobStoreTestUtil.mockClusterService(), + new RecoverySettings(settings, new ClusterSettings(settings, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS)) + ) + ); + assertThat("base_path [" + basePath + "] must be rejected", e.getMessage(), containsString("base_path")); + } + } + + public void testRelativeBasePathWithinPathRepoIsAccepted() { + final Path repo = createTempDir(); + final Settings settings = Settings.builder() + .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toAbsolutePath()) + .put(Environment.PATH_REPO_SETTING.getKey(), repo.toAbsolutePath()) + .put("location", repo) + .put(FsRepository.BASE_PATH_SETTING.getKey(), "nested/base/path") + .build(); + final RepositoryMetadata metadata = new RepositoryMetadata("test", "fs", settings); + // A well-formed relative base_path that stays within path.repo must construct without throwing. + new FsRepository( + metadata, + new Environment(settings, null), + NamedXContentRegistry.EMPTY, + BlobStoreTestUtil.mockClusterService(), + new RecoverySettings(settings, new ClusterSettings(settings, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS)) + ); + } + private void runGeneric(ThreadPool threadPool, Runnable runnable) throws InterruptedException { CountDownLatch latch = new CountDownLatch(1); threadPool.generic().submit(() -> { diff --git a/server/src/test/java/org/opensearch/rest/BytesRestResponseTests.java b/server/src/test/java/org/opensearch/rest/BytesRestResponseTests.java index ac81f45d5cf0e..3651d42214d83 100644 --- a/server/src/test/java/org/opensearch/rest/BytesRestResponseTests.java +++ b/server/src/test/java/org/opensearch/rest/BytesRestResponseTests.java @@ -35,6 +35,7 @@ import org.opensearch.ExceptionsHelper; import org.opensearch.OpenSearchException; import org.opensearch.OpenSearchStatusException; +import org.opensearch.OpenSearchTimeoutException; import org.opensearch.ResourceAlreadyExistsException; import org.opensearch.ResourceNotFoundException; import org.opensearch.action.OriginalIndices; @@ -248,6 +249,17 @@ public void testResponseWhenInternalServerError() throws IOException { assertThat(content, containsString("\"status\":" + 500)); } + public void testResponseWhenTimeoutException() throws IOException { + final RestRequest request = new FakeRestRequest(); + final RestChannel channel = new DetailedExceptionRestChannel(request); + final BytesRestResponse response = new BytesRestResponse(channel, new OpenSearchTimeoutException("simulated timeout")); + assertEquals(RestStatus.GATEWAY_TIMEOUT, response.status()); + assertNotNull(response.content()); + final String content = response.content().utf8ToString(); + assertThat(content, containsString("\"reason\":\"simulated timeout\"")); + assertThat(content, containsString("\"status\":" + 504)); + } + public void testErrorToAndFromXContent() throws IOException { final boolean detailed = randomBoolean(); diff --git a/server/src/test/java/org/opensearch/snapshots/RestoreServiceTests.java b/server/src/test/java/org/opensearch/snapshots/RestoreServiceTests.java index 3629f324a62e4..219fd2589a393 100644 --- a/server/src/test/java/org/opensearch/snapshots/RestoreServiceTests.java +++ b/server/src/test/java/org/opensearch/snapshots/RestoreServiceTests.java @@ -159,4 +159,36 @@ public void testValidateReplicationTypeRestoreSettings_WhenSnapshotIsSegment_Res () -> RestoreService.validateReplicationTypeRestoreSettings(snapshot, ReplicationType.SEGMENT.toString(), indexMetadata) ); } + + // Tests for internal vs user ignore settings filter separation (PR #20494) + + public void testInternalIgnoreOverridesProtection() { + var filter = RestoreService.createSettingsFilterPredicate( + new String[] {}, + new String[] { "index.remote_store.*" }, + RestoreService.getUserUnremovableSettings() + ); + // Internal pattern can filter protected settings + assertFalse(filter.test("index.remote_store.enabled")); + } + + public void testUserIgnoreRespectsProtection() { + var filter = RestoreService.createSettingsFilterPredicate( + new String[] { "index.number_of_replicas" }, + new String[] {}, + RestoreService.getUserUnremovableSettings() + ); + // User cannot filter protected settings + assertTrue(filter.test("index.number_of_replicas")); + } + + public void testUserIgnoreWorksForNonProtected() { + var filter = RestoreService.createSettingsFilterPredicate( + new String[] { "index.custom.*" }, + new String[] {}, + RestoreService.getUserUnremovableSettings() + ); + // User can filter non-protected settings + assertFalse(filter.test("index.custom.setting")); + } } diff --git a/server/src/test/java/org/opensearch/storage/action/tiering/TransportPrepareTieringActionTests.java b/server/src/test/java/org/opensearch/storage/action/tiering/TransportPrepareTieringActionTests.java index 4a6c07b4c98e7..a1f69020e5dc9 100644 --- a/server/src/test/java/org/opensearch/storage/action/tiering/TransportPrepareTieringActionTests.java +++ b/server/src/test/java/org/opensearch/storage/action/tiering/TransportPrepareTieringActionTests.java @@ -53,12 +53,14 @@ import org.mockito.ArgumentCaptor; import org.mockito.InOrder; +import static org.hamcrest.Matchers.containsString; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -143,6 +145,7 @@ private void executeShardOperation(IndexShard indexShard, ShardRouting shardRout indexShard.flush(new FlushRequest().force(true).waitIfOngoing(true)); indexShard.refresh("prepare_tiering"); indexShard.waitForRemoteStoreSync(); + indexShard.waitForReplicaSync(TimeValue.timeValueSeconds(30)); int uncommitted = indexShard.translogStats().getUncommittedOperations(); if (uncommitted > 0) { @@ -172,7 +175,8 @@ public void testShardOperation_FreezesAndDrainsMergesBeforeFlush() throws IOExce } /** - * Verifies that the shard operation calls sync, flush, refresh, and waitForRemoteStoreSync in order. + * Verifies that the shard operation calls sync, flush, refresh, waitForRemoteStoreSync, + * and waitForReplicaSync in order. */ public void testShardOperation_SyncFlushRefreshAndWaitForRemoteSync() throws IOException { mockPermitAcquisitionSuccess(); @@ -184,6 +188,28 @@ public void testShardOperation_SyncFlushRefreshAndWaitForRemoteSync() throws IOE inOrder.verify(mockIndexShard).flush(any(FlushRequest.class)); inOrder.verify(mockIndexShard).refresh("prepare_tiering"); inOrder.verify(mockIndexShard).waitForRemoteStoreSync(); + inOrder.verify(mockIndexShard).waitForReplicaSync(any(TimeValue.class)); + } + + /** + * Verifies that if waitForReplicaSync throws (replicas failed to sync in time), + * the exception propagates and the prepare action fails — allowing retry. + */ + public void testShardOperation_WaitForReplicaSyncTimeout_PropagatesFailure() throws IOException { + mockPermitAcquisitionSuccess(); + + doThrow( + new IOException( + "[REPLICA_SYNC_TIMEOUT] Shard [[clickbench][0]] replicas failed to sync within 30s. " + + "Replicas still behind: 1, max checkpoints behind: 2" + ) + ).when(mockIndexShard).waitForReplicaSync(any(TimeValue.class)); + + IOException ex = expectThrows(IOException.class, () -> executeShardOperation(mockIndexShard, primaryShardRouting)); + assertThat(ex.getMessage(), containsString("[REPLICA_SYNC_TIMEOUT]")); + + // Verify permit was released despite the exception + verify(mockPermit, timeout(5000)).close(); } /** @@ -231,7 +257,7 @@ public void testShardOperation_UncommittedOpsAfterFlush_ThrowsIOException() thro ); assertTrue("Exception message should include the count", thrown.getMessage().contains("5")); // Permit should still be released via finally block - verify(mockPermit).close(); + verify(mockPermit, timeout(5000)).close(); } /** @@ -245,7 +271,7 @@ public void testShardOperation_ReleasesPermitOnFailure() throws IOException { expectThrows(IOException.class, () -> executeShardOperation(mockIndexShard, primaryShardRouting)); // Verify permit was released despite the exception - verify(mockPermit).close(); + verify(mockPermit, timeout(5000)).close(); } /** @@ -355,8 +381,8 @@ private void executeShardOperationAsync( TimeValue effectiveTimeout = TimeValue.timeValueMillis(mergeTimeoutMillis); AtomicBoolean completed = new AtomicBoolean(false); - // Schedule timeout - Scheduler.ScheduledCancellable timeout = threadPool.schedule(() -> { + // Schedule timeout (named `timeoutTask` to avoid shadowing the Mockito.timeout static import) + Scheduler.ScheduledCancellable timeoutTask = threadPool.schedule(() -> { if (completed.compareAndSet(false, true)) { int activeMerges = indexShard.getActiveMergeCount(); boolean hasPendingMerges = indexShard.hasPendingMerges(); @@ -373,7 +399,7 @@ private void executeShardOperationAsync( // Non-blocking merge wait indexShard.onMergesDrained(() -> { if (completed.compareAndSet(false, true)) { - timeout.cancel(); + timeoutTask.cancel(); try { completeSyncAndFlushForTest(indexShard, shardRouting); listener.onResponse(null); @@ -447,7 +473,7 @@ public void onFailure(Exception e) { verify(mockIndexShard).refresh("prepare_tiering"); verify(mockIndexShard).waitForRemoteStoreSync(); - verify(mockPermit).close(); + verify(mockPermit, timeout(5000)).close(); } finally { terminate(testThreadPool); } @@ -503,7 +529,7 @@ public void onFailure(Exception e) { verify(mockIndexShard).refresh("prepare_tiering"); verify(mockIndexShard).waitForRemoteStoreSync(); - verify(mockPermit).close(); + verify(mockPermit, timeout(5000)).close(); } finally { terminate(testThreadPool); } @@ -559,7 +585,7 @@ public void onFailure(Exception e) { assertTrue("Message should report pending merges as yes", timeoutEx.getMessage().contains("pending: yes")); assertTrue("Timeout message should contain timeout value", timeoutEx.getMessage().contains("100ms")); - verify(mockPermit).close(); + verify(mockPermit, timeout(5000)).close(); } finally { terminate(testThreadPool); } @@ -729,7 +755,7 @@ public void onFailure(Exception e) { executeShardOperationAsync(mockIndexShard, primaryShardRouting, listener, testThreadPool, TimeValue.timeValueSeconds(30)); assertTrue("Listener should fire", latch.await(5, TimeUnit.SECONDS)); - verify(mockPermit).close(); + verify(mockPermit, timeout(5000)).close(); } finally { terminate(testThreadPool); } @@ -764,7 +790,7 @@ public void onFailure(Exception e) { executeShardOperationAsync(mockIndexShard, primaryShardRouting, listener, testThreadPool, TimeValue.timeValueMillis(100)); assertTrue("Listener should fire after timeout", latch.await(5, TimeUnit.SECONDS)); - verify(mockPermit).close(); + verify(mockPermit, timeout(5000)).close(); } finally { terminate(testThreadPool); } @@ -811,7 +837,7 @@ public void onFailure(Exception e) { assertNotNull("Should have received a failure", failureRef.get()); assertTrue("Should be IOException", failureRef.get() instanceof IOException); - verify(mockPermit).close(); + verify(mockPermit, timeout(5000)).close(); } finally { terminate(testThreadPool); } @@ -957,7 +983,7 @@ public void onFailure(Exception e) { assertTrue("Message should report pending merges as yes", timeoutEx.getMessage().contains("pending: yes")); assertTrue("Message should contain the configured timeout", timeoutEx.getMessage().contains("50ms")); - verify(mockPermit).close(); + verify(mockPermit, timeout(5000)).close(); } finally { terminate(testThreadPool); } @@ -1064,7 +1090,10 @@ public void onFailure(Exception e) { private TransportPrepareTieringAction newRealAction(TestThreadPool threadPool) { ClusterService mockClusterService = mock(ClusterService.class); Settings nodeSettings = Settings.EMPTY; - ClusterSettings clusterSettings = new ClusterSettings(nodeSettings, java.util.Set.of(TieringUtils.PREPARE_TIERING_TIMEOUT)); + ClusterSettings clusterSettings = new ClusterSettings( + nodeSettings, + java.util.Set.of(TieringUtils.PREPARE_TIERING_TIMEOUT, TieringUtils.REPLICA_SYNC_TIMEOUT_SETTING) + ); when(mockClusterService.getSettings()).thenReturn(nodeSettings); when(mockClusterService.getClusterSettings()).thenReturn(clusterSettings); diff --git a/server/src/test/java/org/opensearch/transport/RemoteClusterAwareClientTests.java b/server/src/test/java/org/opensearch/transport/RemoteClusterAwareClientTests.java index 7595982837365..0f259ddaaf55d 100644 --- a/server/src/test/java/org/opensearch/transport/RemoteClusterAwareClientTests.java +++ b/server/src/test/java/org/opensearch/transport/RemoteClusterAwareClientTests.java @@ -39,13 +39,17 @@ import org.opensearch.action.search.SearchRequest; import org.opensearch.cluster.node.DiscoveryNode; import org.opensearch.common.settings.Settings; +import org.opensearch.common.unit.TimeValue; import org.opensearch.common.util.concurrent.ThreadContext; import org.opensearch.core.action.ActionListener; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.transport.TransportResponse; import org.opensearch.telemetry.tracing.noop.NoopTracer; import org.opensearch.test.OpenSearchTestCase; import org.opensearch.test.transport.MockTransportService; import org.opensearch.threadpool.TestThreadPool; import org.opensearch.threadpool.ThreadPool; +import org.opensearch.transport.stream.StreamTransportResponse; import java.util.Arrays; import java.util.Collections; @@ -57,6 +61,13 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + public class RemoteClusterAwareClientTests extends OpenSearchTestCase { private final ThreadPool threadPool = new TestThreadPool(getClass().getName()); @@ -118,6 +129,145 @@ public void testSearchShards() throws Exception { } } + public void testSendStreamRequestThrowsWhenStreamTransportDisabled() throws Exception { + // The streaming send path must NOT fall back to the regular transport when the stream transport + // is unavailable — enabling streaming is a deliberate opt-in, so a missing stream transport is a + // misconfiguration that surfaces as an error (routed to the handler), not a silent pull. + List knownNodes = new CopyOnWriteArrayList<>(); + try (MockTransportService seedTransport = startTransport("seed_node", knownNodes)) { + knownNodes.add(seedTransport.getLocalDiscoNode()); + Settings.Builder builder = Settings.builder(); + builder.putList("cluster.remote.cluster1.seeds", seedTransport.getLocalDiscoNode().getAddress().toString()); + try ( + MockTransportService service = MockTransportService.createNewService( + builder.build(), + Version.CURRENT, + threadPool, + NoopTracer.INSTANCE + ) + ) { + service.start(); + service.acceptIncomingRequests(); + + // 4-arg constructor => streamTransportService is null (stream transport not enabled). + try (RemoteClusterAwareClient client = new RemoteClusterAwareClient(Settings.EMPTY, threadPool, service, "cluster1")) { + IllegalStateException e = expectThrows( + IllegalStateException.class, + () -> client.sendStreamRequest( + "internal:test/stream", + new SearchRequest("test-index"), + seedTransport.getLocalDiscoNode(), + noopStreamHandler() + ) + ); + assertTrue( + "message should explain the stream transport is not enabled, was: " + e.getMessage(), + e.getMessage().contains("stream transport is not enabled") + ); + } + } + } + } + + public void testSendStreamRequestConnectsToTargetNodeOnStreamTransport() throws Exception { + // sendStreamRequest routes through the stream transport: after ensureConnected on the remote + // cluster it connects the resolved target node on the StreamTransportService before sending. Here + // that connect fails, so we can assert (a) it targeted the requested node and (b) the failure is + // wrapped as a ConnectTransportException delivered to the handler (wrapAsTransportException). + List knownNodes = new CopyOnWriteArrayList<>(); + try (MockTransportService seedTransport = startTransport("seed_node", knownNodes)) { + DiscoveryNode seedNode = seedTransport.getLocalDiscoNode(); + knownNodes.add(seedNode); + Settings.Builder builder = Settings.builder(); + builder.putList("cluster.remote.cluster1.seeds", seedNode.getAddress().toString()); + try ( + MockTransportService service = MockTransportService.createNewService( + builder.build(), + Version.CURRENT, + threadPool, + NoopTracer.INSTANCE + ) + ) { + service.start(); + service.acceptIncomingRequests(); + + StreamTransportService streamTransportService = mock(StreamTransportService.class); + when(streamTransportService.getStreamTransportReqTimeout()).thenReturn(TimeValue.timeValueSeconds(30)); + // connectToNode (non-final, stream-transport-specific) fails synchronously. + doAnswer(inv -> { + ActionListener l = inv.getArgument(2); + l.onFailure(new RuntimeException("no stream route")); + return null; + }).when(streamTransportService).connectToNode(any(DiscoveryNode.class), any(), any()); + + CountDownLatch failed = new CountDownLatch(1); + AtomicReference handlerError = new AtomicReference<>(); + try ( + RemoteClusterAwareClient client = new RemoteClusterAwareClient( + Settings.EMPTY, + threadPool, + service, + "cluster1", + streamTransportService + ) + ) { + client.sendStreamRequest( + "internal:test/stream", + new SearchRequest("test-index"), + seedNode, + new StreamTransportResponseHandler<>() { + @Override + public void handleStreamResponse(StreamTransportResponse s) {} + + @Override + public void handleException(TransportException exp) { + handlerError.set(exp); + failed.countDown(); + } + + @Override + public String executor() { + return ThreadPool.Names.SAME; + } + + @Override + public TransportResponse read(StreamInput in) { + return null; + } + } + ); + + assertTrue("handler must be notified of the connect failure", failed.await(5, TimeUnit.SECONDS)); + verify(streamTransportService).connectToNode(eq(seedNode), any(), any()); + assertTrue( + "connect failure must be wrapped as ConnectTransportException, was: " + handlerError.get(), + handlerError.get() instanceof ConnectTransportException + ); + } + } + } + } + + private static StreamTransportResponseHandler noopStreamHandler() { + return new StreamTransportResponseHandler<>() { + @Override + public void handleStreamResponse(StreamTransportResponse s) {} + + @Override + public void handleException(TransportException exp) {} + + @Override + public String executor() { + return ThreadPool.Names.SAME; + } + + @Override + public TransportResponse read(StreamInput in) { + return null; + } + }; + } + public void testSearchShardsThreadContextHeader() { List knownNodes = new CopyOnWriteArrayList<>(); try ( diff --git a/server/src/test/java/org/opensearch/transport/TaskTransportChannelTests.java b/server/src/test/java/org/opensearch/transport/TaskTransportChannelTests.java new file mode 100644 index 0000000000000..a5656dde17228 --- /dev/null +++ b/server/src/test/java/org/opensearch/transport/TaskTransportChannelTests.java @@ -0,0 +1,53 @@ +/* + * 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.transport; + +import org.opensearch.core.transport.TransportResponse; +import org.opensearch.test.OpenSearchTestCase; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * TaskTransportChannel wraps the real channel for task tracking, so it must forward the streaming + * methods to the delegate — otherwise a task-tracked streaming action (the normal registration path) + * can't reach them. In particular the {@code sendResponseBatch(response, sync)} and {@code isCancelled()} + * calls must land on the wrapped channel, not on the base default (which no-ops / throws). + */ +public class TaskTransportChannelTests extends OpenSearchTestCase { + + private TransportChannel delegate; + private TaskTransportChannel channel; + + @Override + public void setUp() throws Exception { + super.setUp(); + delegate = mock(TransportChannel.class); + channel = new TaskTransportChannel(delegate, () -> {}); + } + + public void testSyncSendResponseBatchIsForwarded() { + TransportResponse response = mock(TransportResponse.class); + channel.sendResponseBatch(response, true); + verify(delegate).sendResponseBatch(response, true); + } + + public void testAsyncSendResponseBatchIsForwarded() { + TransportResponse response = mock(TransportResponse.class); + channel.sendResponseBatch(response); + verify(delegate).sendResponseBatch(response); + } + + public void testIsCancelledIsForwarded() { + when(delegate.isCancelled()).thenReturn(true); + assertTrue(channel.isCancelled()); + verify(delegate).isCancelled(); + } +} diff --git a/test/framework/licenses/lucene-codecs-10.4.0.jar.sha1 b/test/framework/licenses/lucene-codecs-10.4.0.jar.sha1 deleted file mode 100644 index 8b573c09b0a4c..0000000000000 --- a/test/framework/licenses/lucene-codecs-10.4.0.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -426ad64f5810e04a533f335aaeca4bbca3c746e1 \ No newline at end of file diff --git a/test/framework/licenses/lucene-codecs-10.5.0.jar.sha1 b/test/framework/licenses/lucene-codecs-10.5.0.jar.sha1 new file mode 100644 index 0000000000000..2409ee3ff1a8f --- /dev/null +++ b/test/framework/licenses/lucene-codecs-10.5.0.jar.sha1 @@ -0,0 +1 @@ +d128c42988203efb7c3a2c7fb8beb788cb578dae \ No newline at end of file diff --git a/test/framework/licenses/lucene-test-framework-10.4.0.jar.sha1 b/test/framework/licenses/lucene-test-framework-10.4.0.jar.sha1 deleted file mode 100644 index 8ba10fe22ab60..0000000000000 --- a/test/framework/licenses/lucene-test-framework-10.4.0.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -2120a695bcd5c88882289bc77ed2b65d36469521 \ No newline at end of file diff --git a/test/framework/licenses/lucene-test-framework-10.5.0.jar.sha1 b/test/framework/licenses/lucene-test-framework-10.5.0.jar.sha1 new file mode 100644 index 0000000000000..891942e6fa1ec --- /dev/null +++ b/test/framework/licenses/lucene-test-framework-10.5.0.jar.sha1 @@ -0,0 +1 @@ +fbca09b1ae940d26b6c3a90befecda40d5f1c3c2 \ No newline at end of file diff --git a/test/framework/src/main/java/org/opensearch/cluster/OpenSearchAllocationTestCase.java b/test/framework/src/main/java/org/opensearch/cluster/OpenSearchAllocationTestCase.java index e3bc374aa2db2..2ba4bf631dac2 100644 --- a/test/framework/src/main/java/org/opensearch/cluster/OpenSearchAllocationTestCase.java +++ b/test/framework/src/main/java/org/opensearch/cluster/OpenSearchAllocationTestCase.java @@ -55,6 +55,7 @@ import org.opensearch.gateway.GatewayAllocator; import org.opensearch.snapshots.SnapshotShardSizeInfo; import org.opensearch.snapshots.SnapshotsInfoService; +import org.opensearch.telemetry.metrics.noop.NoopMetricsRegistry; import org.opensearch.test.OpenSearchTestCase; import org.opensearch.test.gateway.TestGatewayAllocator; @@ -106,7 +107,8 @@ public static MockAllocationService createAllocationService(Settings settings, C new TestGatewayAllocator(), new BalancedShardsAllocator(settings), EmptyClusterInfoService.INSTANCE, - SNAPSHOT_INFO_SERVICE_WITH_NO_SHARD_SIZES + SNAPSHOT_INFO_SERVICE_WITH_NO_SHARD_SIZES, + settings ); } @@ -116,7 +118,8 @@ public static MockAllocationService createAllocationService(Settings settings, C new TestGatewayAllocator(), new BalancedShardsAllocator(settings), clusterInfoService, - SNAPSHOT_INFO_SERVICE_WITH_NO_SHARD_SIZES + SNAPSHOT_INFO_SERVICE_WITH_NO_SHARD_SIZES, + settings ); } @@ -138,7 +141,8 @@ public static MockAllocationService createAllocationService( gatewayAllocator, new BalancedShardsAllocator(settings), EmptyClusterInfoService.INSTANCE, - snapshotsInfoService + snapshotsInfoService, + settings ); } @@ -153,7 +157,8 @@ public static MockAllocationService createAllocationService( gatewayAllocator, new BalancedShardsAllocator(settings, clusterSettings), EmptyClusterInfoService.INSTANCE, - snapshotsInfoService + snapshotsInfoService, + settings ); } @@ -489,6 +494,25 @@ public MockAllocationService( super(allocationDeciders, gatewayAllocator, shardsAllocator, clusterInfoService, snapshotsInfoService); } + public MockAllocationService( + AllocationDeciders allocationDeciders, + GatewayAllocator gatewayAllocator, + ShardsAllocator shardsAllocator, + ClusterInfoService clusterInfoService, + SnapshotsInfoService snapshotsInfoService, + Settings settings + ) { + super( + allocationDeciders, + shardsAllocator, + clusterInfoService, + snapshotsInfoService, + settings, + new ClusterManagerMetrics(NoopMetricsRegistry.INSTANCE) + ); + setExistingShardsAllocators(Collections.singletonMap(GatewayAllocator.ALLOCATOR_NAME, gatewayAllocator)); + } + public void setNanoTimeOverride(long nanoTime) { this.nanoTimeOverride = nanoTime; } diff --git a/test/framework/src/main/java/org/opensearch/repositories/blobstore/OpenSearchBlobStoreRepositoryIntegTestCase.java b/test/framework/src/main/java/org/opensearch/repositories/blobstore/OpenSearchBlobStoreRepositoryIntegTestCase.java index 3b76b94670ba8..4f1f204113dc0 100644 --- a/test/framework/src/main/java/org/opensearch/repositories/blobstore/OpenSearchBlobStoreRepositoryIntegTestCase.java +++ b/test/framework/src/main/java/org/opensearch/repositories/blobstore/OpenSearchBlobStoreRepositoryIntegTestCase.java @@ -124,6 +124,8 @@ protected final String createRepository(final String name, final Settings settin } public void testReadNonExistingPath() throws IOException { + ensureGreen(); + try (BlobStore store = newBlobStore()) { final BlobContainer container = store.blobContainer(new BlobPath()); expectThrows(NoSuchFileException.class, () -> { @@ -135,6 +137,8 @@ public void testReadNonExistingPath() throws IOException { } public void testWriteRead() throws IOException { + ensureGreen(); + try (BlobStore store = newBlobStore()) { final BlobContainer container = store.blobContainer(new BlobPath()); byte[] data = randomBytes(randomIntBetween(10, scaledRandomIntBetween(1024, 1 << 16))); @@ -164,6 +168,8 @@ public void testWriteRead() throws IOException { } public void testReadRange() throws IOException { + ensureGreen(); + try (BlobStore store = newBlobStore()) { final BlobContainer container = store.blobContainer(new BlobPath()); final byte[] data = randomBytes(4096); @@ -185,6 +191,8 @@ public void testReadRange() throws IOException { } public void testList() throws IOException { + ensureGreen(); + try (BlobStore store = newBlobStore()) { final BlobContainer container = store.blobContainer(new BlobPath()); assertThat(container.listBlobs().size(), CoreMatchers.equalTo(0)); @@ -225,6 +233,8 @@ public void testList() throws IOException { } public void testDeleteBlobs() throws IOException { + ensureGreen(); + try (BlobStore store = newBlobStore()) { final List blobNames = Arrays.asList("foobar", "barfoo"); final BlobContainer container = store.blobContainer(new BlobPath()); @@ -257,6 +267,8 @@ public static void writeBlob( } public void testContainerCreationAndDeletion() throws IOException { + ensureGreen(); + try (BlobStore store = newBlobStore()) { final BlobContainer containerFoo = store.blobContainer(new BlobPath().add("foo")); final BlobContainer containerBar = store.blobContainer(new BlobPath().add("bar")); @@ -315,6 +327,8 @@ protected BlobStore newBlobStore() { } public void testSnapshotAndRestore() throws Exception { + ensureGreen(); + final String repoName = createRepository(randomName()); int indexCount = randomIntBetween(1, 5); int[] docCounts = new int[indexCount]; @@ -391,6 +405,8 @@ public void testSnapshotAndRestore() throws Exception { } public void testMultipleSnapshotAndRollback() throws Exception { + ensureGreen(); + final String repoName = createRepository(randomName()); int iterationCount = randomIntBetween(2, 5); int[] docCounts = new int[iterationCount]; @@ -455,6 +471,8 @@ public void testMultipleSnapshotAndRollback() throws Exception { } public void testIndicesDeletedFromRepository() throws Exception { + ensureGreen(); + final String repoName = createRepository("test-repo-" + randomAlphaOfLength(8)); Client client = client(); createIndex("test-idx-1", "test-idx-2", "test-idx-3"); diff --git a/test/framework/src/main/java/org/opensearch/repositories/blobstore/OpenSearchMockAPIBasedRepositoryIntegTestCase.java b/test/framework/src/main/java/org/opensearch/repositories/blobstore/OpenSearchMockAPIBasedRepositoryIntegTestCase.java index 50dae0a6741a8..52890c354e902 100644 --- a/test/framework/src/main/java/org/opensearch/repositories/blobstore/OpenSearchMockAPIBasedRepositoryIntegTestCase.java +++ b/test/framework/src/main/java/org/opensearch/repositories/blobstore/OpenSearchMockAPIBasedRepositoryIntegTestCase.java @@ -156,6 +156,8 @@ public void tearDownHttpServer() { * Test the snapshot and restore of an index which has large segments files. */ public void testSnapshotWithLargeSegmentFiles() throws Exception { + ensureGreen(); + final String repository = createRepository(randomName()); final String index = "index-no-merges"; createIndex( @@ -188,6 +190,8 @@ public void testSnapshotWithLargeSegmentFiles() throws Exception { } public void testRequestStats() throws Exception { + ensureGreen(); + final String repository = createRepository(randomName()); final String index = "index-no-merges"; createIndex(