diff --git a/.github/workflows/java8-artifact.yml b/.github/workflows/java8-artifact.yml new file mode 100644 index 0000000..1f14877 --- /dev/null +++ b/.github/workflows/java8-artifact.yml @@ -0,0 +1,107 @@ +name: Java 8 artefact compatibility +on: + push: + branches: [ea, develop] + pull_request: + types: [opened, synchronize, reopened] +jobs: + package: + name: Package on Java 8 and repeat without clean + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v6 + with: + distribution: zulu + java-version: '8' + cache: maven + - name: Verify and retain the Java 8 binary + run: | + ./mvnw -B -ntp verify -l java8-verify.log + mkdir -p target/java8-artifact + python3 - <<'PY' + import shutil + import xml.etree.ElementTree as ET + + pom = ET.parse('pom.xml').getroot() + ns = {'m': 'http://maven.apache.org/POM/4.0.0'} + name = pom.findtext('m:artifactId', namespaces=ns) + version = pom.findtext('m:version', namespaces=ns) + shutil.copyfile('target/' + name + '-' + version + '.jar', + 'target/java8-artifact/library.jar') + PY + sha256sum target/java8-artifact/library.jar > target/java8-artifact/SHA256SUMS + git rev-parse HEAD > target/java8-artifact/revision.txt + java -version 2> target/java8-artifact/build-jdk.txt + - name: Repeat package without cleaning generated modules + run: ./mvnw -B -ntp -DskipTests package -l repeat-package.log + - uses: actions/upload-artifact@v7 + with: + name: java8-library + path: target/java8-artifact + if-no-files-found: error + - uses: actions/upload-artifact@v7 + if: always() + with: + name: java8-build-evidence + path: | + java8-verify.log + repeat-package.log + target/surefire-reports + target/failsafe-reports + target/invoker-reports + target/it/**/build.log + target/it/**/target/surefire-reports + consumers: + name: Java 8 binary on JDK ${{ matrix.java }} + needs: package + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + java: ['8', '9', '10', '11', '17', '21', '25'] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v6 + with: + distribution: zulu + java-version: ${{ matrix.java }} + cache: maven + - uses: actions/download-artifact@v8 + with: + name: java8-library + path: target/java8-artifact + - name: Install the retained binary without rebuilding the library + run: | + sha256sum --check target/java8-artifact/SHA256SUMS + test "$(cat target/java8-artifact/revision.txt)" = "$(git rev-parse HEAD)" + ./mvnw -B -ntp org.apache.maven.plugins:maven-install-plugin:3.1.4:install-file \ + -Dfile=target/java8-artifact/library.jar -DpomFile=pom.xml \ + -DlocalRepositoryPath=target/local-repo -DgeneratePom=false + - name: Run class-path and module-path consumers + run: | + ./mvnw -B -ntp invoker:run invoker:verify -Dinvoker.failIfNoProjects=true -l consumers.log + installed=(target/local-repo/net/openhft/zero-allocation-hashing/*/zero-allocation-hashing-*.jar) + test "${#installed[@]}" = 1 + cmp target/java8-artifact/library.jar "${installed[0]}" + - name: Check annotation and JDK dependencies + if: matrix.java == '21' + run: | + ./mvnw -B -ntp dependency:copy -Dartifact=com.google.code.findbugs:jsr305:3.0.2 \ + -DoutputDirectory=target/jdeps + jdeps --multi-release 9 -verbose:class --add-modules jsr305 \ + --module-path target/jdeps/jsr305-3.0.2.jar \ + target/java8-artifact/library.jar | tee jdeps.log + ! grep -F 'not found' jdeps.log + grep -E 'net.openhft.hashing.* -> javax.annotation.ParametersAreNonnullByDefault +jsr305$' jdeps.log + - uses: actions/upload-artifact@v7 + if: always() + with: + name: consumers-jdk-${{ matrix.java }} + path: | + consumers.log + jdeps.log + target/java8-artifact/SHA256SUMS + target/invoker-reports + target/it/**/build.log + target/it/**/target/surefire-reports diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties index 642d572..d853aaf 100644 --- a/.mvn/wrapper/maven-wrapper.properties +++ b/.mvn/wrapper/maven-wrapper.properties @@ -1,2 +1,2 @@ -distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.6.3/apache-maven-3.6.3-bin.zip +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip wrapperUrl=https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar diff --git a/pom.xml b/pom.xml index 32be904..d80ccb0 100644 --- a/pom.xml +++ b/pom.xml @@ -39,6 +39,10 @@ all,-missing 1.37 + + + + 1.2.2.Final openhft https://sonarcloud.io @@ -292,6 +296,33 @@ + + org.moditect + moditect-maven-plugin + ${moditect.version} + + + add-module-info + package + + add-module-info + + + + + 9 + + + + true + + ${project.basedir}/src/main/java9/module-info.java + + + + + + maven-failsafe-plugin 3.2.5 @@ -309,6 +340,33 @@ + + + org.apache.maven.plugins + maven-invoker-plugin + 3.9.1 + + ${project.build.directory}/it + ${project.build.directory}/local-repo + verify + false + true + + clean + test + + + + + integration-test + + install + run + verify + + + + @@ -344,13 +402,21 @@ jdk9+-profile - [9,21) + [9,17) ${project.target.release} + + maven-javadoc-plugin + + + -html5 + + + maven-surefire-plugin diff --git a/src/it/README.adoc b/src/it/README.adoc new file mode 100644 index 0000000..331f520 --- /dev/null +++ b/src/it/README.adoc @@ -0,0 +1,53 @@ += Integration Tests + +The Maven Invoker fixtures use JUnit 4: + +* `simple-usage` targets Java 8 and checks public hash functions across arrays, + buffers and character sequences. +* `module-test` targets Java 9 and checks the explicit descriptor, public API, + runtime paths and encapsulation. Invoker skips it on Java 8. + +== Build and repeat packaging + +[source,bash] +---- +./mvnw -B -ntp verify -l java8-verify.log +./mvnw -B -ntp -DskipTests package -l repeat-package.log +---- + +The wrapper pins Maven 3.9.11; ModiTect 1.2.2.Final supports Java 8 and requires +Maven 3.9.6 or later. Ordinary classes target Java 8; the multi-release JAR places +its Java 9 descriptor in `META-INF/versions/9/module-info.class`. +The second command retains generated output to exercise `overwriteExistingFiles`. +`AutomaticModuleNameIT` checks the finished JAR, not the repeated lifecycle. +With Java 8 selected, `python3 src/it/repeat-package.py ../repeat-package-proof` +runs this sequence in fresh enabled/disabled-overwrite copies, retaining logs and +requiring the disabled second package to fail specifically on existing output. + +`verify` installs the library in `target/local-repo` before running the fixtures. +A fresh checkout cannot start with `invoker:run` alone. Invoker inherits Maven +settings (including mirrors, proxies and credentials), with its own local repository. +Reports remain under `target`; tests use assertions rather than console output. + +== Retained Java 8 binary + +The `Java 8 artefact compatibility` workflow retains the JAR, SHA-256, source +revision and build-JDK identity from `verify`, then repeats `package` without +cleaning. Separate jobs install that retained binary with `install:install-file` +and run `invoker:run invoker:verify`, without rebuilding it. They compare installed +and retained JAR bytes and retain logs/reports on JDK 8/9/10/11/17/21/25. +The JDK 21 job also resolves JSR-305 with `jdeps` and rejects unresolved annotations. +These source-compiled fixtures do not establish compatibility for old binaries. + +== Runtime contract + +The explicit `net.openhft.hashing` module exports only its public hashing package, +requires `jdk.unsupported` at runtime and declares optional `requires static jsr305`. +See link:../main/docs/unsafe-and-platform-notes.adoc[Unsafe and platform notes] +for annotation resolution and the required direct-buffer exports for named and +class-path consumers; the modular fixture supplies the named-module export. + +The library's `LongHashFunctionTest` and `LongTupleHashFunctionTest` put guard bytes +around direct-buffer payloads and request a range different from the cursor window. +They assert the hash and unchanged cursor, then restore the payload window before +the whole-buffer byte-order check. Substituting position/remaining must fail them. diff --git a/src/it/module-test/invoker.properties b/src/it/module-test/invoker.properties new file mode 100644 index 0000000..9867019 --- /dev/null +++ b/src/it/module-test/invoker.properties @@ -0,0 +1,3 @@ +invoker.goals=clean test +invoker.buildResult=success +invoker.java.version=9+ diff --git a/src/it/module-test/pom.xml b/src/it/module-test/pom.xml new file mode 100644 index 0000000..4fe7479 --- /dev/null +++ b/src/it/module-test/pom.xml @@ -0,0 +1,45 @@ + + + 4.0.0 + + net.openhft.it + module-test + 1.0-SNAPSHOT + + + UTF-8 + 9 + + + + + @project.groupId@ + @project.artifactId@ + @project.version@ + + + junit + junit + 4.13.2 + test + + + + + + + maven-compiler-plugin + 3.14.0 + + + maven-surefire-plugin + 3.5.4 + + --add-exports java.base/sun.nio.ch=net.openhft.hashing + + + + + diff --git a/src/it/module-test/src/test/java/module-info.java b/src/it/module-test/src/test/java/module-info.java new file mode 100644 index 0000000..7cc33d5 --- /dev/null +++ b/src/it/module-test/src/test/java/module-info.java @@ -0,0 +1,9 @@ +/* + * Copyright 2013-2025 chronicle.software; SPDX-License-Identifier: Apache-2.0 + */ +module net.openhft.it.module { + requires net.openhft.hashing; + requires junit; + exports net.openhft.it.module; + opens net.openhft.it.module to junit; +} diff --git a/src/it/module-test/src/test/java/net/openhft/it/module/ModuleTest.java b/src/it/module-test/src/test/java/net/openhft/it/module/ModuleTest.java new file mode 100644 index 0000000..e8d6c54 --- /dev/null +++ b/src/it/module-test/src/test/java/net/openhft/it/module/ModuleTest.java @@ -0,0 +1,119 @@ +/* + * Copyright 2013-2025 chronicle.software; SPDX-License-Identifier: Apache-2.0 + */ +package net.openhft.it.module; + +import net.openhft.hashing.LongHashFunction; +import net.openhft.hashing.LongTupleHashFunction; +import org.junit.Test; + +import java.lang.module.ModuleDescriptor; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.Set; + +import static org.junit.Assert.*; + +/** + * Integration test verifying proper module encapsulation. + * This test ensures that only exported packages are accessible. + */ +public class ModuleTest { + + private static final String TEST_DATA = "Module Test Data"; + private static final byte[] TEST_BYTES = TEST_DATA.getBytes(StandardCharsets.UTF_8); + + @Test + public void testPublicAPIAccessible() { + long xxHash = LongHashFunction.xx().hashBytes(TEST_BYTES); + long cityHash = LongHashFunction.city_1_1().hashBytes(TEST_BYTES); + long murmurHash = LongHashFunction.murmur_3().hashBytes(TEST_BYTES); + + assertTrue("XxHash should produce non-zero result", xxHash != 0); + assertTrue("CityHash should produce non-zero result", cityHash != 0); + assertTrue("MurmurHash should produce non-zero result", murmurHash != 0); + } + + @Test + public void testMainAPIClassAccessible() { + try { + Class.forName("net.openhft.hashing.LongHashFunction"); + } catch (ClassNotFoundException e) { + fail("Public API net.openhft.hashing.LongHashFunction should be accessible"); + } + } + + @Test + public void testExplicitDescriptorContract() { + Module module = LongHashFunction.class.getModule(); + assertTrue("Library should be a named module", module.isNamed()); + assertEquals("net.openhft.hashing", module.getName()); + + ModuleDescriptor descriptor = module.getDescriptor(); + assertNotNull("Named module should have a descriptor", descriptor); + assertFalse("Library should not resolve as an automatic module", descriptor.isAutomatic()); + + Set exports = descriptor.exports(); + assertEquals("Only the public API package should be exported", 1, exports.size()); + ModuleDescriptor.Exports publicApi = exports.iterator().next(); + assertEquals("net.openhft.hashing", publicApi.source()); + assertFalse("Public API export should be unqualified", publicApi.isQualified()); + + ModuleDescriptor.Requires unsupported = descriptor.requires().stream() + .filter(requirement -> requirement.name().equals("jdk.unsupported")) + .findFirst() + .orElseThrow(() -> new AssertionError("Descriptor must require jdk.unsupported")); + assertFalse("jdk.unsupported must be available at runtime", + unsupported.modifiers().contains(ModuleDescriptor.Requires.Modifier.STATIC)); + + ModuleDescriptor.Requires annotations = descriptor.requires().stream() + .filter(requirement -> requirement.name().equals("jsr305")) + .findFirst() + .orElseThrow(() -> new AssertionError( + "Descriptor must declare jsr305 annotations")); + assertTrue("Annotations should remain optional at runtime", + annotations.modifiers().contains(ModuleDescriptor.Requires.Modifier.STATIC)); + } + + @Test + public void testPublicRuntimePathsWithRequiredDirectBufferExport() { + LongHashFunction[] functions = { + LongHashFunction.city_1_1(), + LongHashFunction.farmNa(), + LongHashFunction.farmUo(), + LongHashFunction.murmur_3(), + LongHashFunction.xx(), + LongHashFunction.xx3(), + LongHashFunction.xx128low(), + LongHashFunction.wy_3(), + LongHashFunction.metro() + }; + + for (LongHashFunction function : functions) { + long expected = function.hashBytes(TEST_BYTES); + assertEquals(expected, function.hashBytes(ByteBuffer.wrap(TEST_BYTES))); + + ByteBuffer direct = ByteBuffer.allocateDirect(TEST_BYTES.length); + direct.put(TEST_BYTES).flip(); + assertEquals(expected, function.hashBytes(direct)); + assertEquals("Direct buffer position should be unchanged", 0, direct.position()); + assertEquals("Direct buffer limit should be unchanged", TEST_BYTES.length, direct.limit()); + + assertEquals(function.hashChars(TEST_DATA), + function.hashChars(new StringBuilder(TEST_DATA))); + int[] integers = {1, 2, 3, 4}; + ByteBuffer integerBytes = ByteBuffer.allocate(integers.length * Integer.BYTES) + .order(ByteOrder.nativeOrder()); + for (int value : integers) + integerBytes.putInt(value); + // Primitive-array hashing uses the bytes as laid out in native memory. + assertEquals(function.hashBytes(integerBytes.array()), function.hashInts(integers)); + } + + LongTupleHashFunction tuple = LongTupleHashFunction.xx128(); + ByteBuffer direct = ByteBuffer.allocateDirect(TEST_BYTES.length); + direct.put(TEST_BYTES).flip(); + assertArrayEquals(tuple.hashBytes(TEST_BYTES), tuple.hashBytes(direct)); + } +} diff --git a/src/it/repeat-package.py b/src/it/repeat-package.py new file mode 100644 index 0000000..0e38d9e --- /dev/null +++ b/src/it/repeat-package.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# Copyright 2013-2026 chronicle.software; SPDX-License-Identifier: Apache-2.0 +"""Run with Java 8 selected: python3 src/it/repeat-package.py NEW_WORK_DIRECTORY.""" +import json +from pathlib import Path +import shutil +import subprocess +import sys +import unittest +import xml.etree.ElementTree as ET +import zipfile + +SOURCE = Path(__file__).resolve().parents[2] + + +class RepeatPackageTest(unittest.TestCase): + def testPackageWithoutClean(self): + version = subprocess.check_output(["./mvnw", "-version"], cwd=SOURCE, text=True) + (WORK / "toolchain.txt").write_text(version) + self.assertIn("Java version: 1.8.", version) + for disabled in (False, True): + case = WORK / ("overwrite-disabled" if disabled else "overwrite-enabled") + shutil.copytree(SOURCE, case, ignore=shutil.ignore_patterns(".git", "target", "*.log")) + if disabled: + pom = case / "pom.xml" + data = pom.read_bytes() + setting = b"true" + self.assertEqual(data.count(setting), 1) + pom.write_bytes(data.replace(setting, setting.replace(b"true", b"false"))) + records = [] + for repeat in (False, True): + log = "repeat-package.log" if repeat else "java8-verify.log" + cmd = ["./mvnw", "-B", "-ntp"] + (["-DskipTests", "package"] if repeat else ["verify"]) + ["-l", log] + with (case / (log + ".console")).open("wb") as output: + run = subprocess.run(cmd, cwd=case, stdout=output, stderr=subprocess.STDOUT) + records.append({"command": cmd, "exit_code": run.returncode}) + (case / "commands.json").write_text(json.dumps(records, indent=2) + "\n") + text = (case / log).read_text() + if disabled and repeat: + self.assertNotEqual(run.returncode, 0, "Overwrite-disabled repeat unexpectedly passed") + self.assertIn("moditect-maven-plugin:1.2.2.Final:add-module-info", text) + self.assertRegex(text, r"(?i)(already exists|FileAlreadyExistsException)") + else: + self.assertEqual(run.returncode, 0, "Build/setup failed; see " + str(case / log)) + self.assertIn("BUILD SUCCESS", text) + if not repeat: + report = ET.parse(case / "target/failsafe-reports/TEST-net.openhft.hashing.AutomaticModuleNameIT.xml").getroot() + self.assertEqual([int(report.get(k, "0")) for k in ("tests", "failures", "errors", "skipped")], [3, 0, 0, 0]) + project = ET.parse(case / "pom.xml").getroot() + ns = {"m": "http://maven.apache.org/POM/4.0.0"} + artifact = project.findtext("m:artifactId", namespaces=ns) + artifact_version = project.findtext("m:version", namespaces=ns) + jar = case / "target" / f"{artifact}-{artifact_version}.jar" + shutil.copyfile(jar, case / "retained-verify.jar") + elif not disabled: + def classes(path): + with zipfile.ZipFile(path) as archive: + return {n: archive.read(n) for n in archive.namelist() if n.endswith(".class")} + self.assertEqual(classes(case / "retained-verify.jar"), classes(jar)) + + +if __name__ == "__main__": + if len(sys.argv) != 2: + sys.exit(__doc__) + WORK = Path(sys.argv.pop(1)).resolve() + if WORK == SOURCE or SOURCE in WORK.parents: + sys.exit("Work directory must be outside the source tree") + WORK.mkdir() + unittest.main(verbosity=2) diff --git a/src/it/simple-usage/invoker.properties b/src/it/simple-usage/invoker.properties new file mode 100644 index 0000000..ac71e23 --- /dev/null +++ b/src/it/simple-usage/invoker.properties @@ -0,0 +1,2 @@ +invoker.goals=clean test +invoker.buildResult=success diff --git a/src/it/simple-usage/pom.xml b/src/it/simple-usage/pom.xml new file mode 100644 index 0000000..7584801 --- /dev/null +++ b/src/it/simple-usage/pom.xml @@ -0,0 +1,42 @@ + + + 4.0.0 + + net.openhft.it + simple-usage + 1.0-SNAPSHOT + + + UTF-8 + 8 + + + + + @project.groupId@ + @project.artifactId@ + @project.version@ + + + junit + junit + 4.13.2 + test + + + + + + + maven-compiler-plugin + 3.14.0 + + + maven-surefire-plugin + 3.5.4 + + + + diff --git a/src/it/simple-usage/src/test/java/net/openhft/it/HashingTest.java b/src/it/simple-usage/src/test/java/net/openhft/it/HashingTest.java new file mode 100644 index 0000000..d2f1f50 --- /dev/null +++ b/src/it/simple-usage/src/test/java/net/openhft/it/HashingTest.java @@ -0,0 +1,90 @@ +/* + * Copyright 2013-2025 chronicle.software; SPDX-License-Identifier: Apache-2.0 + */ +package net.openhft.it; + +import net.openhft.hashing.LongHashFunction; +import org.junit.Test; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; + +import static org.junit.Assert.*; + +/** + * Integration test demonstrating usage of Zero-Allocation-Hashing library. + */ +public class HashingTest { + + private static final String TEST_STRING = "Hello, World!"; + private static final byte[] TEST_BYTES = TEST_STRING.getBytes(StandardCharsets.UTF_8); + + @Test + public void testXxHash() { + LongHashFunction xxHash = LongHashFunction.xx(); + ByteBuffer buffer = ByteBuffer.wrap(TEST_BYTES); + + long hashFromBytes = xxHash.hashBytes(TEST_BYTES); + long hashFromBuffer = xxHash.hashBytes(buffer); + buffer.rewind(); + long hashFromChars = xxHash.hashChars(TEST_STRING); + + assertEquals("XxHash: byte[] and ByteBuffer hashes should match", hashFromBytes, hashFromBuffer); + assertTrue("XxHash from bytes should be non-zero", hashFromBytes != 0); + assertTrue("XxHash from chars should be non-zero", hashFromChars != 0); + } + + @Test + public void testCityHash() { + LongHashFunction cityHash = LongHashFunction.city_1_1(); + ByteBuffer buffer = ByteBuffer.wrap(TEST_BYTES); + + long hashFromBytes = cityHash.hashBytes(TEST_BYTES); + long hashFromBuffer = cityHash.hashBytes(buffer); + buffer.rewind(); + long hashFromChars = cityHash.hashChars(TEST_STRING); + + assertEquals("CityHash: byte[] and ByteBuffer hashes should match", hashFromBytes, hashFromBuffer); + assertTrue("CityHash from bytes should be non-zero", hashFromBytes != 0); + assertTrue("CityHash from chars should be non-zero", hashFromChars != 0); + } + + @Test + public void testMurmurHash() { + LongHashFunction murmur = LongHashFunction.murmur_3(); + ByteBuffer buffer = ByteBuffer.wrap(TEST_BYTES); + + long hashFromBytes = murmur.hashBytes(TEST_BYTES); + long hashFromBuffer = murmur.hashBytes(buffer); + buffer.rewind(); + long hashFromChars = murmur.hashChars(TEST_STRING); + + assertEquals("MurmurHash3: byte[] and ByteBuffer hashes should match", hashFromBytes, hashFromBuffer); + assertTrue("MurmurHash3 from bytes should be non-zero", hashFromBytes != 0); + assertTrue("MurmurHash3 from chars should be non-zero", hashFromChars != 0); + } + + @Test + public void testFarmHash() { + LongHashFunction farmHash = LongHashFunction.farmUo(); + ByteBuffer buffer = ByteBuffer.wrap(TEST_BYTES); + + long hashFromBytes = farmHash.hashBytes(TEST_BYTES); + long hashFromBuffer = farmHash.hashBytes(buffer); + buffer.rewind(); + long hashFromChars = farmHash.hashChars(TEST_STRING); + + assertEquals("FarmHash: byte[] and ByteBuffer hashes should match", hashFromBytes, hashFromBuffer); + assertTrue("FarmHash from bytes should be non-zero", hashFromBytes != 0); + assertTrue("FarmHash from chars should be non-zero", hashFromChars != 0); + } + + @Test + public void testPublicAPIAccessible() { + try { + Class.forName("net.openhft.hashing.LongHashFunction"); + } catch (ClassNotFoundException e) { + fail("Public API net.openhft.hashing.LongHashFunction should be accessible"); + } + } +} diff --git a/src/main/docs/architecture-overview.adoc b/src/main/docs/architecture-overview.adoc index 44dce67..e95af2d 100644 --- a/src/main/docs/architecture-overview.adoc +++ b/src/main/docs/architecture-overview.adoc @@ -36,7 +36,8 @@ It currently delivers 128-bit MurmurHash3 and XXH3 outputs and mirrors the singl * `net.openhft.hashing.Util.VALID_STRING_HASH` selects the correct `StringHash` strategy at JVM initialisation time by inspecting `java.vm.name` and `java.version`, covering HotSpot, OpenJ9, Zing, and unknown VMs (`Util.java:29-63`). * `ModernHotSpotStringHash`, `ModernCompactStringHash`, and `HotSpotPrior7u6StringHash` encode the memory layout differences between pre-compact, compact-string, and legacy HotSpot builds. When the VM cannot be recognised, `UnknownJvmStringHash` provides a defensive fallback. -* Direct buffer hashing uses `sun.nio.ch.DirectBuffer` addresses pulled via `LongHashFunction.hashBytes(ByteBuffer)` and `LongHashFunction.hashMemory(long, long)`; `Util.getDirectBufferAddress` centralises the address extraction (`Util.java:65-68`). +* Direct buffer hashing calls `sun.nio.ch.DirectBuffer.address()` and passes the native address to the allocation-free `Unsafe` path (`LongHashFunction.hashByteBuffer`, `LongTupleHashFunction.hashByteBuffer`, and `Util.getDirectBufferAddress`). +See link:unsafe-and-platform-notes.adoc[Unsafe and platform notes] for the direct-call rationale and required JVM exports. === Supporting Utilities diff --git a/src/main/docs/project-requirements.adoc b/src/main/docs/project-requirements.adoc index d6d0aa7..89b2044 100644 --- a/src/main/docs/project-requirements.adoc +++ b/src/main/docs/project-requirements.adoc @@ -55,7 +55,7 @@ The library must implement the following non-cryptographic hash algorithms: === 3.3. Compatibility (NF-O) * **NF-O-001:** **JDK Version:** The library must support JDK 8 through the current LTS (JDK 21+). -* **NF-O-002:** **Module System:** The library must function in Java 9+ environments. Tests and runtime require `--add-opens java.base/jdk.internal.misc=ALL-UNNAMED --add-opens java.base/sun.nio.ch=ALL-UNNAMED` for `sun.misc.Unsafe` and `sun.nio.ch.DirectBuffer` access (see `unsafe-and-platform-notes.adoc`). +* **NF-O-002:** **Module System:** The library must function as explicit module `net.openhft.hashing` on Java 9+, with runtime `jdk.unsupported` and application-supplied direct-buffer exports as specified in link:unsafe-and-platform-notes.adoc[Unsafe and platform notes]. * **NF-O-003:** **VM Support:** The library must support major JVMs including HotSpot, OpenJDK, and OpenJ9/IBM J9. == 4. Interface Constraints diff --git a/src/main/docs/unsafe-and-platform-notes.adoc b/src/main/docs/unsafe-and-platform-notes.adoc index 492a6eb..a6ff7a8 100644 --- a/src/main/docs/unsafe-and-platform-notes.adoc +++ b/src/main/docs/unsafe-and-platform-notes.adoc @@ -9,20 +9,29 @@ Chronicle Software * `UnsafeAccess` reflects on `sun.misc.Unsafe.theUnsafe` to obtain the singleton and uses it for raw array access, field offsets, and direct memory loads (`UnsafeAccess.java:40-118`). This keeps hashing allocation-free but depends on the `jdk.unsupported` module. -* Direct buffer handling casts to `sun.nio.ch.DirectBuffer` to retrieve native addresses for `hashBytes(ByteBuffer)` and `hashMemory` (`Util.java:65-68`, `LongHashFunction.java:430-470`). -The code path assumes the buffer is direct; heap buffers are copied through array access instead. +* Direct buffer handling casts to `sun.nio.ch.DirectBuffer` and calls `address()` directly before hashing the native memory. +This allocation-free fast path has no reflective fallback when access is unavailable. * String hashing inspects `java.lang.String.value` and, on compact-string VMs, treats the backing `byte[]` as Latin-1 using `CompactLatin1CharSequenceAccess` (`ModernCompactStringHash.java:10-62`). Older HotSpot builds and other JVMs fall back to UTF-16 behaviour through `ModernHotSpotStringHash` or `HotSpotPrior7u6StringHash`. === JVM Configuration -* On Java 9 and newer, strong encapsulation may block reflective access to the JDK internals above. -Add the following opens/exports when running with a strict module layer: -** `--add-opens java.base/sun.misc=ALL-UNNAMED` (access to `Unsafe.theUnsafe`). -** `--add-exports java.base/sun.nio.ch=ALL-UNNAMED` (casts to `DirectBuffer`). -** `--add-opens java.base/java.lang=ALL-UNNAMED` (reading `String.value` for compact strings). -* The library does not automatically request these permissions. -Applications embedding Zero-Allocation Hashing must configure the JVM command line or module descriptors accordingly. +* The explicit module descriptor declares `requires jdk.unsupported` for `sun.misc.Unsafe`. +* `requires static jsr305` declares the optional nullability-annotation dependency. +Applications inspecting those annotations must supply and resolve `jsr305`; ordinary hashing does not require it at runtime. +* On Java 9 and newer, direct-buffer hashing also requires one of the following runtime options: +** Named-module use: `--add-exports java.base/sun.nio.ch=net.openhft.hashing` +** Class-path use: `--add-exports java.base/sun.nio.ch=ALL-UNNAMED` +The launcher must supply the export: a descriptor cannot request another module's exports. +* Java 24 and 25 emit terminal-deprecation warnings for some `sun.misc.Unsafe` memory-access methods. +The Java 25 default is `--sun-misc-unsafe-memory-access=warn`, which permits execution and emits at most one warning per JVM. +The first warning from this library normally identifies `UnsafeAccess.arrayBaseOffset`, but suppressing or replacing that one call would not resolve the underlying dependency: hashing also uses `getByte`, `getShort`, `getInt`, and `getLong` throughout its hot paths. +* Applications that have explicitly accepted this temporary compatibility risk can suppress the warning on Java 24 and 25 with `--sun-misc-unsafe-memory-access=allow`. +This is an application-wide launcher policy, not a library setting, and does not mitigate the future-removal risk. +Use `debug` instead when locating every call; `deny` makes the current implementation fail during `UnsafeAccess` initialisation. +* Removing the warning properly requires replacing all affected on-heap and off-heap memory operations with supported APIs, principally `VarHandle` and the Foreign Function and Memory API. +That work requires version-specific implementations and performance validation, and is separate from JPMS packaging. +See https://openjdk.org/jeps/498[JEP 498] and https://openjdk.org/jeps/471[JEP 471]. === Platform Behaviour diff --git a/src/main/java9/module-info.java b/src/main/java9/module-info.java new file mode 100644 index 0000000..033ad60 --- /dev/null +++ b/src/main/java9/module-info.java @@ -0,0 +1,16 @@ +/* + * Copyright 2013-2025 chronicle.software; SPDX-License-Identifier: Apache-2.0 + */ +//! Publish an explicit module with only the public hashing package exported. +//! ModuleTest#testExplicitDescriptorContract rejects an automatic module or extra exports; +//! #testPublicAPIAccessible checks consumers can still call the API. +module net.openhft.hashing { + //! Unsafe remains a runtime dependency of existing hashing paths, not an optional annotation. + //! ModuleTest#testExplicitDescriptorContract requires a non-static jdk.unsupported edge; + //! #testPublicRuntimePathsWithRequiredDirectBufferExport exercises those paths. + requires jdk.unsupported; + //! Declare compile-time annotations without forcing ordinary runtime consumers to install them. + //! ModuleTest#testExplicitDescriptorContract requires the STATIC jsr305 dependency. + requires static jsr305; + exports net.openhft.hashing; +} diff --git a/src/test/java/net/openhft/hashing/AutomaticModuleNameIT.java b/src/test/java/net/openhft/hashing/AutomaticModuleNameIT.java index 1de4c03..ef30637 100644 --- a/src/test/java/net/openhft/hashing/AutomaticModuleNameIT.java +++ b/src/test/java/net/openhft/hashing/AutomaticModuleNameIT.java @@ -5,14 +5,17 @@ import org.junit.Test; +import java.io.DataInputStream; import java.io.File; import java.io.IOException; import java.util.jar.Attributes; +import java.util.jar.JarEntry; import java.util.jar.JarFile; import java.util.jar.Manifest; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; public class AutomaticModuleNameIT { @@ -32,6 +35,38 @@ public void packagedJarDeclaresAutomaticModuleName() throws IOException { assertNotNull("Packaged JAR has no manifest: " + packagedJar, manifest); final Attributes attributes = manifest.getMainAttributes(); assertEquals(EXPECTED_MODULE_NAME, attributes.getValue("Automatic-Module-Name")); + assertEquals("Versioned descriptor must be enabled", "true", + attributes.getValue("Multi-Release")); + } + } + + @Test + public void java8BuildProducesJava9ModuleDescriptor() throws IOException { + final String packagedJarPath = System.getProperty("packaged.jar"); + assertNotNull("packaged.jar system property", packagedJarPath); + + final File packagedJar = new File(packagedJarPath); + try (JarFile jarFile = new JarFile(packagedJar)) { + final JarEntry descriptor = jarFile.getJarEntry("META-INF/versions/9/module-info.class"); + assertNotNull("Packaged JAR has no Java 9 module descriptor", descriptor); + + try (DataInputStream input = new DataInputStream(jarFile.getInputStream(descriptor))) { + assertEquals("Invalid class-file magic", 0xCAFEBABE, input.readInt()); + input.readUnsignedShort(); + assertEquals("Descriptor should target Java 9 class files", 53, input.readUnsignedShort()); + } + } + } + + @Test + public void packagedJarDoesNotContainJdkCompilationStub() throws IOException { + final String packagedJarPath = System.getProperty("packaged.jar"); + assertNotNull("packaged.jar system property", packagedJarPath); + + try (JarFile jarFile = new JarFile(new File(packagedJarPath))) { + assertNull("The JDK supplies sun.nio.ch.DirectBuffer at runtime; the compile-time stub " + + "must not be packaged", + jarFile.getJarEntry("sun/nio/ch/DirectBuffer.class")); } } } diff --git a/src/test/java/net/openhft/hashing/LongHashFunctionTest.java b/src/test/java/net/openhft/hashing/LongHashFunctionTest.java index fa4380a..dc3277f 100644 --- a/src/test/java/net/openhft/hashing/LongHashFunctionTest.java +++ b/src/test/java/net/openhft/hashing/LongHashFunctionTest.java @@ -152,11 +152,34 @@ private static void testByteBuffers(LongHashFunction f, long eh, int len, ByteBu bb2.put(bb); assertEquals("byte buffer little endian off len", eh, f.hashBytes(bb2, 1, len)); + ByteBuffer direct = ByteBuffer.allocateDirect(len + 2).order(LITTLE_ENDIAN); + direct.put(0, (byte) 0x6D); + direct.put(len + 1, (byte) 0xB7); + ((Buffer)direct).position(1); + ByteBuffer directSource = bb.duplicate(); + ((Buffer)directSource).clear(); + direct.put(directSource); + ((Buffer)direct).position(1); + ((Buffer)direct).limit(len + 1); + assertEquals("direct byte buffer", eh, f.hashBytes(direct)); + assertEquals("direct byte buffer position unchanged", 1, direct.position()); + assertEquals("direct byte buffer limit unchanged", len + 1, direct.limit()); + + // The explicit range excludes guards that are inside the active window. + ((Buffer)direct).clear(); + assertEquals("direct byte buffer off len", eh, f.hashBytes(direct, 1, len)); + assertEquals("direct range position unchanged", 0, direct.position()); + assertEquals("direct range limit unchanged", len + 2, direct.limit()); + ((Buffer)direct).position(1); + ((Buffer)direct).limit(len + 1); + ((Buffer)bb.order(BIG_ENDIAN)).clear(); assertEquals("byte buffer big endian", eh, f.hashBytes(bb)); bb2.order(BIG_ENDIAN); assertEquals("byte buffer big endian off len", eh, f.hashBytes(bb2, 1, len)); + direct.order(BIG_ENDIAN); + assertEquals("direct byte buffer big endian", eh, f.hashBytes(direct)); ((Buffer)bb.order(nativeOrder())).clear(); } diff --git a/src/test/java/net/openhft/hashing/LongTupleHashFunctionTest.java b/src/test/java/net/openhft/hashing/LongTupleHashFunctionTest.java index 9781aa2..78c2b36 100644 --- a/src/test/java/net/openhft/hashing/LongTupleHashFunctionTest.java +++ b/src/test/java/net/openhft/hashing/LongTupleHashFunctionTest.java @@ -204,11 +204,34 @@ private static void testByteBuffers(LongTupleHashFunction f, long[] eh, int len, bb2.put(bb); assertArrayEquals("byte buffer little endian off len", eh, f.hashBytes(bb2, 1, len)); + ByteBuffer direct = ByteBuffer.allocateDirect(len + 2).order(LITTLE_ENDIAN); + direct.put(0, (byte) 0x6D); + direct.put(len + 1, (byte) 0xB7); + ((Buffer)direct).position(1); + ByteBuffer directSource = bb.duplicate(); + ((Buffer)directSource).clear(); + direct.put(directSource); + ((Buffer)direct).position(1); + ((Buffer)direct).limit(len + 1); + assertArrayEquals("direct byte buffer", eh, f.hashBytes(direct)); + assertEquals("direct byte buffer position unchanged", 1, direct.position()); + assertEquals("direct byte buffer limit unchanged", len + 1, direct.limit()); + + // The explicit range excludes guards that are inside the active window. + ((Buffer)direct).clear(); + assertArrayEquals("direct byte buffer off len", eh, f.hashBytes(direct, 1, len)); + assertEquals("direct range position unchanged", 0, direct.position()); + assertEquals("direct range limit unchanged", len + 2, direct.limit()); + ((Buffer)direct).position(1); + ((Buffer)direct).limit(len + 1); + ((Buffer)bb.order(BIG_ENDIAN)).clear(); assertArrayEquals("byte buffer big endian", eh, f.hashBytes(bb)); bb2.order(BIG_ENDIAN); assertArrayEquals("byte buffer big endian off len", eh, f.hashBytes(bb2, 1, len)); + direct.order(BIG_ENDIAN); + assertArrayEquals("direct byte buffer big endian", eh, f.hashBytes(direct)); ((Buffer)bb.order(nativeOrder())).clear(); }