diff --git a/java/cuvs-lucene/pom.xml b/java/cuvs-lucene/pom.xml index a9c7483ea0..af9e2dcf49 100644 --- a/java/cuvs-lucene/pom.xml +++ b/java/cuvs-lucene/pom.xml @@ -199,6 +199,24 @@ SPDX-License-Identifier: Apache-2.0 + + org.apache.maven.plugins + maven-failsafe-plugin + 3.5.4 + + + + integration-test + verify + + + + + + ${project.build.directory}/${project.build.finalName}.jar + + + org.apache.maven.plugins maven-source-plugin diff --git a/java/cuvs-lucene/src/main/resources/META-INF/services/org.apache.lucene.codecs.KnnVectorsFormat b/java/cuvs-lucene/src/main/resources/META-INF/services/org.apache.lucene.codecs.KnnVectorsFormat index 6625ac72a4..1f9ceeda67 100644 --- a/java/cuvs-lucene/src/main/resources/META-INF/services/org.apache.lucene.codecs.KnnVectorsFormat +++ b/java/cuvs-lucene/src/main/resources/META-INF/services/org.apache.lucene.codecs.KnnVectorsFormat @@ -1,8 +1,6 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 -org.apache.lucene.codecs.lucene99.Lucene99HnswVectorsFormat -org.apache.lucene.codecs.lucene99.Lucene99HnswScalarQuantizedVectorsFormat com.nvidia.cuvs.lucene.CuVS2510GPUVectorsFormat com.nvidia.cuvs.lucene.Lucene99AcceleratedHNSWVectorsFormat com.nvidia.cuvs.lucene.LuceneAcceleratedHNSWBinaryQuantizedVectorsFormat diff --git a/java/cuvs-lucene/src/test/java/com/nvidia/cuvs/lucene/ThinJarContentsIT.java b/java/cuvs-lucene/src/test/java/com/nvidia/cuvs/lucene/ThinJarContentsIT.java new file mode 100644 index 0000000000..e4a08b70a4 --- /dev/null +++ b/java/cuvs-lucene/src/test/java/com/nvidia/cuvs/lucene/ThinJarContentsIT.java @@ -0,0 +1,211 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuvs.lucene; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import org.junit.Test; + +/** Verifies the SPI and ownership boundaries of the packaged thin JAR. */ +public class ThinJarContentsIT { + + private static final String MAVEN_METADATA_DIRECTORY = + "META-INF/maven/com.nvidia.cuvs.lucene/cuvs-lucene/"; + private static final String CODEC_SERVICE = "META-INF/services/org.apache.lucene.codecs.Codec"; + private static final String FORMAT_SERVICE = + "META-INF/services/org.apache.lucene.codecs.KnnVectorsFormat"; + private static final Set EXPECTED_RESOURCES = + Set.of( + "META-INF/MANIFEST.MF", + MAVEN_METADATA_DIRECTORY + "pom.properties", + MAVEN_METADATA_DIRECTORY + "pom.xml", + CODEC_SERVICE, + FORMAT_SERVICE, + "logging.properties"); + private static final long PROBE_TIMEOUT_SECONDS = 30; + private static final long TERMINATION_TIMEOUT_SECONDS = 5; + private static final Map> EXPECTED_SERVICES = + Map.of( + CODEC_SERVICE, + Set.of( + "com.nvidia.cuvs.lucene.CuVS2510GPUSearchCodec", + "com.nvidia.cuvs.lucene.Lucene101AcceleratedHNSWCodec", + "com.nvidia.cuvs.lucene.LuceneAcceleratedHNSWBinaryQuantizedCodec", + "com.nvidia.cuvs.lucene.LuceneAcceleratedHNSWScalarQuantizedCodec"), + FORMAT_SERVICE, + Set.of( + "com.nvidia.cuvs.lucene.CuVS2510GPUVectorsFormat", + "com.nvidia.cuvs.lucene.Lucene99AcceleratedHNSWVectorsFormat", + "com.nvidia.cuvs.lucene.LuceneAcceleratedHNSWBinaryQuantizedVectorsFormat", + "com.nvidia.cuvs.lucene.LuceneAcceleratedHNSWScalarQuantizedVectorsFormat")); + + @Test + public void testThinJarContainsOnlyOwnedProductionClassesAndExactServices() throws Exception { + Path thinJar = configuredThinJar(); + try (JarFile jar = new JarFile(thinJar.toFile())) { + Set entries = + jar.stream() + .filter(entry -> !entry.isDirectory()) + .map(JarEntry::getName) + .collect(Collectors.toUnmodifiableSet()); + + Set resources = + entries.stream() + .filter(entry -> !entry.endsWith(".class")) + .collect(Collectors.toUnmodifiableSet()); + assertEquals("Unexpected thin-JAR resources", EXPECTED_RESOURCES, resources); + assertExactServices(jar, entries); + for (String entry : entries) { + if (entry.endsWith(".class")) { + assertTrue( + "Thin JAR contains a dependency class: " + entry, + entry.startsWith("com/nvidia/cuvs/lucene/")); + assertFalse("Thin JAR contains a test class: " + entry, isTestClass(entry)); + } + } + } + } + + @Test + public void testThinJarProvidersResolveThroughLuceneSpiInFreshJvm() throws Exception { + Path thinJar = configuredThinJar(); + Path outputFile = Files.createTempFile("cuvs-lucene-thin-jar-spi-", ".log"); + Process process = null; + try { + process = + new ProcessBuilder( + javaExecutable(), + "-cp", + packagedTestClasspath(thinJar), + ThinJarSpiProbe.class.getName()) + .redirectErrorStream(true) + .redirectOutput(outputFile.toFile()) + .start(); + + boolean completed = process.waitFor(PROBE_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!completed) { + process.destroyForcibly(); + boolean terminated = process.waitFor(TERMINATION_TIMEOUT_SECONDS, TimeUnit.SECONDS); + throw new AssertionError( + "Timed out waiting for thin-JAR SPI probe; terminated=" + + terminated + + "; output:\n" + + Files.readString(outputFile, StandardCharsets.UTF_8)); + } + + String output = Files.readString(outputFile, StandardCharsets.UTF_8); + assertEquals("Fresh-JVM SPI probe failed:\n" + output, 0, process.exitValue()); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw interrupted; + } finally { + if (process != null && process.isAlive()) { + process.destroyForcibly(); + } + Files.deleteIfExists(outputFile); + } + } + + private static Path configuredThinJar() { + String configured = System.getProperty("cuvs.lucene.thinJar"); + assertNotNull("Missing cuvs.lucene.thinJar", configured); + Path thinJar = Path.of(configured).toAbsolutePath().normalize(); + assertTrue("Thin JAR does not exist: " + thinJar, Files.isRegularFile(thinJar)); + return thinJar; + } + + private static void assertExactServices(JarFile jar, Set entries) throws IOException { + Set descriptors = + entries.stream() + .filter(name -> name.startsWith("META-INF/services/org.apache.lucene.")) + .collect(Collectors.toUnmodifiableSet()); + assertEquals(EXPECTED_SERVICES.keySet(), descriptors); + for (Map.Entry> service : EXPECTED_SERVICES.entrySet()) { + JarEntry descriptor = jar.getJarEntry(service.getKey()); + assertNotNull("Missing service descriptor " + service.getKey(), descriptor); + List providers = + new String(jar.getInputStream(descriptor).readAllBytes(), StandardCharsets.UTF_8) + .lines() + .map(ThinJarContentsIT::stripComment) + .filter(line -> !line.isEmpty()) + .toList(); + assertEquals( + "Duplicate providers in " + service.getKey(), + providers.size(), + Set.copyOf(providers).size()); + assertEquals( + "Unexpected providers in " + service.getKey(), service.getValue(), Set.copyOf(providers)); + for (String provider : providers) { + assertTrue( + "Missing provider class " + provider, + entries.contains(provider.replace('.', '/') + ".class")); + } + } + } + + private static boolean isTestClass(String entry) { + String name = entry.substring(entry.lastIndexOf('/') + 1); + return name.startsWith("Test") + || name.contains("Test$") + || name.endsWith("IT.class") + || name.contains("IT$") + || name.contains("Probe"); + } + + private static String packagedTestClasspath(Path thinJar) throws URISyntaxException { + Path mainClasses = thinJar.getParent().resolve("classes").toAbsolutePath().normalize(); + String current = + System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")); + List entries = new ArrayList<>(); + entries.add(thinJar.toString()); + Pattern.compile(Pattern.quote(File.pathSeparator)) + .splitAsStream(current) + .map(Path::of) + .map(Path::toAbsolutePath) + .map(Path::normalize) + .filter(path -> !path.equals(mainClasses)) + .filter(path -> !path.equals(thinJar)) + .map(Path::toString) + .forEach(entries::add); + assertFalse( + "Packaged probe classpath includes target/classes", + entries.contains(mainClasses.toString())); + Path testClasses = + Path.of(ThinJarSpiProbe.class.getProtectionDomain().getCodeSource().getLocation().toURI()) + .toAbsolutePath() + .normalize(); + assertTrue( + "Packaged probe classpath is missing test classes", + entries.contains(testClasses.toString())); + return String.join(File.pathSeparator, entries); + } + + private static String javaExecutable() { + return Path.of(System.getProperty("java.home"), "bin", "java").toString(); + } + + private static String stripComment(String line) { + int comment = line.indexOf('#'); + return (comment < 0 ? line : line.substring(0, comment)).trim(); + } +} diff --git a/java/cuvs-lucene/src/test/java/com/nvidia/cuvs/lucene/ThinJarSpiProbe.java b/java/cuvs-lucene/src/test/java/com/nvidia/cuvs/lucene/ThinJarSpiProbe.java new file mode 100644 index 0000000000..817e5a7c41 --- /dev/null +++ b/java/cuvs-lucene/src/test/java/com/nvidia/cuvs/lucene/ThinJarSpiProbe.java @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuvs.lucene; + +import java.util.Set; +import org.apache.lucene.codecs.Codec; +import org.apache.lucene.codecs.KnnVectorsFormat; + +/** Fresh-JVM entry point used by {@link ThinJarContentsIT}. */ +public final class ThinJarSpiProbe { + + private static final Set CODECS = + Set.of( + "CuVS2510GPUSearchCodec", + "Lucene101AcceleratedHNSWCodec", + "Lucene101AcceleratedHNSWBinaryQuantizedCodec", + "Lucene101AcceleratedHNSWScalarQuantizedCodec"); + private static final Set FORMATS = + Set.of( + "CuVS2510GPUVectorsFormat", + "Lucene99HnswVectorsFormat", + "Lucene99HnswScalarQuantizedVectorsFormat", + "Lucene99AcceleratedHNSWVectorsFormat", + "Lucene99AcceleratedHNSWBinaryQuantizedVectorsFormat", + "Lucene99AcceleratedHNSWScalarQuantizedVectorsFormat"); + + private ThinJarSpiProbe() {} + + public static void main(String[] arguments) { + for (String name : CODECS) { + if (!Codec.availableCodecs().contains(name) || !Codec.forName(name).getName().equals(name)) { + throw new AssertionError("Codec SPI did not resolve " + name); + } + } + for (String name : FORMATS) { + if (!KnnVectorsFormat.availableKnnVectorsFormats().contains(name) + || !KnnVectorsFormat.forName(name).getName().equals(name)) { + throw new AssertionError("KnnVectorsFormat SPI did not resolve " + name); + } + } + } +}