From 0ed5d14b736d147ed4ea40643b7590a9389f7d4f Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Fri, 11 Sep 2026 17:44:55 +0200 Subject: [PATCH 1/7] fix(core): restore double formatting on JDK 26 Applications running the client on JDK 26 crash with IllegalAccessError the first time they send a double that needs the slow formatting path, such as 1e300, 4.9e-324 or Double.MAX_VALUE. Common values like 0.1 or 123456.789 are unaffected, so the failure shows up late and only for some rows. Building the client from source on JDK 26 fails outright. Cause: JDK 26 (JDK-8366017) made jdk.internal.math.FDBigInteger, which the client's double formatter borrows for its bignum arithmetic, package-private. No module export can make a non-public class reachable. Fix: the Java 9+ bridge now binds the eight FDBigInteger methods it needs through method handles resolved once at class-init, instead of naming the class in source. Existing --add-exports plumbing and the reflective module export become unnecessary and are removed. There is no performance cost: JMH on JDK 25 shows identical ns/op and B/op to the previous direct calls. Supported runtimes are unchanged: Java 8 through 26. The shipping JDK 8 artifact was verified on Java 8, 25 and 26; builds and tests were also run on JDK 11, 17, 25 and 26. A JMH benchmark for the double formatter is added under core/src/test. Fixes #96 --- core/pom.xml | 26 --- .../java11/io/questdb/client/std/Compat.java | 19 -- .../java11/io/questdb/client/std/FdBig.java | 153 +++++++++++++--- .../java8/io/questdb/client/std/Compat.java | 7 - .../java8/io/questdb/client/std/FdBig.java | 6 - .../test/std/DoubleFormatBenchmark.java | 164 ++++++++++++++++++ .../client/test/std/JarPackagingIT.java | 12 +- 7 files changed, 302 insertions(+), 85 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/std/DoubleFormatBenchmark.java diff --git a/core/pom.xml b/core/pom.xml index bcef13053..9996932a2 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -80,8 +80,6 @@ 3.11.0 - ${compilerArg1} - ${compilerArg2} -J-XX:-TieredCompilation -J-XX:TieredStopAtLevel=1 @@ -266,10 +264,6 @@ none ${javac.compile.source} false - - ${javadocJOption1} - ${javadocJOption2} - ${excludePattern1} module-info.java @@ -398,10 +392,6 @@ none ${javac.compile.source} false - - ${javadocJOption1} - ${javadocJOption2} - ${excludePattern1} module-info.java @@ -524,13 +514,6 @@ 11 11 questdb - --add-exports - java.base/jdk.internal.math=io.questdb.client - - --add-exports - java.base/jdk.internal.math=io.questdb.client nothing-to-exclude-dummy-value-include-all-java11plus nothing-to-exclude-dummy-value-include-all-java11plus ${javac.target} @@ -559,13 +542,6 @@ 8 [1.8,11) questdb - - -Xlint:none - -Xlint:none - - -quiet - -quiet @@ -621,8 +597,6 @@ the jar is broken on Java 9+ again (JarPackagingIT cross-checks the packaged jar against the source root) --> - - diff --git a/core/src/main/java11/io/questdb/client/std/Compat.java b/core/src/main/java11/io/questdb/client/std/Compat.java index d0c6b1a66..86635036e 100644 --- a/core/src/main/java11/io/questdb/client/std/Compat.java +++ b/core/src/main/java11/io/questdb/client/std/Compat.java @@ -24,8 +24,6 @@ package io.questdb.client.std; -import java.lang.reflect.Method; - /** * JDK-version-specific helpers. This is the Java 9+ variant; the parallel copy * under {@code src/main/java8} provides Java 8 implementations of the same API. @@ -54,21 +52,4 @@ public static long currentPid() { public static void onSpinWait() { Thread.onSpinWait(); } - - /** - * Opens {@code java.base/jdk.internal.math} to this module so that - * {@code FDBigInteger} is reachable at runtime, mirroring the - * {@code --add-exports} flag used at compile time. - */ - static void exportFdBigInteger() { - try { - Module base = System.class.getModule(); - Module current = Compat.class.getModule(); - Method implAddExports = Module.class.getDeclaredMethod("implAddExports", String.class, Module.class); - Unsafe.makeAccessible(implAddExports); - implAddExports.invoke(base, "jdk.internal.math", current); - } catch (ReflectiveOperationException e) { - e.printStackTrace(System.out); - } - } } diff --git a/core/src/main/java11/io/questdb/client/std/FdBig.java b/core/src/main/java11/io/questdb/client/std/FdBig.java index 0f019ab28..bbf7b6a95 100644 --- a/core/src/main/java11/io/questdb/client/std/FdBig.java +++ b/core/src/main/java11/io/questdb/client/std/FdBig.java @@ -24,59 +24,166 @@ package io.questdb.client.std; -import jdk.internal.math.FDBigInteger; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.reflect.Method; /** - * Thin bridge over the JDK's internal {@code FDBigInteger}, used only by the - * {@link Numbers} double-to-string slow path. The bignum class lives in - * {@code jdk.internal.math} on Java 9+; the parallel copy of this file under - * {@code src/main/java8} targets {@code sun.misc.FDBigInteger}. Keeping the - * type behind this wrapper lets {@code Numbers} carry a single, JDK-agnostic - * copy of the algorithm. + * Thin bridge over the JDK's internal {@code jdk.internal.math.FDBigInteger}, + * used only by the {@link Numbers} double-to-string slow path. This is the + * Java 9+ variant; the parallel copy under {@code src/main/java8} targets + * {@code sun.misc.FDBigInteger} directly. Keeping the type behind this wrapper + * lets {@code Numbers} carry a single, JDK-agnostic copy of the algorithm. + *

