This is an automated email from the ASF dual-hosted git repository.
yashmayya pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git
The following commit(s) were added to refs/heads/master by this push:
new 6d5cdfb93ab Verify the Java 11 pinned client artifacts actually run on
a Java 11 JVM in CI (#19102)
6d5cdfb93ab is described below
commit 6d5cdfb93ab6513bb02c31e1172d2604899698ef
Author: Yash Mayya <[email protected]>
AuthorDate: Tue Jul 28 17:06:37 2026 -0400
Verify the Java 11 pinned client artifacts actually run on a Java 11 JVM in
CI (#19102)
---
.../pinot_java11_client_compatibility.yml | 114 +++
.../scripts/.pinot_java11_client_compat.sh | 97 +++
pinot-java11-client-verifier/pom.xml | 140 ++++
.../pinot/java11/CannedResponseTransport.java | 73 ++
.../pinot/java11/ClasspathClosureScanner.java | 334 +++++++++
.../pinot/java11/Java11CompatibilityVerifier.java | 803 +++++++++++++++++++++
.../src/main/resources/sample-broker-response.json | 53 ++
.../pinot/java11/ClasspathClosureScannerTest.java | 367 ++++++++++
pom.xml | 1 +
9 files changed, 1982 insertions(+)
diff --git a/.github/workflows/pinot_java11_client_compatibility.yml
b/.github/workflows/pinot_java11_client_compatibility.yml
new file mode 100644
index 00000000000..44598d5d982
--- /dev/null
+++ b/.github/workflows/pinot_java11_client_compatibility.yml
@@ -0,0 +1,114 @@
+#
+# 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.
+#
+
+name: Pinot Java 11 Client Compatibility
+
+on:
+ push:
+ branches:
+ - master
+ paths-ignore:
+ - "contrib/**"
+ - "docs/**"
+ - "docker/**"
+ - "kubernetes/**"
+ - "licenses/**"
+ - "licenses-binary/**"
+ - "**.md"
+ pull_request:
+ branches:
+ - master
+ paths-ignore:
+ - "contrib/**"
+ - "docs/**"
+ - "docker/**"
+ - "kubernetes/**"
+ - "licenses/**"
+ - "licenses-binary/**"
+ - "**.md"
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number ||
github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ # pinot-spi, pinot-segment-spi, pinot-timeseries-spi, pinot-common,
pinot-java-client and
+ # pinot-jdbc-client hard-code <release>11</release> so that third-party
plugins and applications
+ # embedding the Java/JDBC client are not forced onto the JDK that Pinot's
services require.
+ #
+ # --release 11 keeps Pinot's own code in those modules Java 11 clean, but
nothing checks their
+ # transitive dependency closure. A routine dependency bump can drop a Java
17+ jar into the client
+ # classpath, and today the first sign of that would be a user reporting
+ # UnsupportedClassVersionError. This job loads and exercises the clients on
a real Java 11 JVM.
+ java11-client-compatibility:
+ if: github.repository == 'apache/pinot'
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ # build-java is the JDK the artifacts are compiled with; the verifier
always runs on the
+ # Java version the clients are pinned to.
+ build-java: [ 25 ]
+ distribution: [ "temurin" ]
+ name: Pinot Java 11 Client Compatibility (built on JDK ${{
matrix.build-java }}-${{ matrix.distribution }})
+ steps:
+ - uses: actions/checkout@v7
+ # Installed first so that the build JDK below ends up as JAVA_HOME.
setup-java also exports
+ # JAVA_HOME_11_<arch>, which the script falls back on if this step's
output is unavailable.
+ - name: Set up Java 11 (verification runtime)
+ id: java11
+ uses: actions/setup-java@v5
+ with:
+ java-version: 11
+ distribution: ${{ matrix.distribution }}
+ - name: Set up JDK ${{ matrix.build-java }} (build)
+ uses: actions/setup-java@v5
+ with:
+ java-version: ${{ matrix.build-java }}
+ distribution: ${{ matrix.distribution }}
+ cache: 'maven'
+ - uses: actions/cache@v6
+ env:
+ SEGMENT_DOWNLOAD_TIMEOUT_MINS: 10
+ with:
+ path: ~/.m2/repository
+ key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
+ restore-keys: |
+ ${{ runner.os }}-maven-
+ - name: Verify the Java 11 clients on a Java 11 JVM
+ timeout-minutes: 40
+ env:
+ JAVA11_HOME: ${{ steps.java11.outputs.path }}
+ DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
+ # Same download hardening the other Maven jobs use, so a transient
Central hiccup retries
+ # here too instead of failing the job.
+ #
+ # Only JVM arguments and -D system properties belong here:
MAVEN_OPTS is handed to the JVM
+ # that runs Maven, not to Maven itself. The peer jobs also list -B
and -ntp, which the JVM
+ # rejects outright ("Unrecognized option: -B") and which only
survive there because those
+ # jobs additionally pass -XX:+IgnoreUnrecognizedVMOptions. The
script passes -B -ntp on the
+ # mvn command line where they belong, so they are left out rather
than masked.
+ #
+ # Deliberately no -DskipShade either: the script passes
-Dshade.phase.prop=none, which
+ # disables shading for both client modules (-DskipShade only covers
pinot-jdbc-client).
+ MAVEN_OPTS: >
+ -Xmx2G -DfailIfNoTests=false
-Dmaven.wagon.httpconnectionManager.ttlSeconds=25
+ -Dmaven.wagon.http.retryHandler.count=30 -Dhttp.keepAlive=false
-Dmaven.wagon.http.pool=false
+ run: |
+ .github/workflows/scripts/.pinot_java11_client_compat.sh
diff --git a/.github/workflows/scripts/.pinot_java11_client_compat.sh
b/.github/workflows/scripts/.pinot_java11_client_compat.sh
new file mode 100755
index 00000000000..8f8399d62a2
--- /dev/null
+++ b/.github/workflows/scripts/.pinot_java11_client_compat.sh
@@ -0,0 +1,97 @@
+#!/bin/bash -x
+#
+# 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.
+#
+# Verifies that Pinot's Java-11-pinned client and SPI artifacts work on a Java
11 JVM.
+#
+# The build cannot run on Java 11 -- the root pom enforces requireJavaVersion
[25,) and Pinot's
+# services have a genuine Java 25 floor. So this is necessarily a two-JDK job:
build the artifacts
+# with the build JDK, then run the verifier under Java 11.
+#
+# Environment:
+# JAVA11_HOME Optional. Home of the JVM to verify against. Falls back to the
+# JAVA_HOME_11_<arch> variables that GitHub-hosted runners
export.
+# MVN Optional. Maven command, defaults to "mvn".
+
+TARGET_JAVA_VERSION=11
+VERIFIER_MODULE="pinot-java11-client-verifier"
+VERIFIER_MAIN_CLASS="org.apache.pinot.java11.Java11CompatibilityVerifier"
+MVN="${MVN:-mvn}"
+
+# Build JDK, for the record.
+java -version
+
+# GitHub-hosted runners export JAVA_HOME_<version>_<arch> for every JDK that
setup-java installed.
+# The arch suffix differs between x64 and arm64 runners, so try both rather
than assuming.
+if [ -z "${JAVA11_HOME}" ]; then
+ JAVA11_HOME="${JAVA_HOME_11_X64:-${JAVA_HOME_11_ARM64:-}}"
+fi
+if [ -z "${JAVA11_HOME}" ]; then
+ echo "No Java ${TARGET_JAVA_VERSION} JVM found. Set JAVA11_HOME, or install
one with actions/setup-java" \
+ "and pass its path through."
+ exit 1
+fi
+
+JAVA11_BIN="${JAVA11_HOME}/bin/java"
+if [ ! -x "${JAVA11_BIN}" ]; then
+ echo "Not an executable JVM launcher: ${JAVA11_BIN}"
+ exit 1
+fi
+"${JAVA11_BIN}" -version || exit 1
+
+# Build the verifier and everything it depends on, which is exactly the six
Java-11-pinned modules
+# and the third-party closure underneath them. Linting is covered by the
linter job, so skip it here.
+#
+# -Dshade.phase.prop=none matters: pinot-java-client and pinot-jdbc-client
each produce a ~150 MB
+# shaded jar, and shadedArtifactAttached=true means those jars never even
appear on the runtime
+# classpath this job verifies. Without the flag the job spends minutes
building them and then pushes
+# 300 MB into ~/.m2, which actions/cache uploads under a key the other Maven
jobs share. Note that
+# -DskipShade=true is not enough: it only deactivates the pinot-jdbc-client
profile, while
+# pinot-java-client sets shade.phase.prop=package unconditionally.
+${MVN} clean install -B -ntp -T1C -pl "${VERIFIER_MODULE}" -am \
+ -DskipTests \
+ -Dshade.phase.prop=none \
+ -Dmaven.javadoc.skip=true \
+ -Dlicense.skip=true \
+ -Dcheckstyle.skip=true \
+ -Dspotless.check.skip=true || exit 1
+
+CLASSPATH_FILE="${VERIFIER_MODULE}/target/runtime-classpath.txt"
+if [ ! -s "${CLASSPATH_FILE}" ]; then
+ echo "Expected the build to write the resolved runtime closure to
${CLASSPATH_FILE}"
+ exit 1
+fi
+
+VERIFIER_CLASSPATH="${VERIFIER_MODULE}/target/classes:$(cat
"${CLASSPATH_FILE}")"
+
+# Tracing off for the run itself: echoing a few hundred jar paths buries the
verifier's own output.
+set +x
+echo "Running ${VERIFIER_MAIN_CLASS} on Java ${TARGET_JAVA_VERSION} against a
closure of" \
+ "$(tr ':' '\n' <<< "${VERIFIER_CLASSPATH}" | wc -l | tr -d ' ') classpath
entries"
+
+# No --add-opens or -Dio.netty.tryReflectionSetAccessible here on purpose.
Those flags exist in
+# Pinot's own launch scripts for JDK 17+, and adding them would paper over
exactly the kind of
+# runtime breakage this job is meant to catch. The verifier asserts it is
really running on Java
+# ${TARGET_JAVA_VERSION}, so a mis-wired JDK fails the job instead of passing
it vacuously.
+if ! "${JAVA11_BIN}" -cp "${VERIFIER_CLASSPATH}" "${VERIFIER_MAIN_CLASS}"
"${TARGET_JAVA_VERSION}"; then
+ # Hosted runners discard the workspace, so dump the closure that was
verified while we still can.
+ echo
+ echo "Verification failed. The runtime closure that was verified:"
+ tr ':' '\n' <<< "${VERIFIER_CLASSPATH}"
+ exit 1
+fi
diff --git a/pinot-java11-client-verifier/pom.xml
b/pinot-java11-client-verifier/pom.xml
new file mode 100644
index 00000000000..410cd519ba3
--- /dev/null
+++ b/pinot-java11-client-verifier/pom.xml
@@ -0,0 +1,140 @@
+<?xml version="1.0"?>
+<!--
+
+ 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.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <parent>
+ <artifactId>pinot</artifactId>
+ <groupId>org.apache.pinot</groupId>
+ <version>1.6.0-SNAPSHOT</version>
+ </parent>
+ <artifactId>pinot-java11-client-verifier</artifactId>
+ <name>Pinot Java 11 Client Verifier</name>
+ <url>https://pinot.apache.org/</url>
+ <packaging>jar</packaging>
+
+ <properties>
+ <pinot.root>${basedir}/..</pinot.root>
+ <!--
+ A CI harness, not something a consumer should depend on. Keep it out of
the published
+ artifacts; nothing in the reactor depends on it either.
+ -->
+ <maven.deploy.skip>true</maven.deploy.skip>
+ </properties>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-compiler-plugin</artifactId>
+ <configuration>
+ <!-- Load-bearing rather than copied boilerplate:
.pinot_java11_client_compat.sh launches
+ this module's own classes on a Java 11 JVM, so its bytecode has
to be Java 11 as well.
+ Hard-coded literal so -Djdk.version=21 on the CLI cannot
silently bump the level. -->
+ <release>11</release>
+ <source>11</source>
+ <target>11</target>
+ </configuration>
+ </plugin>
+ <plugin>
+ <!--
+ Writes the resolved runtime closure of this module (the union of the
pinot-java-client and
+ pinot-jdbc-client closures) to a file, so the CI driver script can
hand exactly that
+ classpath to a Java 11 JVM.
+ -->
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-dependency-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>write-runtime-classpath</id>
+ <phase>prepare-package</phase>
+ <goals>
+ <goal>build-classpath</goal>
+ </goals>
+ <configuration>
+ <includeScope>runtime</includeScope>
+
<outputFile>${project.build.directory}/runtime-classpath.txt</outputFile>
+ <regenerateFile>true</regenerateFile>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+
+ <dependencies>
+ <!-- The two consumer-facing clients. Depending on both makes this
module's runtime closure the
+ union of theirs, which is what gets scanned for bytecode a Java 11
JVM cannot load. -->
+ <dependency>
+ <groupId>org.apache.pinot</groupId>
+ <artifactId>pinot-java-client</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.pinot</groupId>
+ <artifactId>pinot-jdbc-client</artifactId>
+ </dependency>
+
+ <!-- The remaining Java-11-pinned modules. All four already arrive
transitively, so declaring
+ them changes nothing about the closure; it is done because the
verifier imports them
+ directly, and so that dropping one breaks the build instead of
silently shrinking coverage.
+ Java11CompatibilityVerifier also asserts at runtime that all six are
on the classpath. -->
+ <dependency>
+ <groupId>org.apache.pinot</groupId>
+ <artifactId>pinot-common</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.pinot</groupId>
+ <artifactId>pinot-spi</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.pinot</groupId>
+ <artifactId>pinot-segment-spi</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.pinot</groupId>
+ <artifactId>pinot-timeseries-spi</artifactId>
+ </dependency>
+
+ <!-- Third-party types the verifier constructs directly. -->
+ <dependency>
+ <groupId>org.apache.helix</groupId>
+ <artifactId>helix-core</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>com.google.protobuf</groupId>
+ <artifactId>protobuf-java</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>com.fasterxml.jackson.core</groupId>
+ <artifactId>jackson-databind</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>io.grpc</groupId>
+ <artifactId>grpc-api</artifactId>
+ </dependency>
+
+ <dependency>
+ <groupId>org.testng</groupId>
+ <artifactId>testng</artifactId>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+</project>
diff --git
a/pinot-java11-client-verifier/src/main/java/org/apache/pinot/java11/CannedResponseTransport.java
b/pinot-java11-client-verifier/src/main/java/org/apache/pinot/java11/CannedResponseTransport.java
new file mode 100644
index 00000000000..65619cdd68b
--- /dev/null
+++
b/pinot-java11-client-verifier/src/main/java/org/apache/pinot/java11/CannedResponseTransport.java
@@ -0,0 +1,73 @@
+/**
+ * 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.
+ */
+package org.apache.pinot.java11;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import java.util.concurrent.CompletableFuture;
+import javax.annotation.Nullable;
+import org.apache.pinot.client.BrokerResponse;
+import org.apache.pinot.client.PinotClientException;
+import org.apache.pinot.client.PinotClientTransport;
+
+
+/// A {@link PinotClientTransport} that replays a fixed broker response
instead of talking to a broker, so the verifier
+/// can drive the real {@code Connection} / {@code ResultSetGroup} / JDBC code
paths without standing up a cluster.
+///
+/// Records the last query it was handed so callers can assert that query
construction
+/// (for example {@code PreparedStatement} parameter binding) happened on the
way in.
+///
+/// Not thread-safe: the verifier drives it from a single thread.
+final class CannedResponseTransport implements PinotClientTransport<Void> {
+ private final JsonNode _cannedResponse;
+ private String _lastQuery;
+ private boolean _closed;
+
+ CannedResponseTransport(JsonNode cannedResponse) {
+ _cannedResponse = cannedResponse;
+ }
+
+ @Override
+ public BrokerResponse executeQuery(String brokerAddress, String query)
+ throws PinotClientException {
+ _lastQuery = query;
+ return BrokerResponse.fromJson(_cannedResponse);
+ }
+
+ @Override
+ public CompletableFuture<BrokerResponse> executeQueryAsync(String
brokerAddress, String query)
+ throws PinotClientException {
+ return CompletableFuture.completedFuture(executeQuery(brokerAddress,
query));
+ }
+
+ @Override
+ public void close()
+ throws PinotClientException {
+ _closed = true;
+ }
+
+ /// The last query handed to this transport, or null if it has not been
asked to execute one yet.
+ @Nullable
+ String getLastQuery() {
+ return _lastQuery;
+ }
+
+ boolean isClosed() {
+ return _closed;
+ }
+}
diff --git
a/pinot-java11-client-verifier/src/main/java/org/apache/pinot/java11/ClasspathClosureScanner.java
b/pinot-java11-client-verifier/src/main/java/org/apache/pinot/java11/ClasspathClosureScanner.java
new file mode 100644
index 00000000000..c691c70edbd
--- /dev/null
+++
b/pinot-java11-client-verifier/src/main/java/org/apache/pinot/java11/ClasspathClosureScanner.java
@@ -0,0 +1,334 @@
+/**
+ * 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.
+ */
+package org.apache.pinot.java11;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.io.DataInputStream;
+import java.io.EOFException;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.TreeMap;
+import java.util.jar.Attributes;
+import java.util.jar.Manifest;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipException;
+import java.util.zip.ZipFile;
+
+
+/// Scans every entry of a classpath and reports any class file whose bytecode
version is newer than a given Java
+/// feature release can load.
+///
+/// This complements the hard-coded compiler {@code release} of 11 on Pinot's
client and SPI modules. That pin
+/// guarantees Pinot's _own_ bytecode is Java 11 clean, but says nothing about
the transitive third-party closure those
+/// modules drag in. A dependency bump that pulls in a Java 17+ jar would
otherwise only be discovered by a user hitting
+/// {@link UnsupportedClassVersionError} at runtime.
+///
+/// Two categories of entry are deliberately _not_ violations, because a Java
11 JVM never loads them:
+/// - {@code module-info.class} descriptors, which are compiled at the
version of the JDK that built the jar and are
+/// ignored entirely on the classpath.
+/// - Entries under {@code META-INF/versions/<n>/} that this JVM would not
select. A JVM only performs multi-release
+/// lookup at all when the jar manifest carries {@code Multi-Release:
true}, so in a jar without that attribute
+/// every versioned entry is an inert resource and is skipped outright. In
a genuine multi-release jar, only the
+/// entries at or below the target release are selected, so entries above
it are skipped and the rest are checked.
+/// Directory classpath entries never get multi-release treatment, so
their versioned entries are skipped too.
+///
+/// This class is not thread-safe; a {@link Result} is produced by a single
{@link #scan} call.
+final class ClasspathClosureScanner {
+ /// Class file major version for Java 11 is 55; each later feature release
adds one.
+ private static final int CLASS_FILE_MAJOR_VERSION_OFFSET = 44;
+ private static final int CLASS_FILE_MAGIC = 0xCAFEBABE;
+ private static final String MULTI_RELEASE_PREFIX = "META-INF/versions/";
+ private static final String MODULE_INFO_ENTRY = "module-info.class";
+ private static final String MANIFEST_ENTRY = "META-INF/MANIFEST.MF";
+ /// Cap the reported violations so a wholesale mistake does not produce
megabytes of CI log.
+ @VisibleForTesting
+ static final int MAX_REPORTED_VIOLATIONS = 40;
+
+ private ClasspathClosureScanner() {
+ }
+
+ /// A single class file that a JVM at the target feature release could not
load.
+ static final class Violation {
+ private final String _classpathEntry;
+ private final String _classFile;
+ private final int _majorVersion;
+
+ Violation(String classpathEntry, String classFile, int majorVersion) {
+ _classpathEntry = classpathEntry;
+ _classFile = classFile;
+ _majorVersion = majorVersion;
+ }
+
+ @Override
+ public String toString() {
+ return String.format("%s!/%s (class file major version %d, needs Java
%d)", _classpathEntry, _classFile,
+ _majorVersion, _majorVersion - CLASS_FILE_MAJOR_VERSION_OFFSET);
+ }
+ }
+
+ /// Outcome of a scan, including enough counters to tell a real pass from a
vacuous one.
+ static final class Result {
+ private final List<Violation> _reportedViolations = new ArrayList<>();
+ private final List<String> _archiveNames = new ArrayList<>();
+ private final List<String> _skippedEntries = new ArrayList<>();
+ private final Map<Integer, Integer> _majorVersionHistogram = new
TreeMap<>();
+ private int _totalViolationCount;
+ private int _archivesScanned;
+ private int _directoriesScanned;
+ private int _classFilesInArchives;
+ private int _classFilesInDirectories;
+
+ /// The first {@value #MAX_REPORTED_VIOLATIONS} violations, so a wholesale
mistake does not produce megabytes of CI
+ /// log. {@link #getTotalViolationCount()} is the authoritative count.
+ List<Violation> getReportedViolations() {
+ return _reportedViolations;
+ }
+
+ int getTotalViolationCount() {
+ return _totalViolationCount;
+ }
+
+ /// File names of every archive that was opened, for asserting which
artifacts were covered.
+ List<String> getArchiveNames() {
+ return _archiveNames;
+ }
+
+ /// Classpath entries that carried no bytecode to check, each with a
reason. Callers should treat a non-empty list
+ /// as suspicious rather than routine -- see the allow-list in the
verifier.
+ List<String> getSkippedEntries() {
+ return _skippedEntries;
+ }
+
+ int getArchivesScanned() {
+ return _archivesScanned;
+ }
+
+ int getDirectoriesScanned() {
+ return _directoriesScanned;
+ }
+
+ /// Class files inspected inside jars, counted separately from {@link
#getClassFilesInDirectories()} so a vacuity
+ /// guard can assert that third-party bytecode was actually looked at.
Counting both together would let the
+ /// verifier's own {@code target/classes} satisfy the guard on its own.
+ int getClassFilesInArchives() {
+ return _classFilesInArchives;
+ }
+
+ int getClassFilesInDirectories() {
+ return _classFilesInDirectories;
+ }
+
+ /// Major version to class file count, so the CI log shows what the scan
actually looked at. A pass whose histogram
+ /// is empty, or whose highest bucket is implausibly low, is a broken scan
rather than a clean closure.
+ Map<Integer, Integer> getMajorVersionHistogram() {
+ return _majorVersionHistogram;
+ }
+
+ private void record(String classpathEntry, String classFile, int
majorVersion, int maxAllowedMajorVersion,
+ boolean insideArchive) {
+ if (insideArchive) {
+ _classFilesInArchives++;
+ } else {
+ _classFilesInDirectories++;
+ }
+ _majorVersionHistogram.merge(majorVersion, 1, Integer::sum);
+ if (majorVersion > maxAllowedMajorVersion) {
+ _totalViolationCount++;
+ if (_reportedViolations.size() < MAX_REPORTED_VIOLATIONS) {
+ _reportedViolations.add(new Violation(classpathEntry, classFile,
majorVersion));
+ }
+ }
+ }
+ }
+
+ /// Scans {@code classpath} (a {@link File#pathSeparator}-delimited list)
for class files that a JVM at {@code
+ /// targetJavaFeatureVersion} could not load.
+ ///
+ /// @throws IOException if a {@code .jar}/{@code .zip} entry on the
classpath cannot be opened or read. A corrupt
+ /// archive is a hard error, never a silent skip.
+ static Result scan(String classpath, int targetJavaFeatureVersion)
+ throws IOException {
+ int maxAllowedMajorVersion = targetJavaFeatureVersion +
CLASS_FILE_MAJOR_VERSION_OFFSET;
+ Result result = new Result();
+ for (String entry : classpath.split(File.pathSeparator)) {
+ if (entry.isEmpty()) {
+ continue;
+ }
+ File file = new File(entry);
+ if (!file.exists()) {
+ result._skippedEntries.add(entry + " (does not exist)");
+ continue;
+ }
+ if (file.isDirectory()) {
+ result._directoriesScanned++;
+ scanDirectory(file, file, result, targetJavaFeatureVersion,
maxAllowedMajorVersion);
+ continue;
+ }
+ String lowerCaseName = file.getName().toLowerCase(Locale.ROOT);
+ if (!lowerCaseName.endsWith(".jar") && !lowerCaseName.endsWith(".zip")) {
+ // Maven puts `pom`-type dependencies (e.g. groovy-all) on the
classpath as .pom files. Those
+ // carry no bytecode, so there is nothing to check -- but report them
so the skip is visible
+ // and the caller can decide whether the reason is one it tolerates.
+ result._skippedEntries.add(entry + " (not an archive)");
+ continue;
+ }
+ result._archivesScanned++;
+ result._archiveNames.add(file.getName());
+ scanArchive(file, result, targetJavaFeatureVersion,
maxAllowedMajorVersion);
+ }
+ return result;
+ }
+
+ private static void scanArchive(File archive, Result result, int
targetJavaFeatureVersion,
+ int maxAllowedMajorVersion)
+ throws IOException {
+ try (ZipFile zipFile = new ZipFile(archive)) {
+ Enumeration<? extends ZipEntry> entries = zipFile.entries();
+ boolean multiRelease = isMultiRelease(zipFile);
+ while (entries.hasMoreElements()) {
+ ZipEntry zipEntry = entries.nextElement();
+ String name = zipEntry.getName();
+ if (zipEntry.isDirectory() || !isLoadableClassEntry(name,
targetJavaFeatureVersion, multiRelease)) {
+ continue;
+ }
+ try (InputStream inputStream = zipFile.getInputStream(zipEntry)) {
+ readMajorVersion(inputStream).ifPresent(
+ majorVersion -> result.record(archive.getPath(), name,
majorVersion, maxAllowedMajorVersion, true));
+ }
+ }
+ } catch (ZipException e) {
+ throw new IOException("Failed to open archive on the classpath: " +
archive, e);
+ }
+ }
+
+ private static void scanDirectory(File root, File current, Result result,
int targetJavaFeatureVersion,
+ int maxAllowedMajorVersion)
+ throws IOException {
+ File[] children = current.listFiles();
+ if (children == null) {
+ result._skippedEntries.add(current.getPath() + " (unreadable
directory)");
+ return;
+ }
+ for (File child : children) {
+ if (child.isDirectory()) {
+ scanDirectory(root, child, result, targetJavaFeatureVersion,
maxAllowedMajorVersion);
+ continue;
+ }
+ String relativePath =
root.toPath().relativize(child.toPath()).toString().replace(File.separatorChar,
'/');
+ // A directory classpath entry never gets multi-release treatment,
whatever a MANIFEST.MF inside
+ // it might claim, so any versioned entry under it is inert.
+ if (!isLoadableClassEntry(relativePath, targetJavaFeatureVersion,
false)) {
+ continue;
+ }
+ try (InputStream inputStream = Files.newInputStream(child.toPath())) {
+ readMajorVersion(inputStream).ifPresent(
+ majorVersion -> result.record(root.getPath(), relativePath,
majorVersion, maxAllowedMajorVersion, false));
+ }
+ }
+ }
+
+ /// Returns whether the archive declares {@code Multi-Release: true} in its
manifest. Only such an archive gets
+ /// versioned-entry lookup from a JVM; in any other jar the {@code
META-INF/versions/} tree is inert data.
+ private static boolean isMultiRelease(ZipFile zipFile)
+ throws IOException {
+ ZipEntry manifestEntry = zipFile.getEntry(MANIFEST_ENTRY);
+ if (manifestEntry == null) {
+ // Some archives spell the manifest with different casing; the JAR spec
treats it case-insensitively.
+ Enumeration<? extends ZipEntry> entries = zipFile.entries();
+ while (entries.hasMoreElements()) {
+ ZipEntry candidate = entries.nextElement();
+ if (candidate.getName().equalsIgnoreCase(MANIFEST_ENTRY)) {
+ manifestEntry = candidate;
+ break;
+ }
+ }
+ }
+ if (manifestEntry == null) {
+ return false;
+ }
+ try (InputStream inputStream = zipFile.getInputStream(manifestEntry)) {
+ String value = new
Manifest(inputStream).getMainAttributes().getValue(Attributes.Name.MULTI_RELEASE);
+ // The attribute value is defined to be case-insensitive; anything other
than "true" means not multi-release.
+ return value != null && Boolean.parseBoolean(value.trim());
+ } catch (IOException e) {
+ // A jar we cannot read the manifest of is a real problem, not something
to silently treat as single-release.
+ throw new IOException("Failed to read the manifest of archive on the
classpath: " + zipFile.getName(), e);
+ }
+ }
+
+ /// Returns true if {@code name} is a class file that a JVM at {@code
targetJavaFeatureVersion} would actually load
+ /// from the classpath. {@code multiRelease} says whether the enclosing
archive declared
+ /// {@code Multi-Release: true}; when it did not, versioned entries are
never selected and so are never checked.
+ private static boolean isLoadableClassEntry(String name, int
targetJavaFeatureVersion, boolean multiRelease) {
+ if (!name.endsWith(".class")) {
+ return false;
+ }
+ String effectiveName = name;
+ if (name.startsWith(MULTI_RELEASE_PREFIX)) {
+ if (!multiRelease) {
+ return false;
+ }
+ int versionEnd = name.indexOf('/', MULTI_RELEASE_PREFIX.length());
+ if (versionEnd < 0) {
+ return false;
+ }
+ int version;
+ try {
+ version =
Integer.parseInt(name.substring(MULTI_RELEASE_PREFIX.length(), versionEnd));
+ } catch (NumberFormatException e) {
+ // Not a well-formed multi-release directory; treat it as an inert
resource.
+ return false;
+ }
+ if (version > targetJavaFeatureVersion) {
+ return false;
+ }
+ effectiveName = name.substring(versionEnd + 1);
+ }
+ // module-info descriptors are ignored on the classpath, whatever version
they were compiled at.
+ return !effectiveName.equals(MODULE_INFO_ENTRY) &&
!effectiveName.endsWith('/' + MODULE_INFO_ENTRY);
+ }
+
+ /// Reads the class file major version from the first 8 bytes. Returns empty
for anything that is not a class file
+ /// despite the {@code .class} name, which does occur in the wild as
packaged test data.
+ private static Optional<Integer> readMajorVersion(InputStream inputStream)
+ throws IOException {
+ DataInputStream dataInputStream = new DataInputStream(inputStream);
+ int magic;
+ int major;
+ try {
+ magic = dataInputStream.readInt();
+ dataInputStream.readUnsignedShort(); // minor version
+ major = dataInputStream.readUnsignedShort();
+ } catch (EOFException e) {
+ return Optional.empty();
+ }
+ if (magic != CLASS_FILE_MAGIC) {
+ return Optional.empty();
+ }
+ return Optional.of(major);
+ }
+}
diff --git
a/pinot-java11-client-verifier/src/main/java/org/apache/pinot/java11/Java11CompatibilityVerifier.java
b/pinot-java11-client-verifier/src/main/java/org/apache/pinot/java11/Java11CompatibilityVerifier.java
new file mode 100644
index 00000000000..c93b6665e6e
--- /dev/null
+++
b/pinot-java11-client-verifier/src/main/java/org/apache/pinot/java11/Java11CompatibilityVerifier.java
@@ -0,0 +1,803 @@
+/**
+ * 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.
+ */
+package org.apache.pinot.java11;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.protobuf.ByteString;
+import io.grpc.ManagedChannel;
+import java.io.IOException;
+import java.io.InputStream;
+import java.math.BigDecimal;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.sql.Driver;
+import java.sql.DriverManager;
+import java.sql.DriverPropertyInfo;
+import java.sql.ResultSetMetaData;
+import java.sql.Types;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.concurrent.TimeUnit;
+import org.apache.helix.model.ExternalView;
+import org.apache.helix.zookeeper.datamodel.ZNRecord;
+import org.apache.helix.zookeeper.datamodel.serializer.ZNRecordSerializer;
+import org.apache.pinot.client.Connection;
+import org.apache.pinot.client.ConnectionFactory;
+import org.apache.pinot.client.ExecutionStats;
+import org.apache.pinot.client.JsonAsyncHttpPinotClientTransportFactory;
+import org.apache.pinot.client.PinotClientTransport;
+import org.apache.pinot.client.PinotResultSet;
+import org.apache.pinot.client.PreparedStatement;
+import org.apache.pinot.client.ResultSet;
+import org.apache.pinot.client.ResultSetGroup;
+import org.apache.pinot.client.grpc.GrpcUtils;
+import org.apache.pinot.common.compression.CompressionFactory;
+import org.apache.pinot.common.compression.Compressor;
+import org.apache.pinot.common.config.GrpcConfig;
+import org.apache.pinot.common.metadata.segment.SegmentZKMetadata;
+import org.apache.pinot.common.proto.Broker;
+import org.apache.pinot.common.request.PinotQuery;
+import org.apache.pinot.common.response.broker.BrokerResponseNative;
+import org.apache.pinot.common.response.broker.ResultTable;
+import org.apache.pinot.common.response.encoder.ResponseEncoder;
+import org.apache.pinot.common.response.encoder.ResponseEncoderFactory;
+import org.apache.pinot.common.utils.DataSchema;
+import org.apache.pinot.common.utils.DataSchema.ColumnDataType;
+import org.apache.pinot.common.utils.grpc.BrokerGrpcQueryClient;
+import org.apache.pinot.segment.spi.memory.PinotDataBuffer;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
+import org.apache.pinot.spi.data.Schema;
+import org.apache.pinot.spi.utils.CommonConstants;
+import org.apache.pinot.spi.utils.JsonUtils;
+import org.apache.pinot.sql.parsers.CalciteSqlParser;
+import org.apache.pinot.sql.parsers.SqlNodeAndOptions;
+import org.apache.pinot.tsdb.spi.AggInfo;
+import org.apache.pinot.tsdb.spi.TimeBuckets;
+import org.apache.pinot.tsdb.spi.plan.BaseTimeSeriesPlanNode;
+import org.apache.pinot.tsdb.spi.plan.LeafTimeSeriesPlanNode;
+import org.apache.pinot.tsdb.spi.plan.serde.TimeSeriesPlanSerde;
+
+
+/// Verifies that Pinot's consumer-facing client and SPI artifacts actually
work on an older JVM, by being run _by_ that
+/// JVM.
+///
+/// Six modules (pinot-spi, pinot-segment-spi, pinot-timeseries-spi,
pinot-common, pinot-java-client and
+/// pinot-jdbc-client) hard-code a compiler {@code release} of 11 so that
third-party plugins and embedding applications
+/// are not forced onto the JDK that Pinot's services require. {@code
--release} makes Pinot's own code in those modules
+/// Java 11 clean, but it says nothing about their transitive dependency
closure: a routine dependency bump can drop a
+/// Java 17+ jar into the client's classpath, and nothing in the build would
notice. This verifier closes that gap.
+///
+/// It must be launched by a JVM at the target feature release (11 by default)
-- running it on the build JDK proves
+/// nothing, so {@link #checkJvmIsAtTargetFeatureVersion} fails loudly in that
case. The build itself still requires JDK
+/// 25 ({@code requireJavaVersion} in the root pom), so CI builds the
artifacts with JDK 25 and then runs this class
+/// under Java 11.
+///
+/// Usage: {@code java -cp <runtime closure>
org.apache.pinot.java11.Java11CompatibilityVerifier
+/// [targetJavaFeatureVersion]}. Exits 0 if every check passes, 1 otherwise.
+///
+/// The assertions go through a local {@link #require} helper rather than
TestNG on purpose. A test framework on the
+/// classpath would be scanned by {@link #checkClasspathClosureIsLoadable}
alongside the real dependencies, so a TestNG
+/// or byte-buddy release that dropped Java 11 support would fail this job for
a reason no Pinot consumer would ever
+/// hit.
+///
+/// Not thread-safe, and deliberately single-threaded: checks run sequentially
on the calling thread so that a failure
+/// maps to exactly one named check.
+public final class Java11CompatibilityVerifier {
+ private static final int DEFAULT_TARGET_JAVA_FEATURE_VERSION = 11;
+ private static final String SAMPLE_BROKER_RESPONSE_RESOURCE =
"sample-broker-response.json";
+ private static final String JDBC_SERVICE_DESCRIPTOR =
"META-INF/services/java.sql.Driver";
+ private static final String SAMPLE_TABLE_NAME = "baseballStats";
+ private static final String JVM_VERSION_CHECK =
"jvm-is-at-target-feature-version";
+
+ /// Every module that hard-codes a Java 11 compiler release. All of them
have to be on the verified closure, otherwise
+ /// this job quietly checks less than it advertises. Add to this list when a
seventh module gets pinned.
+ private static final List<String> JAVA11_PINNED_MODULES = List.of(
+ "pinot-spi", "pinot-segment-spi", "pinot-timeseries-spi",
"pinot-common", "pinot-java-client",
+ "pinot-jdbc-client");
+
+ /// Encoders and compression codecs that must stay covered. Iterating
whatever the factories happen to return would
+ /// let a refactor drop ARROW or ZSTD -- and with them arrow-memory-netty
and the JNI codecs, the most likely sources
+ /// of a Java version floor bump -- while the job stayed green.
+ private static final List<String> REQUIRED_RESPONSE_ENCODERS =
List.of("JSON", "ARROW");
+ private static final List<String> REQUIRED_COMPRESSION_CODECS =
List.of("ZSTD", "SNAPPY", "LZ4", "GZIP", "DEFLATE");
+
+ /// Floors for the closure scan's vacuity guards, set well below the real
numbers (~220 jars, ~69k class files at the
+ /// time of writing) so ordinary dependency churn does not trip them, but
far enough above zero to catch a closure
+ /// that failed to resolve.
+ private static final int MIN_EXPECTED_ARCHIVES = 100;
+ private static final int MIN_EXPECTED_CLASS_FILES_IN_ARCHIVES = 20_000;
+
+ private final int _targetJavaFeatureVersion;
+ private final String _sampleBrokerResponseJson;
+ private final JsonNode _sampleBrokerResponse;
+
+ private Java11CompatibilityVerifier(int targetJavaFeatureVersion)
+ throws IOException {
+ _targetJavaFeatureVersion = targetJavaFeatureVersion;
+ _sampleBrokerResponseJson = readResource(SAMPLE_BROKER_RESPONSE_RESOURCE);
+ _sampleBrokerResponse =
JsonUtils.stringToJsonNode(_sampleBrokerResponseJson);
+ }
+
+ /// A single named verification. Throwing anything -- including {@link
AssertionError} -- fails it.
+ @FunctionalInterface
+ private interface Check {
+ void run()
+ throws Exception;
+ }
+
+ public static void main(String[] args)
+ throws IOException {
+ int targetJavaFeatureVersion = DEFAULT_TARGET_JAVA_FEATURE_VERSION;
+ if (args.length > 1) {
+ throw new IllegalArgumentException(
+ "Usage: Java11CompatibilityVerifier [targetJavaFeatureVersion], got:
" + Arrays.toString(args));
+ }
+ if (args.length == 1) {
+ targetJavaFeatureVersion = Integer.parseInt(args[0].trim());
+ }
+ System.exit(new
Java11CompatibilityVerifier(targetJavaFeatureVersion).run());
+ }
+
+ private int run() {
+ Map<String, Check> checks = new LinkedHashMap<>();
+ // Guards against the whole job passing vacuously on the build JDK. Must
stay first; failing it
+ // aborts the run.
+ checks.put(JVM_VERSION_CHECK, this::checkJvmIsAtTargetFeatureVersion);
+ checks.put("classpath-closure-is-loadable",
this::checkClasspathClosureIsLoadable);
+ checks.put("spi-schema-deserialization",
this::checkSpiSchemaDeserialization);
+ checks.put("spi-table-config-deserialization",
this::checkSpiTableConfigDeserialization);
+ checks.put("segment-spi-data-buffer", this::checkSegmentSpiDataBuffer);
+ checks.put("timeseries-spi-plan-serde", this::checkTimeSeriesSpiPlanSerde);
+ checks.put("common-data-schema-round-trip",
this::checkCommonDataSchemaRoundTrip);
+ checks.put("common-broker-response-deserialization",
this::checkCommonBrokerResponseDeserialization);
+ checks.put("common-response-encoders", this::checkCommonResponseEncoders);
+ checks.put("common-calcite-sql-parsing",
this::checkCommonCalciteSqlParsing);
+ checks.put("common-helix-segment-metadata",
this::checkCommonHelixSegmentMetadata);
+ checks.put("common-grpc-response-decoding",
this::checkCommonGrpcResponseDecoding);
+ checks.put("common-grpc-channel-construction",
this::checkCommonGrpcChannelConstruction);
+ checks.put("java-client-http-transport",
this::checkJavaClientHttpTransport);
+ checks.put("java-client-query-execution",
this::checkJavaClientQueryExecution);
+ checks.put("java-client-prepared-statement",
this::checkJavaClientPreparedStatement);
+ checks.put("jdbc-driver-registration", this::checkJdbcDriverRegistration);
+ checks.put("jdbc-result-set", this::checkJdbcResultSet);
+
+ System.out.printf("Verifying Pinot clients on %s %s (%s), targeting Java
%d%n", System.getProperty("java.vm.name"),
+ System.getProperty("java.version"), System.getProperty("java.vendor"),
_targetJavaFeatureVersion);
+ System.out.println();
+
+ List<String> failed = new ArrayList<>();
+ for (Map.Entry<String, Check> entry : checks.entrySet()) {
+ String name = entry.getKey();
+ try {
+ entry.getValue().run();
+ System.out.printf(" PASS %s%n", name);
+ } catch (Throwable t) {
+ failed.add(name);
+ System.out.printf(" FAIL %s%n", name);
+ System.out.printf(" %s: %s%n", t.getClass().getName(),
t.getMessage());
+ t.printStackTrace(System.out);
+ if (name.equals(JVM_VERSION_CHECK)) {
+ // Running on the wrong JVM makes everything below meaningless. Stop
rather than emit a wall
+ // of passes that a reader could mistake for real coverage.
+ System.out.printf("%nAborting: the remaining %d checks would not
tell us anything on this JVM.%n",
+ checks.size() - 1);
+ break;
+ }
+ }
+ }
+
+ System.out.println();
+ if (failed.isEmpty()) {
+ System.out.printf("All %d checks passed on Java %d.%n", checks.size(),
_targetJavaFeatureVersion);
+ return 0;
+ }
+ System.out.printf("%d of %d checks FAILED on Java %d: %s%n",
failed.size(), checks.size(),
+ _targetJavaFeatureVersion, String.join(", ", failed));
+ return 1;
+ }
+
+ //
---------------------------------------------------------------------------------------------
+ // Checks
+ //
---------------------------------------------------------------------------------------------
+
+ /// The point of this harness is that an _old_ JVM runs it. If CI ever hands
it the build JDK, every other check would
+ /// pass for the wrong reason, so refuse to run.
+ private void checkJvmIsAtTargetFeatureVersion() {
+ int actual = Runtime.version().feature();
+ require(actual == _targetJavaFeatureVersion,
+ "expected to be running on a Java %d JVM but this is Java %d (%s);
verifying the clients on the build JDK "
+ + "would make every other check vacuous",
_targetJavaFeatureVersion, actual,
+ System.getProperty("java.home"));
+ }
+
+ /// Walks the whole runtime closure looking for bytecode this JVM could not
load. {@code --release 11} covers Pinot's
+ /// own class files; this is the part that covers everybody else's.
+ private void checkClasspathClosureIsLoadable()
+ throws IOException {
+ String classpath = System.getProperty("java.class.path");
+ require(classpath != null && !classpath.isEmpty(), "java.class.path is
empty");
+ ClasspathClosureScanner.Result result =
ClasspathClosureScanner.scan(classpath, _targetJavaFeatureVersion);
+
+ System.out.printf(" scanned %d archives (%d class files) and %d
directories (%d class files); "
+ + "major versions: %s%n", result.getArchivesScanned(),
result.getClassFilesInArchives(),
+ result.getDirectoriesScanned(), result.getClassFilesInDirectories(),
result.getMajorVersionHistogram());
+
+ // Vacuity guards. Every one of these has to be able to fail, or a green
run means nothing:
+ // - the closure must actually have been resolved, not collapsed to a
handful of entries;
+ // - third-party bytecode specifically must have been read. Counting the
verifier's own
+ // target/classes towards that would make the guard unfalsifiable,
which is why the scanner
+ // keeps the archive and directory counts apart;
+ // - every Java-11-pinned module must be present, so that making one of
them optional or
+ // provided shrinks coverage loudly instead of silently.
+ require(result.getArchivesScanned() >= MIN_EXPECTED_ARCHIVES,
+ "only %d jars on the classpath, expected at least %d -- the runtime
closure was not resolved properly",
+ result.getArchivesScanned(), MIN_EXPECTED_ARCHIVES);
+ require(result.getClassFilesInArchives() >=
MIN_EXPECTED_CLASS_FILES_IN_ARCHIVES,
+ "only %d class files inspected inside jars, expected at least %d --
the scan is broken, not the closure",
+ result.getClassFilesInArchives(),
MIN_EXPECTED_CLASS_FILES_IN_ARCHIVES);
+ assertPinnedModulesArePresent(result.getArchiveNames());
+
+ for (String skipped : result.getSkippedEntries()) {
+ // A `pom`-type dependency (groovy-all) legitimately lands on the
classpath as a .pom. Anything
+ // else that carried no bytecode means the closure is not what we think
it is.
+ require(skipped.endsWith("(not an archive)") && skipped.contains(".pom
"),
+ "unexpected classpath entry that could not be scanned: %s", skipped);
+ System.out.printf(" skipped classpath entry: %s%n", skipped);
+ }
+
+ if (result.getTotalViolationCount() > 0) {
+ StringBuilder message = new StringBuilder(
+ String.format("%d class file(s) on the client runtime closure cannot
be loaded by Java %d. A dependency was "
+ + "bumped to a release that no longer supports Java %d; pin
it back or drop it from the client "
+ + "closure. Offenders:", result.getTotalViolationCount(),
_targetJavaFeatureVersion,
+ _targetJavaFeatureVersion));
+ for (ClasspathClosureScanner.Violation violation :
result.getReportedViolations()) {
+ message.append("\n ").append(violation);
+ }
+ if (result.getTotalViolationCount() >
result.getReportedViolations().size()) {
+ message.append("\n ... and ")
+ .append(result.getTotalViolationCount() -
result.getReportedViolations().size())
+ .append(" more");
+ }
+ throw new AssertionError(message.toString());
+ }
+ }
+
+ /// Fails unless a jar is on the closure for every module that pins its
bytecode to Java 11. Without this, dropping
+ /// one of them from the closure leaves every check passing while silently
verifying less than the job claims to.
+ private static void assertPinnedModulesArePresent(List<String> archiveNames)
{
+ List<String> missing = new ArrayList<>();
+ for (String artifactId : JAVA11_PINNED_MODULES) {
+ boolean found = false;
+ for (String archiveName : archiveNames) {
+ // Jar names are <artifactId>-<version>.jar; require the '-' so
pinot-spi does not match
+ // pinot-spi-something-else.
+ if (archiveName.startsWith(artifactId + "-")) {
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ missing.add(artifactId);
+ }
+ }
+ require(missing.isEmpty(),
+ "these Java 11 pinned modules are not on the verified closure, so this
job is no longer checking them: %s. "
+ + "Either restore the dependency or update
JAVA11_PINNED_MODULES.", missing);
+ }
+
+ /// pinot-spi schema deserialization, which is Jackson plus the SPI's own
field spec model.
+ private void checkSpiSchemaDeserialization()
+ throws IOException {
+ String schemaJson = "{"
+ + "\"schemaName\":\"" + SAMPLE_TABLE_NAME + "\","
+ + "\"dimensionFieldSpecs\":["
+ + "{\"name\":\"playerName\",\"dataType\":\"STRING\"},"
+ +
"{\"name\":\"teams\",\"dataType\":\"STRING\",\"singleValueField\":false}],"
+ +
"\"metricFieldSpecs\":[{\"name\":\"numGames\",\"dataType\":\"LONG\"}],"
+ +
"\"dateTimeFieldSpecs\":[{\"name\":\"gameDate\",\"dataType\":\"LONG\","
+ + "\"format\":\"1:MILLISECONDS:EPOCH\",\"granularity\":\"1:DAYS\"}]}";
+
+ Schema schema = Schema.fromString(schemaJson);
+ require(SAMPLE_TABLE_NAME.equals(schema.getSchemaName()), "unexpected
schema name: %s", schema.getSchemaName());
+ require(schema.getColumnNames().size() == 4, "expected 4 columns, got %s",
schema.getColumnNames());
+ require(schema.getFieldSpecFor("playerName").getDataType() ==
DataType.STRING, "playerName should be STRING");
+ require(!schema.getFieldSpecFor("teams").isSingleValueField(), "teams
should be multi-valued");
+ require(schema.getDateTimeSpec("gameDate") != null, "gameDate date-time
spec missing");
+
+ Schema reparsed = Schema.fromString(schema.toSingleLineJsonString());
+ require(schema.equals(reparsed), "schema did not survive a JSON round
trip");
+ }
+
+ /// pinot-spi table config deserialization, the other half of what a
third-party plugin reads.
+ private void checkSpiTableConfigDeserialization()
+ throws IOException {
+ String tableConfigJson = "{"
+ + "\"tableName\":\"" + SAMPLE_TABLE_NAME + "\","
+ + "\"tableType\":\"OFFLINE\","
+ +
"\"segmentsConfig\":{\"replication\":\"2\",\"timeColumnName\":\"gameDate\"},"
+ +
"\"tenants\":{\"broker\":\"DefaultTenant\",\"server\":\"DefaultTenant\"},"
+ +
"\"tableIndexConfig\":{\"invertedIndexColumns\":[\"playerName\"],\"loadMode\":\"MMAP\"},"
+ + "\"metadata\":{}}";
+
+ TableConfig tableConfig = JsonUtils.stringToObject(tableConfigJson,
TableConfig.class);
+ require(tableConfig.getTableType() == TableType.OFFLINE, "unexpected table
type: %s", tableConfig.getTableType());
+ require((SAMPLE_TABLE_NAME +
"_OFFLINE").equals(tableConfig.getTableName()), "unexpected table name: %s",
+ tableConfig.getTableName());
+ require("2".equals(tableConfig.getValidationConfig().getReplication()),
"unexpected replication: %s",
+ tableConfig.getValidationConfig().getReplication());
+
require(tableConfig.getIndexingConfig().getInvertedIndexColumns().contains("playerName"),
+ "inverted index column lost: %s",
tableConfig.getIndexingConfig().getInvertedIndexColumns());
+
+ TableConfig reserialized =
JsonUtils.stringToObject(JsonUtils.objectToString(tableConfig),
TableConfig.class);
+ require(tableConfig.getTableName().equals(reserialized.getTableName()),
+ "table config did not survive a JSON round trip");
+ }
+
+ /// pinot-segment-spi's off-heap buffer, which is what a third-party index
or reader plugin built against the SPI
+ /// actually uses. Worth exercising rather than merely scanning: the
allocation path goes through {@code Unsafe} and
+ /// {@code sun.misc.Cleaner} reflection whose availability is exactly what
shifts between JDK releases, and the
+ /// class-file scan cannot see that.
+ private void checkSegmentSpiDataBuffer()
+ throws IOException {
+ int size = 64;
+ try (PinotDataBuffer buffer = PinotDataBuffer.allocateDirect(size,
ByteOrder.LITTLE_ENDIAN,
+ "java11-verifier")) {
+ require(buffer.size() == size, "unexpected buffer size: %d",
buffer.size());
+ buffer.putInt(0, 0xCAFEBABE);
+ buffer.putLong(8, 1750000000000L);
+ buffer.putDouble(16, 0.305d);
+ require(buffer.getInt(0) == 0xCAFEBABE, "int lost in the off-heap
buffer: %d", buffer.getInt(0));
+ require(buffer.getLong(8) == 1750000000000L, "long lost in the off-heap
buffer: %d", buffer.getLong(8));
+ require(buffer.getDouble(16) == 0.305d, "double lost in the off-heap
buffer: %s", buffer.getDouble(16));
+
+ byte[] bytes = "baseballStats".getBytes(StandardCharsets.UTF_8);
+ buffer.readFrom(24, bytes, 0, bytes.length);
+ byte[] readBack = new byte[bytes.length];
+ buffer.copyTo(24, readBack, 0, readBack.length);
+ require(Arrays.equals(bytes, readBack), "bytes lost in the off-heap
buffer: %s",
+ new String(readBack, StandardCharsets.UTF_8));
+ }
+ }
+
+ /// pinot-timeseries-spi's plan model, which a time series language plugin
serializes and ships.
+ private void checkTimeSeriesSpiPlanSerde() {
+ LeafTimeSeriesPlanNode leaf = new LeafTimeSeriesPlanNode("sfp#0",
List.of(), SAMPLE_TABLE_NAME, "gameDate",
+ TimeUnit.MILLISECONDS, 0L, "battingAverage > 0.3", "numGames",
+ new AggInfo("SUM", false, Map.of("window", "60")),
List.of("playerName"), 100, Map.of("timeoutMs", "20000"));
+
+ String serialized = TimeSeriesPlanSerde.serialize(leaf);
+ require(serialized.contains(SAMPLE_TABLE_NAME), "table name lost during
plan serialization: %s", serialized);
+
+ BaseTimeSeriesPlanNode deserialized =
TimeSeriesPlanSerde.deserialize(serialized);
+ require(deserialized instanceof LeafTimeSeriesPlanNode, "unexpected plan
node type: %s",
+ deserialized.getClass().getName());
+ LeafTimeSeriesPlanNode roundTripped = (LeafTimeSeriesPlanNode)
deserialized;
+ require(SAMPLE_TABLE_NAME.equals(roundTripped.getTableName()), "unexpected
table name: %s",
+ roundTripped.getTableName());
+ require("numGames".equals(roundTripped.getValueExpression()), "unexpected
value expression: %s",
+ roundTripped.getValueExpression());
+ require(roundTripped.getAggInfo() != null &&
"SUM".equals(roundTripped.getAggInfo().getAggFunction()),
+ "aggregation lost during the plan round trip: %s",
roundTripped.getAggInfo());
+
require(List.of("playerName").equals(roundTripped.getGroupByExpressions()),
"group by lost: %s",
+ roundTripped.getGroupByExpressions());
+ require(roundTripped.getLimit() == 100, "unexpected limit: %d",
roundTripped.getLimit());
+
+ TimeBuckets timeBuckets = TimeBuckets.ofSeconds(1750000000L,
Duration.ofSeconds(60), 10);
+ require(timeBuckets.getNumBuckets() == 10, "unexpected bucket count: %d",
timeBuckets.getNumBuckets());
+ require(timeBuckets.getTimeBuckets().length == 10, "unexpected bucket
array length: %d",
+ timeBuckets.getTimeBuckets().length);
+ }
+
+ /// pinot-common's wire-format schema, both its binary encoding and its JSON
encoding.
+ private void checkCommonDataSchemaRoundTrip()
+ throws IOException {
+ DataSchema dataSchema = sampleDataSchema();
+
+ DataSchema fromBytes =
DataSchema.fromBytes(ByteBuffer.wrap(dataSchema.toBytes()));
+ require(dataSchema.equals(fromBytes), "DataSchema did not survive a binary
round trip: %s", fromBytes);
+
+ DataSchema fromJson =
JsonUtils.stringToObject(JsonUtils.objectToString(dataSchema),
DataSchema.class);
+ require(dataSchema.equals(fromJson), "DataSchema did not survive a JSON
round trip: %s", fromJson);
+ require(dataSchema.getColumnDataType(1) == ColumnDataType.INT, "unexpected
column type: %s",
+ dataSchema.getColumnDataType(1));
+ }
+
+ /// A realistic broker response parsed by pinot-common's own response model.
+ private void checkCommonBrokerResponseDeserialization()
+ throws IOException {
+ BrokerResponseNative response =
BrokerResponseNative.fromJsonString(_sampleBrokerResponseJson);
+ require(response.getExceptions().isEmpty(), "unexpected exceptions: %s",
response.getExceptions());
+ require(response.getNumDocsScanned() == 4231, "unexpected numDocsScanned:
%d", response.getNumDocsScanned());
+ require(response.getTotalDocs() == 97889, "unexpected totalDocs: %d",
response.getTotalDocs());
+ require(response.getTimeUsedMs() == 37, "unexpected timeUsedMs: %d",
response.getTimeUsedMs());
+ require("Broker_192.168.1.10_8000".equals(response.getBrokerId()),
"unexpected brokerId: %s",
+ response.getBrokerId());
+
+ ResultTable resultTable = response.getResultTable();
+ require(resultTable != null, "resultTable was dropped during
deserialization");
+ require(resultTable.getRows().size() == 3, "expected 3 rows, got %d",
resultTable.getRows().size());
+ require("Hank Aaron".equals(resultTable.getRows().get(0)[0]), "unexpected
first cell: %s",
+ resultTable.getRows().get(0)[0]);
+ require(Arrays.equals(new String[]{
+ "playerName", "playerId", "numGames", "battingAverage", "isActive",
"teams"
+ }, resultTable.getDataSchema().getColumnNames()), "unexpected columns: %s",
+ Arrays.toString(resultTable.getDataSchema().getColumnNames()));
+
+ String reserialized = JsonUtils.objectToString(response);
+
require(BrokerResponseNative.fromJsonString(reserialized).getNumDocsScanned()
== 4231,
+ "broker response did not survive a JSON round trip");
+ }
+
+ /// Round-trips a result table through every encoder the gRPC client can be
asked to decode. The Arrow encoder is the
+ /// interesting one: arrow-vector and arrow-memory-netty are the client's
most likely source of a Java version floor
+ /// bump, and they only fail when actually exercised.
+ private void checkCommonResponseEncoders()
+ throws IOException {
+ ResultTable resultTable = sampleResultTable();
+ int numRows = resultTable.getRows().size();
+ List<String> encoderTypes =
Arrays.asList(ResponseEncoderFactory.getResponseEncoderTypes());
+ require(encoderTypes.containsAll(REQUIRED_RESPONSE_ENCODERS),
+ "response encoders %s no longer cover %s, so this check would stop
exercising them",
+ encoderTypes, REQUIRED_RESPONSE_ENCODERS);
+
+ for (String encoderType : encoderTypes) {
+ ResponseEncoder encoder =
ResponseEncoderFactory.getResponseEncoder(encoderType);
+ byte[] encoded = encoder.encodeResultTable(resultTable, 0, numRows);
+ require(encoded.length > 0, "%s encoder produced no bytes", encoderType);
+
+ ResultTable decoded = encoder.decodeResultTable(encoded, numRows,
resultTable.getDataSchema());
+ require(decoded.getRows().size() == numRows, "%s encoder lost rows: %d
of %d", encoderType,
+ decoded.getRows().size(), numRows);
+ assertSampleRowsMatch(encoderType, decoded);
+ }
+ }
+
+ /// Calcite SQL parsing, which third-party consumers of pinot-common reach
through the SQL compiler.
+ private void checkCommonCalciteSqlParsing() {
+ String sql = "SELECT playerName, SUM(numGames) AS totalGames FROM " +
SAMPLE_TABLE_NAME
+ + " WHERE battingAverage > 0.3 AND playerName IN ('Hank Aaron',
'Willie Mays') "
+ + "GROUP BY playerName ORDER BY totalGames DESC LIMIT 5";
+
+ PinotQuery pinotQuery = CalciteSqlParser.compileToPinotQuery(sql);
+
require(SAMPLE_TABLE_NAME.equals(pinotQuery.getDataSource().getTableName()),
"unexpected table: %s",
+ pinotQuery.getDataSource().getTableName());
+ require(pinotQuery.getSelectListSize() == 2, "unexpected select list size:
%d", pinotQuery.getSelectListSize());
+ require(pinotQuery.getGroupByListSize() == 1, "unexpected group by size:
%d", pinotQuery.getGroupByListSize());
+ require(pinotQuery.getOrderByListSize() == 1, "unexpected order by size:
%d", pinotQuery.getOrderByListSize());
+ require(pinotQuery.getLimit() == 5, "unexpected limit: %d",
pinotQuery.getLimit());
+ require(pinotQuery.getFilterExpression() != null, "filter was dropped");
+
+ SqlNodeAndOptions sqlNodeAndOptions =
CalciteSqlParser.compileToSqlNodeAndOptions("SET timeoutMs = 20000; " + sql);
+ require("20000".equals(sqlNodeAndOptions.getOptions().get("timeoutMs")),
"query option lost: %s",
+ sqlNodeAndOptions.getOptions());
+ }
+
+ /// pinot-common's ZooKeeper metadata model, which is Helix's {@code
ZNRecord} plus Helix serialization.
+ private void checkCommonHelixSegmentMetadata() {
+ SegmentZKMetadata metadata = new SegmentZKMetadata(SAMPLE_TABLE_NAME +
"_0");
+ metadata.setCrc(3141592653L);
+ metadata.setTotalDocs(97889);
+ metadata.setIndexVersion("v3");
+ metadata.setCreationTime(1750000000000L);
+ metadata.setStartTime(1749000000000L);
+ metadata.setEndTime(1750000000000L);
+ metadata.setTimeUnit(TimeUnit.MILLISECONDS);
+
+ ZNRecordSerializer serializer = new ZNRecordSerializer();
+ byte[] serialized = serializer.serialize(metadata.toZNRecord());
+ require(serialized.length > 0, "ZNRecordSerializer produced no bytes");
+ ZNRecord deserialized = (ZNRecord) serializer.deserialize(serialized);
+ require(deserialized != null, "ZNRecordSerializer returned null");
+
+ SegmentZKMetadata roundTripped = new SegmentZKMetadata(deserialized);
+ require(roundTripped.getCrc() == 3141592653L, "unexpected crc: %d",
roundTripped.getCrc());
+ require(roundTripped.getTotalDocs() == 97889, "unexpected totalDocs: %d",
roundTripped.getTotalDocs());
+ require("v3".equals(roundTripped.getIndexVersion()), "unexpected
indexVersion: %s",
+ roundTripped.getIndexVersion());
+ require(roundTripped.getStartTimeMs() == 1749000000000L, "unexpected
startTimeMs: %d",
+ roundTripped.getStartTimeMs());
+ require(roundTripped.getEndTimeMs() == 1750000000000L, "unexpected
endTimeMs: %d", roundTripped.getEndTimeMs());
+ require(roundTripped.getCreationTime() == 1750000000000L, "unexpected
creationTime: %d",
+ roundTripped.getCreationTime());
+
+ // The broker list the java client discovers from ZooKeeper is a Helix
external view.
+ ExternalView externalView = new
ExternalView(CommonConstants.Helix.BROKER_RESOURCE_INSTANCE);
+ externalView.setState(SAMPLE_TABLE_NAME + "_OFFLINE",
"Broker_192.168.1.10_8000", "ONLINE");
+ require("ONLINE".equals(externalView.getStateMap(SAMPLE_TABLE_NAME +
"_OFFLINE").get("Broker_192.168.1.10_8000")),
+ "external view state lost: %s",
externalView.getStateMap(SAMPLE_TABLE_NAME + "_OFFLINE"));
+ }
+
+ /// The gRPC client's decode path end to end: a protobuf {@code
BrokerResponse} carrying a compressed, encoded
+ /// payload, unpacked exactly the way {@code GrpcConnection} unpacks a
server response. Covers protobuf, the
+ /// compression codecs and the response encoders in one go.
+ private void checkCommonGrpcResponseDecoding()
+ throws IOException {
+ DataSchema dataSchema = sampleResultTable().getDataSchema();
+ Broker.BrokerResponse schemaResponse =
+
Broker.BrokerResponse.newBuilder().setPayload(ByteString.copyFrom(dataSchema.toBytes())).build();
+ require(dataSchema.equals(GrpcUtils.extractSchema(schemaResponse)),
"schema lost through the gRPC payload");
+
+ String metadataJson = "{\"requestId\":\"42\",\"numDocsScanned\":4231}";
+ Broker.BrokerResponse metadataResponse = Broker.BrokerResponse.newBuilder()
+
.setPayload(ByteString.copyFrom(metadataJson.getBytes(StandardCharsets.UTF_8)))
+ .putMetadata("rowSize", "3")
+ .build();
+
require("42".equals(GrpcUtils.extractMetadataJson(metadataResponse).get("requestId").asText()),
+ "requestId lost through the gRPC metadata payload");
+ ExecutionStats executionStats =
+
GrpcUtils.extractExecutionStats(JsonUtils.stringToJsonNode(_sampleBrokerResponseJson));
+ require(executionStats.getNumDocsScanned() == 4231, "unexpected
numDocsScanned: %d",
+ executionStats.getNumDocsScanned());
+
+ ResultTable resultTable = sampleResultTable();
+ int numRows = resultTable.getRows().size();
+ List<String> compressionTypes =
Arrays.asList(CompressionFactory.getCompressionTypes());
+ require(compressionTypes.containsAll(REQUIRED_COMPRESSION_CODECS),
+ "compression codecs %s no longer cover %s, so the JNI codecs would
stop being loaded here",
+ compressionTypes, REQUIRED_COMPRESSION_CODECS);
+
+ for (String encoderType :
ResponseEncoderFactory.getResponseEncoderTypes()) {
+ // Loop-invariant: encoding once per encoder avoids repeating the Arrow
allocator setup for
+ // every codec.
+ byte[] encoded = ResponseEncoderFactory.getResponseEncoder(encoderType)
+ .encodeResultTable(resultTable, 0, numRows);
+ for (String compressionType : compressionTypes) {
+ Compressor compressor =
CompressionFactory.getCompressor(compressionType);
+ byte[] compressed;
+ try {
+ compressed = compressor.compress(encoded);
+ } catch (Exception e) {
+ throw new AssertionError("compressor " + compressionType + " failed
on Java "
+ + _targetJavaFeatureVersion, e);
+ }
+ Broker.BrokerResponse dataResponse = Broker.BrokerResponse.newBuilder()
+ .setPayload(ByteString.copyFrom(compressed))
+ .putMetadata("rowSize", String.valueOf(numRows))
+ .putMetadata(CommonConstants.Broker.Grpc.COMPRESSION,
compressionType)
+ .putMetadata(CommonConstants.Broker.Grpc.ENCODING, encoderType)
+ .build();
+ ResultTable decoded = GrpcUtils.extractResultTable(dataResponse,
dataSchema);
+ require(decoded.getRows().size() == numRows, "%s/%s lost rows: %d of
%d", encoderType, compressionType,
+ decoded.getRows().size(), numRows);
+ assertSampleRowsMatch(encoderType + "/" + compressionType, decoded);
+ }
+ }
+ }
+
+ /// Builds the Netty-backed gRPC channel the client uses, without
connecting. Exercises grpc-netty's provider lookup
+ /// and the pooled direct-buffer allocator, both of which are version
sensitive.
+ private void checkCommonGrpcChannelConstruction()
+ throws IOException {
+ // Construction is deliberately outside the try: it builds the Netty
channel and the pooled
+ // direct-buffer allocator, and either step throwing on an older JVM is
the failure we are looking
+ // for, so it should propagate as-is -- and there is nothing to close if
it never returned.
+ BrokerGrpcQueryClient client = new BrokerGrpcQueryClient("localhost",
8010, new GrpcConfig(Map.of()));
+ ManagedChannel channel = client.getChannel();
+ try {
+ require(!channel.isShutdown(), "the gRPC channel was already shut down
on creation");
+ } finally {
+ client.close();
+ }
+ // close() swallows its own exceptions, so assert the channel state rather
than trusting it to throw.
+ require(channel.isTerminated(), "the gRPC channel did not terminate on
close");
+ }
+
+ /// Builds the async-http-client transport the java client queries brokers
with, without connecting.
+ private void checkJavaClientHttpTransport()
+ throws Exception {
+ JsonAsyncHttpPinotClientTransportFactory factory = new
JsonAsyncHttpPinotClientTransportFactory();
+ Properties properties = new Properties();
+ properties.setProperty("brokerReadTimeoutMs", "12000");
+ properties.setProperty("brokerConnectTimeoutMs", "3000");
+ PinotClientTransport<?> transport =
factory.withConnectionProperties(properties).buildTransport();
+ require(transport != null, "transport was not built");
+ transport.close();
+ }
+
+ /// The java client's query path, driven against a canned broker response
instead of a live cluster.
+ private void checkJavaClientQueryExecution() {
+ CannedResponseTransport transport = new
CannedResponseTransport(_sampleBrokerResponse);
+ Connection connection =
+ ConnectionFactory.fromHostList(new Properties(),
List.of("localhost:8000"), transport);
+ try {
+ ResultSetGroup resultSetGroup = connection.execute(
+ "SELECT playerName, playerId, numGames, battingAverage, isActive,
teams FROM " + SAMPLE_TABLE_NAME
+ + " LIMIT 3");
+ require(transport.getLastQuery() != null &&
transport.getLastQuery().contains(SAMPLE_TABLE_NAME),
+ "the transport never saw the query: %s", transport.getLastQuery());
+ require(!resultSetGroup.getBrokerResponse().hasExceptions(), "unexpected
exceptions: %s",
+ resultSetGroup.getExceptions());
+ require(resultSetGroup.getResultSetCount() == 1, "expected 1 result set,
got %d",
+ resultSetGroup.getResultSetCount());
+
+ ResultSet resultSet = resultSetGroup.getResultSet(0);
+ require(resultSet.getRowCount() == 3, "expected 3 rows, got %d",
resultSet.getRowCount());
+ require(resultSet.getColumnCount() == 6, "expected 6 columns, got %d",
resultSet.getColumnCount());
+ require("playerName".equals(resultSet.getColumnName(0)), "unexpected
column name: %s",
+ resultSet.getColumnName(0));
+ require("Hank Aaron".equals(resultSet.getString(0, 0)), "unexpected
string value: %s",
+ resultSet.getString(0, 0));
+ require(resultSet.getInt(0, 1) == 1001, "unexpected int value: %d",
resultSet.getInt(0, 1));
+ require(resultSet.getLong(0, 2) == 3298L, "unexpected long value: %d",
resultSet.getLong(0, 2));
+ require(Math.abs(resultSet.getDouble(0, 3) - 0.305) < 1e-9, "unexpected
double value: %s",
+ resultSet.getDouble(0, 3));
+ require("Shohei Ohtani".equals(resultSet.getString(2, 0)), "unexpected
last row: %s",
+ resultSet.getString(2, 0));
+
+ ExecutionStats stats = resultSetGroup.getExecutionStats();
+ require(stats.getNumDocsScanned() == 4231, "unexpected numDocsScanned:
%d", stats.getNumDocsScanned());
+ require(stats.getTotalDocs() == 97889, "unexpected totalDocs: %d",
stats.getTotalDocs());
+ require(stats.getNumServersQueried() == 2, "unexpected
numServersQueried: %d", stats.getNumServersQueried());
+ require(stats.getTimeUsedMs() == 37, "unexpected timeUsedMs: %d",
stats.getTimeUsedMs());
+ } finally {
+ connection.close();
+ }
+ require(transport.isClosed(), "closing the connection did not close the
transport");
+ }
+
+ /// Parameter binding in the java client's prepared statement.
+ private void checkJavaClientPreparedStatement() {
+ CannedResponseTransport transport = new
CannedResponseTransport(_sampleBrokerResponse);
+ Connection connection =
+ ConnectionFactory.fromHostList(new Properties(),
List.of("localhost:8000"), transport);
+ try {
+ PreparedStatement statement = connection.prepareStatement(
+ "SELECT playerName FROM " + SAMPLE_TABLE_NAME + " WHERE playerName =
? AND playerId > ?");
+ statement.setString(0, "Hank Aaron");
+ statement.setInt(1, 1000);
+ ResultSetGroup resultSetGroup = statement.execute();
+
+ String executedQuery = transport.getLastQuery();
+ require(executedQuery != null && executedQuery.contains("'Hank Aaron'"),
+ "string parameter was not bound: %s", executedQuery);
+ require(executedQuery.contains("1000"), "int parameter was not bound:
%s", executedQuery);
+ require(!executedQuery.contains("?"), "query still has unbound
parameters: %s", executedQuery);
+ require(resultSetGroup.getResultSetCount() == 1, "expected 1 result set,
got %d",
+ resultSetGroup.getResultSetCount());
+ } finally {
+ connection.close();
+ }
+ }
+
+ /// The JDBC driver has to be discoverable through {@link DriverManager}
without an explicit {@code Class.forName},
+ /// which means the {@code META-INF/services} descriptor and Java's {@code
ServiceLoader} have to work together on
+ /// this JVM.
+ private void checkJdbcDriverRegistration()
+ throws Exception {
+ require(getClass().getClassLoader().getResource(JDBC_SERVICE_DESCRIPTOR)
!= null,
+ "%s is missing from the classpath, so DriverManager cannot
auto-register the driver",
+ JDBC_SERVICE_DESCRIPTOR);
+
+ Driver driver = DriverManager.getDriver("jdbc:pinot://localhost:8000");
+
require("org.apache.pinot.client.PinotDriver".equals(driver.getClass().getName()),
+ "DriverManager resolved an unexpected driver: %s",
driver.getClass().getName());
+ require(driver.acceptsURL("jdbc:pinot://localhost:8000"), "the driver
rejected a pinot URL");
+ require(driver.acceptsURL("jdbc:pinotgrpc://localhost:8010"), "the driver
rejected a pinotgrpc URL");
+ require(!driver.acceptsURL("jdbc:mysql://localhost:3306/db"), "the driver
accepted a non-pinot URL");
+
+ DriverPropertyInfo[] propertyInfo =
driver.getPropertyInfo("jdbc:pinot://localhost:8000", new Properties());
+ List<String> propertyNames = new ArrayList<>();
+ for (DriverPropertyInfo info : propertyInfo) {
+ propertyNames.add(info.name);
+ }
+ // Membership, not position: a future driver property added ahead of
"tenant" is not a Java 11
+ // problem and must not turn this job red.
+ require(propertyNames.contains("tenant"), "the driver did not report a
tenant property: %s", propertyNames);
+ require(driver.getMajorVersion() > 0, "unexpected driver major version:
%d", driver.getMajorVersion());
+ }
+
+ /// The JDBC result set over a real broker response, including its metadata
and type mapping.
+ private void checkJdbcResultSet()
+ throws Exception {
+ // PinotResultSet.fromJson swallows failures and returns an empty result
set, so asserting on the
+ // row contents is what makes this check meaningful.
+ try (PinotResultSet resultSet =
PinotResultSet.fromJson(_sampleBrokerResponseJson)) {
+ ResultSetMetaData metaData = resultSet.getMetaData();
+ require(metaData.getColumnCount() == 6, "expected 6 columns, got %d",
metaData.getColumnCount());
+ require("playerName".equals(metaData.getColumnName(1)), "unexpected
column name: %s",
+ metaData.getColumnName(1));
+ require(metaData.getColumnType(1) == Types.VARCHAR, "expected VARCHAR
for a STRING column, got %d",
+ metaData.getColumnType(1));
+ require(metaData.getColumnType(4) == Types.DOUBLE, "expected DOUBLE for
a DOUBLE column, got %d",
+ metaData.getColumnType(4));
+
+ require(resultSet.next(), "the JDBC result set was empty --
PinotResultSet.fromJson swallowed a failure");
+ require("Hank Aaron".equals(resultSet.getString(1)), "unexpected string
value: %s", resultSet.getString(1));
+ require(resultSet.getInt(2) == 1001, "unexpected int value: %d",
resultSet.getInt(2));
+ require(resultSet.getLong(3) == 3298L, "unexpected long value: %d",
resultSet.getLong(3));
+ require(Math.abs(resultSet.getDouble(4) - 0.305) < 1e-9, "unexpected
double value: %s",
+ resultSet.getDouble(4));
+ require(new BigDecimal("0.305").compareTo(resultSet.getBigDecimal(4)) ==
0, "unexpected big decimal value: %s",
+ resultSet.getBigDecimal(4));
+ require("Hank Aaron".equals(resultSet.getString("playerName")), "column
lookup by name failed: %s",
+ resultSet.getString("playerName"));
+
+ int rowCount = 1;
+ while (resultSet.next()) {
+ rowCount++;
+ }
+ require(rowCount == 3, "expected 3 rows, got %d", rowCount);
+ }
+ }
+
+ //
---------------------------------------------------------------------------------------------
+ // Fixtures and helpers
+ //
---------------------------------------------------------------------------------------------
+
+ private static DataSchema sampleDataSchema() {
+ return new DataSchema(new String[]{
+ "playerName", "playerId", "numGames", "battingAverage", "isActive"
+ }, new ColumnDataType[]{
+ ColumnDataType.STRING, ColumnDataType.INT, ColumnDataType.LONG,
ColumnDataType.DOUBLE, ColumnDataType.BOOLEAN
+ });
+ }
+
+ /// A result table matching {@link #sampleDataSchema()}. Values are chosen
so that the assertions in {@link
+ /// #assertSampleRowsMatch} are exact rather than approximate.
+ private static ResultTable sampleResultTable() {
+ return new ResultTable(sampleDataSchema(), List.of(
+ new Object[]{"Hank Aaron", 1001, 3298L, 0.305d, false},
+ new Object[]{"Willie Mays", 1002, 2992L, 0.301d, false},
+ new Object[]{"Shohei Ohtani", 1003, 716L, 0.277d, true}));
+ }
+
+ /// Asserts that a decoded result table still carries the values from {@link
#sampleResultTable()}. Compares rendered
+ /// values because the encoders are free to pick their own boxed
representation for a given {@link ColumnDataType}.
+ private static void assertSampleRowsMatch(String label, ResultTable decoded)
{
+ List<Object[]> expectedRows = sampleResultTable().getRows();
+ for (int rowId = 0; rowId < expectedRows.size(); rowId++) {
+ Object[] expected = expectedRows.get(rowId);
+ Object[] actual = decoded.getRows().get(rowId);
+ require(actual.length == expected.length, "%s: row %d has %d columns,
expected %d", label, rowId, actual.length,
+ expected.length);
+ for (int colId = 0; colId < expected.length; colId++) {
+ String expectedValue = String.valueOf(expected[colId]);
+ String actualValue = String.valueOf(actual[colId]);
+ require(expectedValue.equals(actualValue), "%s: row %d column %d is
%s, expected %s", label, rowId, colId,
+ actualValue, expectedValue);
+ }
+ }
+ }
+
+ private static String readResource(String resource)
+ throws IOException {
+ try (InputStream inputStream =
Java11CompatibilityVerifier.class.getClassLoader()
+ .getResourceAsStream(resource)) {
+ if (inputStream == null) {
+ throw new IOException("Resource not found on the classpath: " +
resource);
+ }
+ return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ }
+
+ private static void require(boolean condition, String message, Object...
args) {
+ if (!condition) {
+ throw new AssertionError(args.length == 0 ? message :
String.format(message, args));
+ }
+ }
+}
diff --git
a/pinot-java11-client-verifier/src/main/resources/sample-broker-response.json
b/pinot-java11-client-verifier/src/main/resources/sample-broker-response.json
new file mode 100644
index 00000000000..710fc5a567b
--- /dev/null
+++
b/pinot-java11-client-verifier/src/main/resources/sample-broker-response.json
@@ -0,0 +1,53 @@
+{
+ "requestId": "8465172461064265729",
+ "brokerId": "Broker_192.168.1.10_8000",
+ "resultTable": {
+ "dataSchema": {
+ "columnNames": [
+ "playerName",
+ "playerId",
+ "numGames",
+ "battingAverage",
+ "isActive",
+ "teams"
+ ],
+ "columnDataTypes": [
+ "STRING",
+ "INT",
+ "LONG",
+ "DOUBLE",
+ "BOOLEAN",
+ "STRING_ARRAY"
+ ]
+ },
+ "rows": [
+ ["Hank Aaron", 1001, 3298, 0.305, false, ["MLN", "ATL", "MIL"]],
+ ["Willie Mays", 1002, 2992, 0.301, false, ["NY1", "SFN", "NYN"]],
+ ["Shohei Ohtani", 1003, 716, 0.277, true, ["LAA", "LAD"]]
+ ]
+ },
+ "exceptions": [],
+ "numRowsResultSet": 3,
+ "numServersQueried": 2,
+ "numServersResponded": 2,
+ "numSegmentsQueried": 12,
+ "numSegmentsProcessed": 8,
+ "numSegmentsMatched": 5,
+ "numConsumingSegmentsQueried": 1,
+ "numSegmentsPrunedByBroker": 2,
+ "numSegmentsPrunedByServer": 2,
+ "numDocsScanned": 4231,
+ "numEntriesScannedInFilter": 97889,
+ "numEntriesScannedPostFilter": 12693,
+ "numGroupsLimitReached": false,
+ "groupsTrimmed": false,
+ "partialResult": false,
+ "totalDocs": 97889,
+ "timeUsedMs": 37,
+ "brokerReduceTimeMs": 4,
+ "minConsumingFreshnessTimeMs": 1750000000000,
+ "offlineThreadCpuTimeNs": 1250000,
+ "realtimeThreadCpuTimeNs": 310000,
+ "tablesQueried": ["baseballStats"],
+ "traceInfo": {}
+}
diff --git
a/pinot-java11-client-verifier/src/test/java/org/apache/pinot/java11/ClasspathClosureScannerTest.java
b/pinot-java11-client-verifier/src/test/java/org/apache/pinot/java11/ClasspathClosureScannerTest.java
new file mode 100644
index 00000000000..10893fdf816
--- /dev/null
+++
b/pinot-java11-client-verifier/src/test/java/org/apache/pinot/java11/ClasspathClosureScannerTest.java
@@ -0,0 +1,367 @@
+/**
+ * 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.
+ */
+package org.apache.pinot.java11;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertThrows;
+import static org.testng.Assert.assertTrue;
+
+
+/// Guards the fail path of {@link ClasspathClosureScanner}.
+///
+/// The scanner is the only part of the Java 11 verifier that can go red on a
dependency bump, and both of its filters
+/// ({@code isLoadableClassEntry} and {@code readMajorVersion}) fail _open_ --
an input they do not recognise is treated
+/// as "not a violation". A regression in either therefore produces a
permanently green CI job rather than a red one,
+/// which is worse than having no job at all. These tests pin the behaviour
that keeps it honest, and need no Java 11
+/// JVM, so they run in the normal unit test job.
+public class ClasspathClosureScannerTest {
+ private static final int JAVA_11_FEATURE_VERSION = 11;
+ private static final int JAVA_11_MAJOR_VERSION = 55;
+ private static final int JAVA_17_MAJOR_VERSION = 61;
+ private static final int JAVA_21_MAJOR_VERSION = 65;
+ private static final String MANIFEST = "META-INF/MANIFEST.MF";
+
+ private Path _tempDir;
+
+ @BeforeClass
+ public void setUp()
+ throws IOException {
+ _tempDir = Files.createTempDirectory("closure-scanner-test");
+ }
+
+ @AfterClass
+ public void tearDown()
+ throws IOException {
+ if (_tempDir == null) {
+ return;
+ }
+ try (Stream<Path> paths = Files.walk(_tempDir)) {
+ paths.sorted(Comparator.reverseOrder()).forEach(path -> {
+ try {
+ Files.delete(path);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ }
+ }
+
+ @Test
+ public void testPostTargetBytecodeAtJarRootIsAViolation()
+ throws IOException {
+ Path jar = writeJar("too-new.jar", Map.of("com/example/Foo.class",
classFile(JAVA_21_MAJOR_VERSION)));
+
+ ClasspathClosureScanner.Result result =
ClasspathClosureScanner.scan(jar.toString(), JAVA_11_FEATURE_VERSION);
+
+ assertEquals(result.getTotalViolationCount(), 1);
+ assertEquals(result.getClassFilesInArchives(), 1);
+
assertTrue(result.getReportedViolations().get(0).toString().contains("com/example/Foo.class"),
+ "the violation should name the offending entry: " +
result.getReportedViolations().get(0));
+ assertTrue(result.getReportedViolations().get(0).toString().contains("Java
21"),
+ "the violation should translate the major version: " +
result.getReportedViolations().get(0));
+ }
+
+ @Test
+ public void testTargetBytecodeIsNotAViolation()
+ throws IOException {
+ Path jar = writeJar("just-right.jar", Map.of("com/example/Foo.class",
classFile(JAVA_11_MAJOR_VERSION)));
+
+ ClasspathClosureScanner.Result result =
ClasspathClosureScanner.scan(jar.toString(), JAVA_11_FEATURE_VERSION);
+
+ assertEquals(result.getTotalViolationCount(), 0);
+ assertEquals(result.getClassFilesInArchives(), 1);
+ assertEquals(result.getMajorVersionHistogram(),
Map.of(JAVA_11_MAJOR_VERSION, 1));
+ }
+
+ /// A module descriptor is never loaded from the classpath, whatever version
built it.
+ @Test
+ public void testModuleInfoIsIgnoredAtAnyVersion()
+ throws IOException {
+ Map<String, byte[]> entries = new LinkedHashMap<>();
+ entries.put("module-info.class", classFile(JAVA_21_MAJOR_VERSION));
+ entries.put("META-INF/versions/9/module-info.class",
classFile(JAVA_21_MAJOR_VERSION));
+ Path jar = writeJar("with-module-info.jar", entries);
+
+ ClasspathClosureScanner.Result result =
ClasspathClosureScanner.scan(jar.toString(), JAVA_11_FEATURE_VERSION);
+
+ assertEquals(result.getTotalViolationCount(), 0);
+ assertEquals(result.getClassFilesInArchives(), 0);
+ }
+
+ /// Real multi-release jars on the client closure (jackson-core,
jersey-common) ship Java 17 and 21 bytecode under
+ /// META-INF/versions. Flagging those would fail the job for classes a Java
11 JVM never loads, so this is the
+ /// false-positive guard.
+ @Test
+ public void testMultiReleaseEntriesAboveTargetAreIgnored()
+ throws IOException {
+ Map<String, byte[]> entries = new LinkedHashMap<>();
+ entries.put(MANIFEST, multiReleaseManifest());
+ entries.put("com/example/Foo.class", classFile(JAVA_11_MAJOR_VERSION));
+ entries.put("META-INF/versions/17/com/example/Foo.class",
classFile(JAVA_17_MAJOR_VERSION));
+ entries.put("META-INF/versions/21/com/example/Foo.class",
classFile(JAVA_21_MAJOR_VERSION));
+ Path jar = writeJar("multi-release.jar", entries);
+
+ ClasspathClosureScanner.Result result =
ClasspathClosureScanner.scan(jar.toString(), JAVA_11_FEATURE_VERSION);
+
+ assertEquals(result.getTotalViolationCount(), 0);
+ assertEquals(result.getClassFilesInArchives(), 1, "only the baseline entry
should have been inspected");
+ }
+
+ /// The other half of the multi-release rule: in a real multi-release jar a
versioned directory at or below the target
+ /// _is_ selected, so bytecode too new for it is a real violation. Without
this the scanner could skip every
+ /// META-INF/versions entry and still look correct.
+ @Test
+ public void testMultiReleaseEntriesAtOrBelowTargetAreChecked()
+ throws IOException {
+ Map<String, byte[]> entries = new LinkedHashMap<>();
+ entries.put(MANIFEST, multiReleaseManifest());
+ entries.put("META-INF/versions/9/com/example/Foo.class",
classFile(JAVA_21_MAJOR_VERSION));
+ Path jar = writeJar("multi-release-9.jar", entries);
+
+ ClasspathClosureScanner.Result result =
ClasspathClosureScanner.scan(jar.toString(), JAVA_11_FEATURE_VERSION);
+
+ assertEquals(result.getTotalViolationCount(), 1);
+ }
+
+ /// A JVM only performs versioned lookup when the manifest says {@code
Multi-Release: true}. Without it the whole
+ /// META-INF/versions tree is inert data, so nothing under it may be
reported -- including entries at or below the
+ /// target, which would otherwise be a false positive on a shaded jar that
merged versioned entries but lost the
+ /// attribute.
+ @Test
+ public void
testVersionedEntriesAreIgnoredWhenTheManifestDoesNotDeclareMultiRelease()
+ throws IOException {
+ Map<String, byte[]> entries = new LinkedHashMap<>();
+ entries.put(MANIFEST, manifest("Manifest-Version: 1.0\n"));
+ entries.put("META-INF/versions/9/com/example/Below.class",
classFile(JAVA_21_MAJOR_VERSION));
+ entries.put("META-INF/versions/21/com/example/Above.class",
classFile(JAVA_21_MAJOR_VERSION));
+ Path jar = writeJar("not-multi-release.jar", entries);
+
+ ClasspathClosureScanner.Result result =
ClasspathClosureScanner.scan(jar.toString(), JAVA_11_FEATURE_VERSION);
+
+ assertEquals(result.getTotalViolationCount(), 0);
+ assertEquals(result.getClassFilesInArchives(), 0);
+ }
+
+ /// Same rule when there is no manifest at all.
+ @Test
+ public void testVersionedEntriesAreIgnoredWhenThereIsNoManifest()
+ throws IOException {
+ Path jar = writeJar("no-manifest.jar",
+ Map.of("META-INF/versions/9/com/example/Foo.class",
classFile(JAVA_21_MAJOR_VERSION)));
+
+ ClasspathClosureScanner.Result result =
ClasspathClosureScanner.scan(jar.toString(), JAVA_11_FEATURE_VERSION);
+
+ assertEquals(result.getTotalViolationCount(), 0);
+ assertEquals(result.getClassFilesInArchives(), 0);
+ }
+
+ /// The attribute value is case-insensitive per the JAR spec.
+ @Test
+ public void testMultiReleaseAttributeValueIsCaseInsensitive()
+ throws IOException {
+ Map<String, byte[]> entries = new LinkedHashMap<>();
+ entries.put(MANIFEST, manifest("Manifest-Version: 1.0\nMulti-Release:
TRUE\n"));
+ entries.put("META-INF/versions/9/com/example/Foo.class",
classFile(JAVA_21_MAJOR_VERSION));
+ Path jar = writeJar("multi-release-caps.jar", entries);
+
+ ClasspathClosureScanner.Result result =
ClasspathClosureScanner.scan(jar.toString(), JAVA_11_FEATURE_VERSION);
+
+ assertEquals(result.getTotalViolationCount(), 1);
+ }
+
+ @Test
+ public void testMalformedMultiReleaseDirectoryIsIgnored()
+ throws IOException {
+ Map<String, byte[]> entries = new LinkedHashMap<>();
+ entries.put(MANIFEST, multiReleaseManifest());
+ entries.put("META-INF/versions/notanumber/com/example/Foo.class",
classFile(JAVA_21_MAJOR_VERSION));
+ entries.put("META-INF/versions/Foo.class",
classFile(JAVA_21_MAJOR_VERSION));
+ Path jar = writeJar("malformed-multi-release.jar", entries);
+
+ ClasspathClosureScanner.Result result =
ClasspathClosureScanner.scan(jar.toString(), JAVA_11_FEATURE_VERSION);
+
+ assertEquals(result.getTotalViolationCount(), 0);
+ assertEquals(result.getClassFilesInArchives(), 0);
+ }
+
+ /// Jars do package non-bytecode files under a .class name; those must not
be misread as violations.
+ @Test
+ public void testEntryWithoutClassFileMagicIsNotCounted()
+ throws IOException {
+ Map<String, byte[]> entries = new LinkedHashMap<>();
+ entries.put("com/example/NotReallyAClass.class", "this is not
bytecode".getBytes(StandardCharsets.UTF_8));
+ entries.put("com/example/Truncated.class", new byte[]{(byte) 0xCA, (byte)
0xFE});
+ entries.put("com/example/Real.class", classFile(JAVA_11_MAJOR_VERSION));
+ Path jar = writeJar("not-bytecode.jar", entries);
+
+ ClasspathClosureScanner.Result result =
ClasspathClosureScanner.scan(jar.toString(), JAVA_11_FEATURE_VERSION);
+
+ assertEquals(result.getTotalViolationCount(), 0);
+ assertEquals(result.getClassFilesInArchives(), 1, "only the real class
file should have been counted");
+ }
+
+ @Test
+ public void testNonClassEntriesAreIgnored()
+ throws IOException {
+ Map<String, byte[]> entries = new LinkedHashMap<>();
+ entries.put("META-INF/MANIFEST.MF", "Manifest-Version:
1.0\n".getBytes(StandardCharsets.UTF_8));
+ entries.put("com/example/resource.json",
"{}".getBytes(StandardCharsets.UTF_8));
+ Path jar = writeJar("resources-only.jar", entries);
+
+ ClasspathClosureScanner.Result result =
ClasspathClosureScanner.scan(jar.toString(), JAVA_11_FEATURE_VERSION);
+
+ assertEquals(result.getTotalViolationCount(), 0);
+ assertEquals(result.getClassFilesInArchives(), 0);
+ assertEquals(result.getArchivesScanned(), 1);
+ }
+
+ /// Directory entries are scanned too, but counted apart so they cannot
satisfy the vacuity guard.
+ @Test
+ public void testDirectoryEntriesAreScannedAndCountedSeparately()
+ throws IOException {
+ Path classesDir =
Files.createDirectories(_tempDir.resolve("classes/com/example"));
+ Files.write(classesDir.resolve("Foo.class"),
classFile(JAVA_21_MAJOR_VERSION));
+ Files.write(classesDir.resolve("module-info.class"),
classFile(JAVA_21_MAJOR_VERSION));
+ Files.write(classesDir.resolve("notes.txt"),
"hello".getBytes(StandardCharsets.UTF_8));
+
+ ClasspathClosureScanner.Result result =
+ ClasspathClosureScanner.scan(_tempDir.resolve("classes").toString(),
JAVA_11_FEATURE_VERSION);
+
+ assertEquals(result.getTotalViolationCount(), 1);
+ assertEquals(result.getClassFilesInDirectories(), 1);
+ assertEquals(result.getClassFilesInArchives(), 0);
+ assertEquals(result.getDirectoriesScanned(), 1);
+ }
+
+ @Test
+ public void testCorruptArchiveIsAHardFailure()
+ throws IOException {
+ Path notAJar = _tempDir.resolve("corrupt.jar");
+ Files.write(notAJar, "definitely not a
zip".getBytes(StandardCharsets.UTF_8));
+
+ assertThrows(IOException.class, () ->
ClasspathClosureScanner.scan(notAJar.toString(), JAVA_11_FEATURE_VERSION));
+ }
+
+ @Test
+ public void testNonArchiveAndMissingEntriesAreReportedAsSkipped()
+ throws IOException {
+ Path pom = _tempDir.resolve("groovy-all-3.0.25.pom");
+ Files.write(pom, "<project/>".getBytes(StandardCharsets.UTF_8));
+ String missing = _tempDir.resolve("does-not-exist.jar").toString();
+ String classpath = pom + File.pathSeparator + missing;
+
+ ClasspathClosureScanner.Result result =
ClasspathClosureScanner.scan(classpath, JAVA_11_FEATURE_VERSION);
+
+ assertEquals(result.getSkippedEntries().size(), 2, "both entries should be
reported: "
+ + result.getSkippedEntries());
+ assertTrue(result.getSkippedEntries().get(0).endsWith("(not an archive)"),
result.getSkippedEntries().get(0));
+ assertTrue(result.getSkippedEntries().get(1).endsWith("(does not exist)"),
result.getSkippedEntries().get(1));
+ assertEquals(result.getArchivesScanned(), 0);
+ }
+
+ @Test
+ public void testViolationReportingIsCappedButCountIsNot()
+ throws IOException {
+ Map<String, byte[]> entries = new LinkedHashMap<>();
+ for (int i = 0; i < 100; i++) {
+ entries.put("com/example/Foo" + i + ".class",
classFile(JAVA_21_MAJOR_VERSION));
+ }
+ Path jar = writeJar("many-violations.jar", entries);
+
+ ClasspathClosureScanner.Result result =
ClasspathClosureScanner.scan(jar.toString(), JAVA_11_FEATURE_VERSION);
+
+ assertEquals(result.getTotalViolationCount(), 100);
+ assertEquals(result.getReportedViolations().size(),
ClasspathClosureScanner.MAX_REPORTED_VIOLATIONS,
+ "reporting should be capped for log sanity while the total count stays
exact");
+ }
+
+ /// The target version is a parameter, so the same scanner has to work if
the floor ever moves.
+ @Test
+ public void testTargetVersionIsHonoured()
+ throws IOException {
+ Path jar = writeJar("java17.jar", Map.of("com/example/Foo.class",
classFile(JAVA_17_MAJOR_VERSION)));
+
+ assertEquals(ClasspathClosureScanner.scan(jar.toString(),
11).getTotalViolationCount(), 1);
+ assertEquals(ClasspathClosureScanner.scan(jar.toString(),
17).getTotalViolationCount(), 0);
+ assertEquals(ClasspathClosureScanner.scan(jar.toString(),
21).getTotalViolationCount(), 0);
+ }
+
+ @Test
+ public void testArchiveNamesAreRecordedForCoverageAssertions()
+ throws IOException {
+ Path first = writeJar("pinot-spi-1.6.0-SNAPSHOT.jar",
+ Map.of("com/example/Foo.class", classFile(JAVA_11_MAJOR_VERSION)));
+ Path second = writeJar("pinot-common-1.6.0-SNAPSHOT.jar",
+ Map.of("com/example/Bar.class", classFile(JAVA_11_MAJOR_VERSION)));
+
+ ClasspathClosureScanner.Result result =
+ ClasspathClosureScanner.scan(first + File.pathSeparator + second,
JAVA_11_FEATURE_VERSION);
+
+ assertEquals(result.getArchiveNames(),
List.of("pinot-spi-1.6.0-SNAPSHOT.jar", "pinot-common-1.6.0-SNAPSHOT.jar"));
+ }
+
+ private Path writeJar(String name, Map<String, byte[]> entries)
+ throws IOException {
+ Path jar = _tempDir.resolve(name);
+ try (OutputStream fileOut = Files.newOutputStream(jar); ZipOutputStream
zipOut = new ZipOutputStream(fileOut)) {
+ for (Map.Entry<String, byte[]> entry : entries.entrySet()) {
+ zipOut.putNextEntry(new ZipEntry(entry.getKey()));
+ zipOut.write(entry.getValue());
+ zipOut.closeEntry();
+ }
+ }
+ return jar;
+ }
+
+ private static byte[] multiReleaseManifest() {
+ return manifest("Manifest-Version: 1.0\nMulti-Release: true\n");
+ }
+
+ private static byte[] manifest(String content) {
+ return content.getBytes(StandardCharsets.UTF_8);
+ }
+
+ /// The first 8 bytes of a class file are all the scanner reads: magic,
minor version, major version.
+ private static byte[] classFile(int majorVersion) {
+ return ByteBuffer.allocate(10)
+ .putInt(0xCAFEBABE)
+ .putShort((short) 0)
+ .putShort((short) majorVersion)
+ .putShort((short) 0)
+ .array();
+ }
+}
diff --git a/pom.xml b/pom.xml
index f251d146816..dd7ff7df93d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -59,6 +59,7 @@
<module>pinot-connectors</module>
<module>pinot-segment-local</module>
<module>pinot-compatibility-verifier</module>
+ <module>pinot-java11-client-verifier</module>
<module>pinot-query-planner-spi</module>
<module>pinot-query-planner</module>
<module>pinot-query-runtime</module>
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]