diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f6a0cd74..9dd89f36 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -32,7 +32,17 @@ jobs:
# zstd is required to compile the native library.
submodules: recursive
+ - name: Set up JDK 11 (MRJAR versioned classes)
+ # Exports JAVA_HOME_11_X64. The JDK 8 build compiles src/main/java11 with
+ # it into META-INF/versions/11 (see the antrun execution in core/pom.xml);
+ # JDK 8 javac cannot compile that source root.
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "11"
+
- name: Set up JDK 8
+ # Last setup-java call wins JAVA_HOME: the build itself runs on JDK 8.
uses: actions/setup-java@v4
with:
distribution: temurin
@@ -60,8 +70,58 @@ jobs:
src/main/resources/io/questdb/client/bin/linux-x86-64/libquestdb.so
- name: Compile, test, and build javadoc
+ env:
+ JAVA11_HOME: ${{ env.JAVA_HOME_11_X64 }}
run: mvn -B -ntp -P javadoc clean install
+ - name: Upload client jar for the cross-JDK smoke job
+ uses: actions/upload-artifact@v4
+ with:
+ name: questdb-client-jar
+ path: |
+ core/target/questdb-client-*.jar
+ !core/target/*-tests.jar
+ !core/target/*-javadoc.jar
+ !core/target/*-sources.jar
+ if-no-files-found: error
+
+ # The 1.3.5-1.3.7 releases shipped a JDK 8-built jar whose FdBig class links
+ # sun.misc.FDBigInteger (gone since Java 9) and whose module name degraded to
+ # the filename-derived "questdb.client". Both are only observable in the
+ # PACKAGED jar on a modern JDK, so take the jar built by build-jdk8 and prove
+ # on JDK 25 that (a) the automatic module name is io.questdb.client and
+ # (b) slow-path double formatting resolves the META-INF/versions/11 bridge.
+ mrjar-smoke-jdk25:
+ name: MRJAR smoke (JDK 8 jar on JDK 25)
+ needs: build-jdk8
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - name: Check out
+ uses: actions/checkout@v4
+
+ - name: Set up JDK 25
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "25"
+
+ - name: Download JDK 8-built client jar
+ uses: actions/download-artifact@v4
+ with:
+ name: questdb-client-jar
+ path: client-jar
+
+ - name: Check module name and run double-formatting smoke
+ run: |
+ jar_file=(client-jar/questdb-client-*.jar)
+ jar_file="${jar_file[0]}"
+ jar --describe-module --file "$jar_file" | tee module.txt
+ grep -q '^io\.questdb\.client@' module.txt
+ javac -cp "$jar_file" -d smoke-classes \
+ core/src/test/java/io/questdb/client/test/std/DoubleFormatSmoke.java
+ java -cp "$jar_file:smoke-classes" io.questdb.client.test.std.DoubleFormatSmoke
+
# The client is also consumed as a submodule of the main questdb repo, which
# builds on JDK 25. Guard against JDK 25 compile breakage (main + test
# sources, both modules) and confirm the javadoc jar builds on JDK 25 too
diff --git a/.github/workflows/maven_central_release.yml b/.github/workflows/maven_central_release.yml
index ffb92b3c..89392c53 100644
--- a/.github/workflows/maven_central_release.yml
+++ b/.github/workflows/maven_central_release.yml
@@ -465,7 +465,17 @@ jobs:
with:
ref: ${{ needs.resolve.outputs.source_sha }}
+ - name: Set up Java 11 (MRJAR versioned classes)
+ # Exports JAVA_HOME_11_X64. The JDK 8 build compiles src/main/java11 with
+ # it into META-INF/versions/11 (see the antrun execution in core/pom.xml);
+ # JDK 8 javac cannot compile that source root.
+ uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+ with:
+ distribution: temurin
+ java-version: "11"
+
- name: Set up Java 8
+ # Last setup-java call wins JAVA_HOME: the build itself runs on JDK 8.
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: temurin
@@ -487,6 +497,8 @@ jobs:
mvn -B -ntp org.codehaus.mojo:versions-maven-plugin:2.16.2:set -DnewVersion="${RELEASE_VERSION}" -DprocessAllModules=true -DgenerateBackupPoms=false
- name: Verify release artifact (full test suite, native libs bundled)
+ env:
+ JAVA11_HOME: ${{ env.JAVA_HOME_11_X64 }}
run: |
# Tests on -- this is the gate. The bundled linux-x86-64 native library
# is exercised by the real test suite before anyone approves the publish.
@@ -559,7 +571,16 @@ jobs:
exit 1
fi
+ - name: Set up Java 11 (MRJAR versioned classes)
+ # Same as the verify job: the deploy below re-packages the jar on JDK 8
+ # and needs JDK 11 for the META-INF/versions/11 classes.
+ uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+ with:
+ distribution: temurin
+ java-version: "11"
+
- name: Set up Java 8
+ # Last setup-java call wins JAVA_HOME: the build itself runs on JDK 8.
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: temurin
@@ -637,6 +658,8 @@ jobs:
- name: Upload signed bundle to Central (validate only, droppable)
id: upload
+ env:
+ JAVA11_HOME: ${{ env.JAVA_HOME_11_X64 }}
run: |
# autoPublish=false + waitUntil=validated (set in core/pom.xml) makes the
# build block ONLY on validation (VALIDATING -> VALIDATED, a few minutes;
diff --git a/CLAUDE.md b/CLAUDE.md
index 7d2e56b6..4c103cda 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -68,6 +68,14 @@ Build on a JDK 8 to validate the shipping artifact (the source-of-truth
target); also confirm it compiles on a modern JDK (11+) before merging, the
same two fronts CI guards.
+Packaging on JDK 8 additionally needs `JAVA11_HOME` pointing at a JDK 11+:
+the jar is a Multi-Release jar whose `META-INF/versions/11` classes (the
+`src/main/java11` FdBig/Compat bridge) cannot be compiled by JDK 8 javac.
+Without them a JDK 8-built jar throws `NoClassDefFoundError:
+sun/misc/FDBigInteger` on Java 9+ (the 1.3.5–1.3.7 regression), so the build
+fails fast instead of skipping. Test-only runs (`mvn -pl core test`) don't
+need it.
+
The parent `questdb` repo's `local-client` profile pulls this module as a
sub-module so server changes can build against unpublished client code; if
you change client code, install it (or pass `-P local-client` in the parent)
diff --git a/core/pom.xml b/core/pom.xml
index ef130cd6..91a4d686 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -119,6 +119,12 @@
${buildNumber}
${project.version}
+
+ io.questdb.client
+ true
@@ -185,9 +191,36 @@
${test.exclude}
+
+ **/*IT.class
+
+
+ org.apache.maven.plugins
+ maven-failsafe-plugin
+ 3.5.3
+
+
+
+ integration-test
+ verify
+
+
+
+
+ false
+
+ ${project.build.directory}/${project.build.finalName}.jar
+ ${project.build.testOutputDirectory}
+ ${project.basedir}/src/main/java11
+
+
+
@@ -416,6 +449,30 @@
maven-central-publish
+
+
+ org.apache.maven.plugins
+ maven-enforcer-plugin
+ 3.0.0-M3
+
+
+ enforce-publish-from-jdk8
+
+ enforce
+
+
+
+
+ [1.8,1.9)
+ questdb-client must be published from JDK 8; a JDK 11+ build produces a jar that breaks Java 8 consumers.
+
+
+
+
+
+
org.sonatype.central
central-publishing-maven-plugin
@@ -519,10 +576,61 @@
1.8
1.3.15
+
+ ${env.JAVA11_HOME}
1.8
+
+
+
+
+ org.apache.maven.plugins
+ maven-antrun-plugin
+ 3.1.0
+
+
+ compile-mrjar-versions-11
+ prepare-package
+
+ run
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
org.jetbrains
diff --git a/core/src/test/java/io/questdb/client/test/std/DoubleFormatSmoke.java b/core/src/test/java/io/questdb/client/test/std/DoubleFormatSmoke.java
new file mode 100644
index 00000000..17ca50d5
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/std/DoubleFormatSmoke.java
@@ -0,0 +1,67 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * Copyright (c) 2014-2019 Appsicle
+ * Copyright (c) 2019-2026 QuestDB
+ *
+ * Licensed 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 io.questdb.client.test.std;
+
+import io.questdb.client.std.str.StringSink;
+
+/**
+ * Standalone (no JUnit) smoke check that double formatting works against the
+ * packaged jar on the running JDK. The extreme-exponent values exercise the
+ * FdBig fallback of {@code Numbers.appendDouble0}, which resolves
+ * {@code FDBigInteger} from a different JDK-internal package on Java 8 vs 9+;
+ * a jar packaged with the wrong bridge dies here with
+ * {@code NoClassDefFoundError: sun/misc/FDBigInteger} (the 1.3.5-1.3.7
+ * regression). Run by {@link JarPackagingIT} on the build JDK and by CI on
+ * JDK 25 against the JDK 8-built jar.
+ */
+public final class DoubleFormatSmoke {
+
+ public static void main(String[] args) {
+ double[] values = {
+ 0.0d,
+ 123.456d,
+ // FdBig slow-path values
+ 1.0E-300,
+ Double.MIN_VALUE,
+ Double.MAX_VALUE,
+ -2.225073858507201E-308,
+ 1.1317400099603851E308
+ };
+ for (int i = 0; i < values.length; i++) {
+ double d = values[i];
+ StringSink sink = new StringSink();
+ sink.put(d);
+ String formatted = sink.toString();
+ if (Double.doubleToLongBits(Double.parseDouble(formatted)) != Double.doubleToLongBits(d)) {
+ System.err.println("FAIL: " + d + " formatted as \"" + formatted + "\" does not round-trip");
+ System.exit(1);
+ }
+ }
+ System.out.println("OK: double formatting works on Java " + System.getProperty("java.version"));
+ }
+
+ private DoubleFormatSmoke() {
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/std/JarPackagingIT.java b/core/src/test/java/io/questdb/client/test/std/JarPackagingIT.java
new file mode 100644
index 00000000..549768a7
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/std/JarPackagingIT.java
@@ -0,0 +1,184 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * Copyright (c) 2014-2019 Appsicle
+ * Copyright (c) 2019-2026 QuestDB
+ *
+ * Licensed 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 io.questdb.client.test.std;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.jar.Attributes;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+
+/**
+ * Guards the packaged jar against the 1.3.5-1.3.7 release regressions, which
+ * unit tests cannot see because they run against target/classes where neither
+ * the manifest nor Multi-Release versioned-class selection exists:
+ *
+ * - the module name silently changing from {@code io.questdb.client} to the
+ * filename-derived {@code questdb.client} (no module-info.class on JDK 8
+ * builds and no Automatic-Module-Name),
+ * - JDK 8 builds shipping only the {@code sun.misc.FDBigInteger} FdBig
+ * bridge, which throws {@code NoClassDefFoundError} on Java 9+ without the
+ * {@code META-INF/versions/11} counterpart.
+ *
+ */
+public class JarPackagingIT {
+ private static final String FD_BIG_ENTRY = "io/questdb/client/std/FdBig.class";
+ private static final String VERSIONED_FD_BIG_ENTRY = "META-INF/versions/11/" + FD_BIG_ENTRY;
+
+ @Test
+ public void testDoubleFormattingAgainstPackagedJar() throws Exception {
+ runSmokeAgainstJar(System.getProperty("java.home"));
+ if ("1.8".equals(System.getProperty("java.specification.version"))) {
+ // On a JDK 8 build the run above only exercised the root sun.misc
+ // classes. Run again on the JDK 11+ that compiled the versioned
+ // bridge, so every packaging build -- including the release verify
+ // gate -- EXECUTES the META-INF/versions/11 classes instead of only
+ // checking they exist. Required, not skipped-if-absent: packaging
+ // already failed earlier without a JDK 11 (see the antrun step).
+ String bridgeJdkHome = System.getProperty("java11.home");
+ if (bridgeJdkHome == null || bridgeJdkHome.isEmpty() || bridgeJdkHome.startsWith("${")) {
+ bridgeJdkHome = System.getenv("JAVA11_HOME");
+ }
+ Assert.assertNotNull("JAVA11_HOME (or -Djava11.home) must point at a JDK 11+", bridgeJdkHome);
+ runSmokeAgainstJar(bridgeJdkHome);
+ }
+ }
+
+ private static void runSmokeAgainstJar(String jdkHome) throws Exception {
+ String javaBin = jdkHome + File.separator + "bin" + File.separator + "java";
+ String classpath = jarPath() + File.pathSeparator + System.getProperty("questdb.client.test.classes");
+ Process process = new ProcessBuilder(javaBin, "-cp", classpath, DoubleFormatSmoke.class.getName())
+ .redirectErrorStream(true)
+ .start();
+ String output = readFully(process.getInputStream());
+ int exitCode = process.waitFor();
+ Assert.assertEquals("double formatting against the packaged jar failed on " + javaBin + ":\n" + output, 0, exitCode);
+ }
+
+ @Test
+ public void testJarLayout() throws Exception {
+ try (JarFile jar = new JarFile(jarPath())) {
+ Attributes attrs = jar.getManifest().getMainAttributes();
+ Assert.assertEquals("io.questdb.client", attrs.getValue("Automatic-Module-Name"));
+ Assert.assertEquals("true", attrs.getValue("Multi-Release"));
+
+ if ("1.8".equals(System.getProperty("java.specification.version"))) {
+ // JDK 8 build: the shipping layout. Root classes target Java 8,
+ // the java11 bridge rides in META-INF/versions/11.
+ Assert.assertTrue(
+ "root FdBig of a JDK 8 build must use sun.misc.FDBigInteger",
+ classReferences(jar, FD_BIG_ENTRY, "sun/misc/FDBigInteger")
+ );
+ Assert.assertTrue(
+ "META-INF/versions/11 FdBig must use jdk.internal.math.FDBigInteger",
+ classReferences(jar, VERSIONED_FD_BIG_ENTRY, "jdk/internal/math/FDBigInteger")
+ );
+ Assert.assertNotNull(
+ "META-INF/versions/11 must carry the java11 Compat shim",
+ jar.getEntry("META-INF/versions/11/io/questdb/client/std/Compat.class")
+ );
+ // every source in src/main/java11 must ship in versions/11 -- a file added
+ // to the source root but missed by the packaging step would recreate the
+ // NoClassDefFoundError class of bug on Java 9+
+ File java11SrcRoot = new File(System.getProperty("questdb.client.java11.src"));
+ Assert.assertTrue("src/main/java11 not found: " + java11SrcRoot, java11SrcRoot.isDirectory());
+ for (String relativeSource : collectJavaSources(java11SrcRoot, "")) {
+ String entry = "META-INF/versions/11/" + relativeSource.replaceAll("\\.java$", ".class");
+ Assert.assertNotNull("src/main/java11/" + relativeSource + " has no packaged counterpart " + entry, jar.getEntry(entry));
+ }
+ } else {
+ // JDK 11+ build: dev/smoke only, never shipped. Root classes are the
+ // java11 variants and the real module descriptor is present.
+ Assert.assertTrue(
+ "root FdBig of a JDK 11+ build must use jdk.internal.math.FDBigInteger",
+ classReferences(jar, FD_BIG_ENTRY, "jdk/internal/math/FDBigInteger")
+ );
+ Assert.assertNotNull("module-info.class missing", jar.getEntry("module-info.class"));
+ }
+ }
+ }
+
+ private static java.util.List collectJavaSources(File dir, String prefix) {
+ java.util.List result = new java.util.ArrayList();
+ File[] files = dir.listFiles();
+ if (files != null) {
+ for (File file : files) {
+ if (file.isDirectory()) {
+ result.addAll(collectJavaSources(file, prefix + file.getName() + "/"));
+ } else if (file.getName().endsWith(".java")) {
+ result.add(prefix + file.getName());
+ }
+ }
+ }
+ return result;
+ }
+
+ private static boolean classReferences(JarFile jar, String entryName, String constant) throws IOException {
+ JarEntry entry = jar.getJarEntry(entryName);
+ Assert.assertNotNull("jar entry missing: " + entryName, entry);
+ byte[] classBytes;
+ try (InputStream in = jar.getInputStream(entry)) {
+ classBytes = readAll(in);
+ }
+ // the referenced class name appears verbatim as a constant-pool UTF-8 entry
+ byte[] needle = constant.getBytes("UTF-8");
+ for (int i = 0; i <= classBytes.length - needle.length; i++) {
+ int j = 0;
+ while (j < needle.length && classBytes[i + j] == needle[j]) {
+ j++;
+ }
+ if (j == needle.length) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static String jarPath() {
+ String path = System.getProperty("questdb.client.jar");
+ Assert.assertNotNull("questdb.client.jar system property not set", path);
+ Assert.assertTrue("packaged jar not found: " + path, new File(path).exists());
+ return path;
+ }
+
+ private static byte[] readAll(InputStream in) throws IOException {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ byte[] buffer = new byte[8192];
+ int n;
+ while ((n = in.read(buffer)) != -1) {
+ out.write(buffer, 0, n);
+ }
+ return out.toByteArray();
+ }
+
+ private static String readFully(InputStream in) throws IOException {
+ return new String(readAll(in), "UTF-8");
+ }
+}