+ * The bignum class is never named in source. Up to JDK 25 it was a + * {@code public} class in a non-exported package, so a compile-time + * {@code --add-exports} plus a reflective runtime export was enough to reach + * it. JDK 26 (JDK-8366017) made the class itself package-private, and no + * module export can make a non-public type accessible. Instead, every method + * is resolved once into a {@code static final} {@link MethodHandle}: + *

    + *
  1. {@link Class#forName(String)} loads the class; loading performs no + * accessibility check.
  2. + *
  3. {@link Class#getDeclaredMethod} finds the member; lookup performs no + * accessibility check either.
  4. + *
  5. {@link Unsafe#makeAccessible} flips {@code AccessibleObject.override} + * directly, bypassing {@code setAccessible}'s module check.
  6. + *
  7. {@link MethodHandles.Lookup#unreflect} sees the override flag and + * resolves through the JDK's trusted {@code IMPL_LOOKUP}, which skips access + * checks entirely and yields a plain direct method handle.
  8. + *
+ * The handles are {@code static final} and invoked via {@code invokeExact} + * with erased ({@code Object}) signatures, so the JIT treats them as + * constants and inlines the calls: the slow path costs the same as a direct + * call did before. The technique works unchanged on every JDK from 9 up. */ final class FdBig { - static { - // Mirror the compile-time --add-exports: grant this module runtime - // access to jdk.internal.math before any FDBigInteger reference is - // resolved. No-op on Java 8 (sun.misc is open). - Compat.exportFdBigInteger(); - } + private static final MethodHandle ADD_AND_CMP; // (Object, Object, Object) int + private static final MethodHandle CMP; // (Object, Object) int + private static final MethodHandle GET_NORMALIZATION_BIAS; // (Object) int + private static final MethodHandle LEFT_SHIFT; // (Object, int) Object + private static final MethodHandle MULT_BY_10; // (Object) Object + private static final MethodHandle QUO_REM_ITERATION; // (Object, Object) int + private static final MethodHandle VALUE_OF_MUL_POW52; // (long, int, int) Object + private static final MethodHandle VALUE_OF_POW52; // (int, int) Object - private final FDBigInteger value; + private final Object value; - private FdBig(FDBigInteger value) { + private FdBig(Object value) { this.value = value; } static FdBig valueOfMulPow52(long value, int p5, int p2) { - return new FdBig(FDBigInteger.valueOfMulPow52(value, p5, p2)); + try { + return new FdBig((Object) VALUE_OF_MUL_POW52.invokeExact(value, p5, p2)); + } catch (Throwable t) { + throw rethrow(t); + } } static FdBig valueOfPow52(int p5, int p2) { - return new FdBig(FDBigInteger.valueOfPow52(p5, p2)); + try { + return new FdBig((Object) VALUE_OF_POW52.invokeExact(p5, p2)); + } catch (Throwable t) { + throw rethrow(t); + } } int addAndCmp(FdBig x, FdBig y) { - return value.addAndCmp(x.value, y.value); + try { + return (int) ADD_AND_CMP.invokeExact(value, x.value, y.value); + } catch (Throwable t) { + throw rethrow(t); + } } int cmp(FdBig other) { - return value.cmp(other.value); + try { + return (int) CMP.invokeExact(value, other.value); + } catch (Throwable t) { + throw rethrow(t); + } } int getNormalizationBias() { - return value.getNormalizationBias(); + try { + return (int) GET_NORMALIZATION_BIAS.invokeExact(value); + } catch (Throwable t) { + throw rethrow(t); + } } FdBig leftShift(int shift) { - return new FdBig(value.leftShift(shift)); + try { + return new FdBig((Object) LEFT_SHIFT.invokeExact(value, shift)); + } catch (Throwable t) { + throw rethrow(t); + } } FdBig multBy10() { - return new FdBig(value.multBy10()); + try { + return new FdBig((Object) MULT_BY_10.invokeExact(value)); + } catch (Throwable t) { + throw rethrow(t); + } } int quoRemIteration(FdBig s) { - return value.quoRemIteration(s.value); + try { + return (int) QUO_REM_ITERATION.invokeExact(value, s.value); + } catch (Throwable t) { + throw rethrow(t); + } + } + + /** + * Resolves {@code owner.name(params)} into a direct method handle whose + * type is {@code erased} (the bignum type replaced by {@code Object}), + * without the caller needing access to {@code owner}. + */ + private static MethodHandle handle( + MethodHandles.Lookup lookup, + Class owner, + String name, + MethodType erased, + Class... params + ) throws ReflectiveOperationException { + Method m = owner.getDeclaredMethod(name, params); + // Sets AccessibleObject.override without setAccessible()'s module + // check. Lookup.unreflect() then treats the member as pre-authorised + // and resolves it via IMPL_LOOKUP, i.e. with no access check at all. + Unsafe.makeAccessible(m); + return lookup.unreflect(m).asType(erased); + } + + private static RuntimeException rethrow(Throwable t) { + if (t instanceof RuntimeException) { + return (RuntimeException) t; + } + if (t instanceof Error) { + throw (Error) t; + } + return new IllegalStateException(t); + } + + static { + try { + final Class big = Class.forName("jdk.internal.math.FDBigInteger"); + final MethodHandles.Lookup lookup = MethodHandles.lookup(); + final Class obj = Object.class; + final Class i32 = int.class; + VALUE_OF_MUL_POW52 = handle(lookup, big, "valueOfMulPow52", MethodType.methodType(obj, long.class, i32, i32), long.class, i32, i32); + VALUE_OF_POW52 = handle(lookup, big, "valueOfPow52", MethodType.methodType(obj, i32, i32), i32, i32); + ADD_AND_CMP = handle(lookup, big, "addAndCmp", MethodType.methodType(i32, obj, obj, obj), big, big); + CMP = handle(lookup, big, "cmp", MethodType.methodType(i32, obj, obj), big); + GET_NORMALIZATION_BIAS = handle(lookup, big, "getNormalizationBias", MethodType.methodType(i32, obj)); + LEFT_SHIFT = handle(lookup, big, "leftShift", MethodType.methodType(obj, obj, i32), i32); + MULT_BY_10 = handle(lookup, big, "multBy10", MethodType.methodType(obj, obj)); + QUO_REM_ITERATION = handle(lookup, big, "quoRemIteration", MethodType.methodType(i32, obj, obj), big); + } catch (ReflectiveOperationException e) { + throw new ExceptionInInitializerError(e); + } } } diff --git a/core/src/main/java8/io/questdb/client/std/Compat.java b/core/src/main/java8/io/questdb/client/std/Compat.java index 3d9073e79..843b4aa8a 100644 --- a/core/src/main/java8/io/questdb/client/std/Compat.java +++ b/core/src/main/java8/io/questdb/client/std/Compat.java @@ -61,11 +61,4 @@ public static long currentPid() { */ public static void onSpinWait() { } - - /** - * No-op on Java 8: {@code sun.misc} is open, so {@code FDBigInteger} needs - * no module export. Present for symmetry with the Java 9+ variant. - */ - static void exportFdBigInteger() { - } } diff --git a/core/src/main/java8/io/questdb/client/std/FdBig.java b/core/src/main/java8/io/questdb/client/std/FdBig.java index 7ca6600ef..a3c2e0822 100644 --- a/core/src/main/java8/io/questdb/client/std/FdBig.java +++ b/core/src/main/java8/io/questdb/client/std/FdBig.java @@ -35,12 +35,6 @@ * single, JDK-agnostic copy of the algorithm. */ final class FdBig { - static { - // No-op on Java 8: sun.misc is open, no module export is needed. Kept - // for symmetry with the Java 9+ variant. - Compat.exportFdBigInteger(); - } - private final FDBigInteger value; private FdBig(FDBigInteger value) { diff --git a/core/src/test/java/io/questdb/client/test/std/DoubleFormatBenchmark.java b/core/src/test/java/io/questdb/client/test/std/DoubleFormatBenchmark.java new file mode 100644 index 000000000..9076319d3 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/std/DoubleFormatBenchmark.java @@ -0,0 +1,164 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.Numbers; +import io.questdb.client.std.str.StringSink; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.runner.Runner; +import org.openjdk.jmh.runner.RunnerException; +import org.openjdk.jmh.runner.options.Options; +import org.openjdk.jmh.runner.options.OptionsBuilder; + +import java.util.Random; +import java.util.concurrent.TimeUnit; + +/** + * JMH benchmark for {@link Numbers#append(io.questdb.client.std.str.CharSink, double)}, + * the double-to-text routine behind every double column the client sends. + *

+ * Its bignum slow path goes through the {@code FdBig} bridge over the JDK's + * internal {@code FDBigInteger}. On Java 9+ that bridge binds the bignum + * methods via {@code static final} method handles resolved once at class-init + * (JDK 26 made the class package-private, so it can no longer be named in + * source). This benchmark exists to show the handle-based bridge costs the + * same as the direct calls it replaced: run it once against a client jar + * built from the previous {@code main} and once against the current tree, + * same JDK, and compare {@code shape=extreme} (100% slow path). + *

+ * Input shapes, each a fixed pool of 1024 values cycled per invocation. The + * slow-path share was measured by running the pool against a pre-fix jar on + * JDK 26, where the bignum path throws and the fast path does not: + *

+ * {@code jdkToString} is the {@code StringBuilder.append(double)} reference + * for the same inputs. + *

+ * Run from the packaged tests jar (the JMH annotation processor writes the + * benchmark list into it): + *

+ * java -cp questdb-client-tests.jar:questdb-client.jar:jmh-core.jar:jopt-simple.jar:commons-math3.jar \
+ *      org.openjdk.jmh.Main DoubleFormatBenchmark
+ * 
+ */ +@State(Scope.Thread) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Warmup(iterations = 5, time = 1) +@Measurement(iterations = 5, time = 1) +@Fork(2) +public class DoubleFormatBenchmark { + private static final int POOL = 1024; + private static final int MASK = POOL - 1; + + @Param({"simple", "unit", "scaled", "wide", "extreme"}) + public String shape; + + private int index; + private final StringBuilder builder = new StringBuilder(32); + private final StringSink sink = new StringSink(32); + private double[] values; + + public static void main(String[] args) throws RunnerException { + Options opt = new OptionsBuilder() + .include(DoubleFormatBenchmark.class.getSimpleName()) + .build(); + new Runner(opt).run(); + } + + @Benchmark + public int jdkToString() { + builder.setLength(0); + builder.append(next()); + return builder.length(); + } + + @Benchmark + public int numbersAppend() { + sink.clear(); + Numbers.append(sink, next()); + return sink.length(); + } + + @Setup + public void setup() { + Random rnd = new Random(0x51ED1D5L); + values = new double[POOL]; + if ("simple".equals(shape)) { + double[] seed = {1.0, 0.1, 42.5, 123456.789, 3.25, 1000.0, 0.5, 99.99}; + for (int i = 0; i < POOL; i++) { + values[i] = seed[i % seed.length]; + } + } else if ("unit".equals(shape)) { + for (int i = 0; i < POOL; i++) { + values[i] = rnd.nextDouble(); + } + } else if ("scaled".equals(shape)) { + for (int i = 0; i < POOL; i++) { + values[i] = rnd.nextDouble() * 1e6; + } + } else if ("wide".equals(shape)) { + for (int i = 0; i < POOL; i++) { + double d; + do { + d = Double.longBitsToDouble(rnd.nextLong()); + } while (Double.isNaN(d) || Double.isInfinite(d)); + values[i] = d; + } + } else if ("extreme".equals(shape)) { + double[] seed = {1e300, 4.9e-324, Double.MAX_VALUE, Double.MIN_NORMAL, 1e-200, 3.141592653589793e100, 2.2250738585072014E-308, 1e200}; + for (int i = 0; i < POOL; i++) { + values[i] = seed[i % seed.length]; + } + } else { + throw new IllegalArgumentException("unknown shape: " + shape); + } + index = 0; + } + + private double next() { + return values[index++ & MASK]; + } +} 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 index 549768a71..ca0a689dc 100644 --- a/core/src/test/java/io/questdb/client/test/std/JarPackagingIT.java +++ b/core/src/test/java/io/questdb/client/test/std/JarPackagingIT.java @@ -96,9 +96,12 @@ public void testJarLayout() throws Exception { "root FdBig of a JDK 8 build must use sun.misc.FDBigInteger", classReferences(jar, FD_BIG_ENTRY, "sun/misc/FDBigInteger") ); + // the java11 bridge never names the class (JDK 26 made it package-private); + // it loads it by dotted name and binds method handles, so the + // constant-pool witness is the dotted string literal Assert.assertTrue( - "META-INF/versions/11 FdBig must use jdk.internal.math.FDBigInteger", - classReferences(jar, VERSIONED_FD_BIG_ENTRY, "jdk/internal/math/FDBigInteger") + "META-INF/versions/11 FdBig must bind 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", @@ -117,8 +120,8 @@ public void testJarLayout() throws Exception { // 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") + "root FdBig of a JDK 11+ build must bind jdk.internal.math.FDBigInteger", + classReferences(jar, FD_BIG_ENTRY, "jdk.internal.math.FDBigInteger") ); Assert.assertNotNull("module-info.class missing", jar.getEntry("module-info.class")); } @@ -148,6 +151,7 @@ private static boolean classReferences(JarFile jar, String entryName, String con classBytes = readAll(in); } // the referenced class name appears verbatim as a constant-pool UTF-8 entry + // (slash form for a class constant, dotted form for a Class.forName literal) byte[] needle = constant.getBytes("UTF-8"); for (int i = 0; i <= classBytes.length - needle.length; i++) { int j = 0; From 960ca972e010017aa3cc12566f7b326a07ac931a Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Fri, 11 Sep 2026 18:27:09 +0200 Subject: [PATCH 2/7] ci: run the packaged jar on JDK 25, 26 and 27-ea; compile on 25 and 26 Issue #96 passed every existing check because the JDK 8-built jar was only ever executed on JDK 8, 11 and 25, and source was only compiled on 8 and 25. The MRJAR smoke job is now a matrix over the JDKs the jar must run on (25, 26, plus a non-blocking 27-ea early warning) and the compile/javadoc smoke covers 25 and 26. The check names for JDK 25 are unchanged. JarPackagingIT also accepts QUESTDB_SMOKE_JDKS (path-separated JDK homes) so a developer with several JDKs installed gets the same cross-runtime check locally from `mvn install`. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01S8vZt12JbC8o8rGyHYTPrE --- .github/workflows/ci.yml | 41 ++++++++++++++----- .../client/test/std/JarPackagingIT.java | 13 ++++++ 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9dd89f36b..e327ec2de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,20 +91,36 @@ jobs: # 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) + # Runs the JDK 8-built jar (the shipped artifact) on every newer JDK the + # client must work on. The double formatter reaches into jdk.internal.math + # via the META-INF/versions/11 bridge, and a JDK-internal change there is + # only visible when the packaged jar is EXECUTED on that JDK: issue #96 + # (JDK 26 made FDBigInteger package-private) passed every JDK 8/11/25 check + # and broke only at runtime on 26. The EA entry is an early warning for the + # next release and does not fail the workflow. + mrjar-smoke: + name: MRJAR smoke (JDK 8 jar on JDK ${{ matrix.java }}) needs: build-jdk8 runs-on: ubuntu-latest timeout-minutes: 15 + continue-on-error: ${{ matrix.experimental }} + strategy: + fail-fast: false + matrix: + java: ["25", "26"] + experimental: [false] + include: + - java: "27-ea" + experimental: true steps: - name: Check out uses: actions/checkout@v4 - - name: Set up JDK 25 + - name: Set up JDK ${{ matrix.java }} uses: actions/setup-java@v4 with: distribution: temurin - java-version: "25" + java-version: ${{ matrix.java }} - name: Download JDK 8-built client jar uses: actions/download-artifact@v4 @@ -123,23 +139,28 @@ jobs: 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 + # builds on JDK 25, and contributors build on the newest GA JDK (issue #96 + # was "does not compile on JDK 26"). Guard against compile breakage on both + # (main + test sources, both modules) and confirm the javadoc jar builds too # (-P javadoc attaches it at the package phase). Do NOT run the tests -- the # parent repo runs them against a real server. - compile-jdk25: - name: Compile & javadoc smoke (JDK 25) + compile-modern-jdk: + name: Compile & javadoc smoke (JDK ${{ matrix.java }}) runs-on: ubuntu-latest timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + java: ["25", "26"] steps: - name: Check out uses: actions/checkout@v4 - - name: Set up JDK 25 + - name: Set up JDK ${{ matrix.java }} uses: actions/setup-java@v4 with: distribution: temurin - java-version: "25" + java-version: ${{ matrix.java }} cache: maven - name: Compile (main + test) and build javadoc (no tests run) 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 index ca0a689dc..8733fa150 100644 --- a/core/src/test/java/io/questdb/client/test/std/JarPackagingIT.java +++ b/core/src/test/java/io/questdb/client/test/std/JarPackagingIT.java @@ -69,6 +69,19 @@ public void testDoubleFormattingAgainstPackagedJar() throws Exception { Assert.assertNotNull("JAVA11_HOME (or -Djava11.home) must point at a JDK 11+", bridgeJdkHome); runSmokeAgainstJar(bridgeJdkHome); } + // Optional extra runtimes for local cross-JDK checks, e.g. + // QUESTDB_SMOKE_JDKS=$HOME/.sdkman/candidates/java/17.0.14-amzn:$HOME/.sdkman/candidates/java/26.0.2-amzn + // CI covers the JDK 8 jar on newer JDKs in the mrjar-smoke matrix; this + // gives a developer with several JDKs installed the same check from + // `mvn install`. Absent or empty means no extra runs. + String extraJdks = System.getenv("QUESTDB_SMOKE_JDKS"); + if (extraJdks != null && !extraJdks.trim().isEmpty()) { + for (String jdkHome : extraJdks.split(File.pathSeparator)) { + if (!jdkHome.trim().isEmpty()) { + runSmokeAgainstJar(jdkHome.trim()); + } + } + } } private static void runSmokeAgainstJar(String jdkHome) throws Exception { From fd658560e5b2bfcefca7d06ff773492e4dfcba27 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Fri, 11 Sep 2026 18:48:49 +0200 Subject: [PATCH 3/7] docs(core): note the confirmed JDK 27-ea boundary on the FdBig bridge Reproduced on Temurin 27+35: the Unsafe write to AccessibleObject.override no longer takes effect, so isAccessible() stays false and Lookup.unreflect cannot reach the package-private FDBigInteger. This defeats both this bridge and the old --add-exports export hack (same primitive). Only a launch-time --add-opens works there, which a library cannot impose on consumers; the durable JDK 27+ fix is a self-contained bignum. The non-blocking mrjar-smoke 27-ea CI job tracks this. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01S8vZt12JbC8o8rGyHYTPrE --- .../main/java11/io/questdb/client/std/FdBig.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/core/src/main/java11/io/questdb/client/std/FdBig.java b/core/src/main/java11/io/questdb/client/std/FdBig.java index bbf7b6a95..632758427 100644 --- a/core/src/main/java11/io/questdb/client/std/FdBig.java +++ b/core/src/main/java11/io/questdb/client/std/FdBig.java @@ -56,7 +56,18 @@ * The handles are {@code static final} and invoked via {@code invokeExact} * with erased ({@code Object}) signatures, so the JIT treats them as * constants and inlines the calls: the slow path costs the same as a direct - * call did before. The technique works unchanged on every JDK from 9 up. + * call did before. The technique works unchanged on JDK 9 through 26. + *

+ * Known boundary: JDK 27-ea neutralises step 3 -- the {@code Unsafe} write to + * {@code override} no longer takes effect, so {@code isAccessible()} stays + * false and {@code unreflect} falls back to this class's own lookup, which + * cannot see the package-private class. No reflective/Unsafe variant reaches + * a JDK-internal type there (the previous {@code --add-exports} export hack + * relied on the same primitive and is equally dead); only a launch-time + * {@code --add-opens} works, which a library cannot impose on its consumers. + * The durable fix for JDK 27+ is to drop the JDK-internal dependency and carry + * a self-contained bignum. The non-blocking {@code mrjar-smoke} 27-ea CI job + * tracks this and will turn green once that lands. */ final class FdBig { private static final MethodHandle ADD_AND_CMP; // (Object, Object, Object) int From 5a66b7cd8615c1cdb54e7e38fdf942fca063ec03 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Fri, 11 Sep 2026 19:09:17 +0200 Subject: [PATCH 4/7] fix(core): derive AccessibleObject.override offset so JDK 27 compact headers work JEP 450 compact object headers are default-on in JDK 27, shrinking the object header from 12 to 8 bytes and moving AccessibleObject.override from offset 12 to 8. Unsafe hard-coded 12/16, so the override write landed inside the header and setAccessible() silently no-opped -- the FdBig double-formatting bridge then threw IllegalAccessError on JDK 27 (proven: -XX:-UseCompactObjectHeaders makes the unchanged jar pass). AccessibleObject_override_fieldOffset() now measures the first-field boundary via a one-field probe instead of hard-coding a value; override sits at that boundary in every layout, so this tracks compact (8), compressed (12), uncompressed (16) and 32-bit (8) alike. Being in the shared source it also repairs every other Unsafe.makeAccessible call site under compact headers. Verified: the JDK 8 MRJAR formats all slow-path doubles on JDK 8/11/17/25/26 and 27-ea in both header modes. The mrjar-smoke 27-ea job is now a green forward canary. Updates the FdBig note accordingly. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01S8vZt12JbC8o8rGyHYTPrE --- .github/workflows/ci.yml | 7 ++- .../java/io/questdb/client/std/Unsafe.java | 48 +++++++------------ .../java11/io/questdb/client/std/FdBig.java | 22 ++++----- 3 files changed, 32 insertions(+), 45 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e327ec2de..19be0f5ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,8 +96,11 @@ jobs: # via the META-INF/versions/11 bridge, and a JDK-internal change there is # only visible when the packaged jar is EXECUTED on that JDK: issue #96 # (JDK 26 made FDBigInteger package-private) passed every JDK 8/11/25 check - # and broke only at runtime on 26. The EA entry is an early warning for the - # next release and does not fail the workflow. + # and broke only at runtime on 26; JDK 27 compact object headers (JEP 450, + # default-on) then shifted the AccessibleObject.override offset the bridge + # relies on -- both are exercised here. The 27-ea entry is a forward canary: + # it should be green, and does not fail the workflow because EA is a moving + # target -- a red run means the next JDK needs attention. mrjar-smoke: name: MRJAR smoke (JDK 8 jar on JDK ${{ matrix.java }}) needs: build-jdk8 diff --git a/core/src/main/java/io/questdb/client/std/Unsafe.java b/core/src/main/java/io/questdb/client/std/Unsafe.java index b2f134762..1382a1d90 100644 --- a/core/src/main/java/io/questdb/client/std/Unsafe.java +++ b/core/src/main/java/io/questdb/client/std/Unsafe.java @@ -202,43 +202,27 @@ private static long AccessibleObject_override_fieldOffset() { if (isJava8Or11()) { return getFieldOffset(AccessibleObject.class, "override"); } - // From Java 12 onwards, AccessibleObject#override is protected and cannot be accessed reflectively. - boolean is32BitJVM = is32BitJVM(); - if (is32BitJVM) { - return 8L; - } - if (getOrdinaryObjectPointersCompressionStatus(is32BitJVM)) { - return 12L; - } - return 16L; + // From Java 12 onwards, AccessibleObject#override is filtered from + // reflection, so its offset cannot be read directly. It is laid out at + // the first-field boundary -- immediately after the object header -- so + // the offset of the first (and only) field of a minimal probe class is + // identical. Measure it rather than hard-coding a value: JDK 24+ compact + // object headers (JEP 450, enabled by default in JDK 27) shrink the + // header from 12 to 8 bytes, and a hard-coded 12/16 would then point + // inside the header -- the Unsafe write to `override` would silently + // miss and setAccessible() would have no effect (surfacing as + // IllegalAccessError from the FdBig double-formatting bridge on JDK 27). + // The probe tracks compact (8), compressed (12), uncompressed (16) and + // 32-bit (8) layouts automatically. + return firstFieldBoundaryOffset(); } - private static boolean getOrdinaryObjectPointersCompressionStatus(boolean is32BitJVM) { + private static long firstFieldBoundaryOffset() { class Probe { @SuppressWarnings("unused") - private int intField; // Accessed through reflection - - boolean probe() { - long offset = getFieldOffset(Probe.class, "intField"); - if (offset == 8L) { - assert is32BitJVM; - return false; - } - if (offset == 12L) { - return true; - } - if (offset == 16L) { - return false; - } - throw new AssertionError(offset); - } + int intField; // read reflectively; sits at the object's first-field boundary } - return new Probe().probe(); - } - - private static boolean is32BitJVM() { - String sunArchDataModel = System.getProperty("sun.arch.data.model"); - return sunArchDataModel.equals("32"); + return getFieldOffset(Probe.class, "intField"); } private static boolean isJava8Or11() { diff --git a/core/src/main/java11/io/questdb/client/std/FdBig.java b/core/src/main/java11/io/questdb/client/std/FdBig.java index 632758427..3d9745486 100644 --- a/core/src/main/java11/io/questdb/client/std/FdBig.java +++ b/core/src/main/java11/io/questdb/client/std/FdBig.java @@ -56,18 +56,18 @@ * The handles are {@code static final} and invoked via {@code invokeExact} * with erased ({@code Object}) signatures, so the JIT treats them as * constants and inlines the calls: the slow path costs the same as a direct - * call did before. The technique works unchanged on JDK 9 through 26. + * call did before. The technique works on JDK 9 through 27. *

- * Known boundary: JDK 27-ea neutralises step 3 -- the {@code Unsafe} write to - * {@code override} no longer takes effect, so {@code isAccessible()} stays - * false and {@code unreflect} falls back to this class's own lookup, which - * cannot see the package-private class. No reflective/Unsafe variant reaches - * a JDK-internal type there (the previous {@code --add-exports} export hack - * relied on the same primitive and is equally dead); only a launch-time - * {@code --add-opens} works, which a library cannot impose on its consumers. - * The durable fix for JDK 27+ is to drop the JDK-internal dependency and carry - * a self-contained bignum. The non-blocking {@code mrjar-smoke} 27-ea CI job - * tracks this and will turn green once that lands. + * JDK 27 needs no change here, but it exposed a latent bug in + * {@link Unsafe#makeAccessible}: compact object headers (JEP 450, enabled by + * default in JDK 27) shrink the object header to 8 bytes, so the + * {@code override} field that step 3 writes moved from offset 12/16 to 8. + * {@code Unsafe} now derives that offset by measuring the first-field boundary + * instead of hard-coding it, which tracks compact, compressed, uncompressed + * and 32-bit layouts alike. The remaining long-term liability is + * {@code sun.misc.Unsafe} itself being removed from a future JDK; the durable + * answer then is a self-contained bignum that needs no JDK-internal access at + * all. The {@code mrjar-smoke} 27-ea CI job guards the current behaviour. */ final class FdBig { private static final MethodHandle ADD_AND_CMP; // (Object, Object, Object) int From 50e327398f386b73cd31a8416384c278e65336e2 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Fri, 11 Sep 2026 20:01:29 +0200 Subject: [PATCH 5/7] ci: run the double-format smoke under each object-header layout The compact-header layout that broke the FdBig bridge on JDK 27 was only exercised by the non-blocking 27-ea leg. A regression to a hard-coded override offset would pass every blocking job yet break users who enable -XX:+UseCompactObjectHeaders on GA JDK 25/26 (a product flag, no unlock) and all JDK 27 users. The mrjar-smoke step now runs DoubleFormatSmoke under three layouts on every leg: default, +UseCompactObjectHeaders, and -UseCompressedOops -UseCompressedClassPointers (offset 16). On the blocking 25/26 legs this turns the compact-header path into a blocking guard. +IgnoreUnrecognizedVMOptions keeps the flags harmless on any JDK. Verified locally: with the fix all three layouts pass on 25/26/27-ea; with the previous hard-coded offset the compact-headers leg fails on 25 and 26. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01S8vZt12JbC8o8rGyHYTPrE --- .github/workflows/ci.yml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19be0f5ed..cbf59dc8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,7 +139,22 @@ jobs: 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 FdBig double formatter reaches jdk.internal.math.FDBigInteger by + # setting AccessibleObject.override at an offset Unsafe derives from the + # object header size. Run the smoke under each object-header layout so a + # regression to a hard-coded offset fails a BLOCKING leg (25, 26), not + # only the non-blocking 27-ea canary. Compact object headers (JEP 450) + # are a product flag on JDK 25/26 and default-on from 27; the + # +IgnoreUnrecognizedVMOptions guard keeps the flags harmless on any JDK. + run_smoke() { #