From eccc6e079e17939d37f7185d3fdd47ea393f33f0 Mon Sep 17 00:00:00 2001 From: Edmond Chuc Date: Wed, 21 Oct 2026 14:53:57 +1000 Subject: [PATCH 01/15] fix: load the GeoSPARQL plugin runtime once per JVM GraphDB loads the plugin through a separate class loader per repository and closes it on deletion. Bundled Apache SIS and Derby then retained every deleted repository's runtime, and only the first repository in a process could open the SIS_DATA EPSG database (Derby XSDB6). A thin GraphDB entry point now forwards to a GeoSparqlPlugin loaded by one never-closed runtime class loader per plugin installation, published in the platform MBean server. Add archive lifecycle tests for deleted, restarted, re-enabled, failed and EPSG-indexing repositories, expected-failure tests for the SIS, Derby, JDK and GraphDB retention limitations, and an SDK interface parity test for the entry point. --- .../geosparql/GeoSparqlPluginFacade.java | 222 +++++++++ .../geosparql/PluginRuntimeClassLoader.java | 66 +++ .../PluginRuntimeClassLoaderMBean.java | 8 + .../services/com.ontotext.trree.sdk.Plugin | 2 +- .../AbstractGeoSparqlPluginTest.java | 8 + .../geosparql/GeoSparqlCrashProcess.java | 4 +- .../geosparql/GeoSparqlPluginFacadeTest.java | 70 +++ .../GeoSparqlPluginLifecycleProcess.java | 459 ++++++++++++++++++ .../geosparql/GraphDbPluginArchiveIT.java | 171 +++++++ .../trree/geosparql/TestChangeSettings.java | 4 - .../geosparql/TestIndexCommitFailure.java | 8 +- .../TestPluginEnableTransactionLifecycle.java | 6 - 12 files changed, 1009 insertions(+), 19 deletions(-) create mode 100644 src/main/java/com/ontotext/trree/geosparql/GeoSparqlPluginFacade.java create mode 100644 src/main/java/com/ontotext/trree/geosparql/PluginRuntimeClassLoader.java create mode 100644 src/main/java/com/ontotext/trree/geosparql/PluginRuntimeClassLoaderMBean.java create mode 100644 src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginFacadeTest.java create mode 100644 src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginLifecycleProcess.java diff --git a/src/main/java/com/ontotext/trree/geosparql/GeoSparqlPluginFacade.java b/src/main/java/com/ontotext/trree/geosparql/GeoSparqlPluginFacade.java new file mode 100644 index 00000000..5832efe1 --- /dev/null +++ b/src/main/java/com/ontotext/trree/geosparql/GeoSparqlPluginFacade.java @@ -0,0 +1,222 @@ +package com.ontotext.trree.geosparql; + +import com.ontotext.trree.sdk.InitReason; +import com.ontotext.trree.sdk.ParallelTransactionListener; +import com.ontotext.trree.sdk.PatternInterpreter; +import com.ontotext.trree.sdk.PluginBase; +import com.ontotext.trree.sdk.PluginConnection; +import com.ontotext.trree.sdk.RequestContext; +import com.ontotext.trree.sdk.ShutdownReason; +import com.ontotext.trree.sdk.StatementIterator; +import com.ontotext.trree.sdk.StatementListener; +import com.ontotext.trree.sdk.UpdateInterpreter; +import org.slf4j.Logger; + +import javax.management.InstanceAlreadyExistsException; +import javax.management.JMException; +import javax.management.MBeanServer; +import javax.management.ObjectName; +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.lang.management.ManagementFactory; +import java.net.URL; +import java.net.URLClassLoader; + +/** + * GraphDB plugin entry point that forwards each repository's calls to a {@link GeoSparqlPlugin} loaded by one JVM-wide + * {@link PluginRuntimeClassLoader}. + * + *

GraphDB loads the plugin for each repository through a separate external plugin class loader and closes it when + * the repository shuts down. Apache SIS and its embedded Derby database keep JVM-wide state that one class loader must + * own for the life of the JVM, so the plugin and its dependencies load once and every repository's entry point shares + * them. The runtime class loader is never closed. It is registered in the platform MBean server under type + * {@link #RUNTIME_CLASS_LOADER_NAME_PREFIX}, keyed by the plugin jar location, so separately installed plugin directories do + * not share a runtime. + * + *

This class refers only to GraphDB SDK, SLF4J and JDK types, so a repository's class loader defines no other plugin + * class and can be collected after GraphDB closes it. When the class loader that defines the entry point also defines + * the GraphDB SDK, the plugin is on the host class path, its dependencies already have the JVM lifetime, and the plugin + * loads alongside the entry point. + */ +public final class GeoSparqlPluginFacade extends PluginBase implements PatternInterpreter, UpdateInterpreter, + ParallelTransactionListener, StatementListener { + static final String RUNTIME_CLASS_LOADER_NAME_PREFIX = "com.ontotext.trree.geosparql:type=PluginRuntimeClassLoader"; + private static final String PLUGIN_CLASS = "com.ontotext.trree.geosparql.GeoSparqlPlugin"; + private static final String RUNTIME_CLASS_LOADER_CLASS = "com.ontotext.trree.geosparql.PluginRuntimeClassLoader"; + + private final PluginBase plugin; + + public GeoSparqlPluginFacade() { + try { + plugin = (PluginBase) Class.forName(PLUGIN_CLASS, true, runtimeClassLoader()).getConstructor().newInstance(); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Cannot load the GeoSPARQL plugin runtime.", e); + } + } + + /** Returns the plugin instance that serves this repository. */ + PluginBase plugin() { + return plugin; + } + + private static ClassLoader runtimeClassLoader() { + ClassLoader repositoryLoader = GeoSparqlPluginFacade.class.getClassLoader(); + if (!(repositoryLoader instanceof URLClassLoader pluginJars) + || PluginBase.class.getClassLoader() == repositoryLoader) { + return repositoryLoader; + } + MBeanServer server = ManagementFactory.getPlatformMBeanServer(); + try { + ObjectName name = new ObjectName(RUNTIME_CLASS_LOADER_NAME_PREFIX + ",location=" + + ObjectName.quote(pluginJar().toString())); + if (!server.isRegistered(name)) { + ClassLoader created = newRuntimeClassLoader(pluginJars.getURLs(), repositoryLoader.getParent()); + try { + server.registerMBean(created, name); + } catch (InstanceAlreadyExistsException publishedConcurrently) { + ((Closeable) created).close(); + } + } + return server.getClassLoader(name); + } catch (JMException | IOException e) { + throw new IllegalStateException("Cannot publish the GeoSPARQL plugin runtime class loader.", e); + } + } + + /** + * Defines the runtime class loader's own class outside the repository's class loader, which GraphDB closes when the + * repository shuts down. + */ + private static ClassLoader newRuntimeClassLoader(URL[] jars, ClassLoader host) { + try { + Class type = Class.forName(RUNTIME_CLASS_LOADER_CLASS, true, + new URLClassLoader(new URL[]{pluginJar()}, host)); + return (ClassLoader) type.getConstructor(URL[].class, ClassLoader.class).newInstance(jars, host); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Cannot create the GeoSPARQL plugin runtime class loader.", e); + } + } + + private static URL pluginJar() { + return GeoSparqlPluginFacade.class.getProtectionDomain().getCodeSource().getLocation(); + } + + @Override + public String getName() { + return plugin.getName(); + } + + @Override + public Logger getLogger() { + return plugin.getLogger(); + } + + @Override + public void setLogger(Logger logger) { + plugin.setLogger(logger); + } + + @Override + public File getDataDir() { + return plugin.getDataDir(); + } + + @Override + public void setDataDir(File dataDir) { + plugin.setDataDir(dataDir); + } + + @Override + public long getFingerprint() { + return plugin.getFingerprint(); + } + + @Override + public void setFingerprint(long fingerprint) { + plugin.setFingerprint(fingerprint); + } + + /** Initializes the plugin with its runtime as the context class loader, so dependency service lookups use it. */ + @Override + public void initialize(InitReason reason, PluginConnection pluginConnection) { + Thread thread = Thread.currentThread(); + ClassLoader previous = thread.getContextClassLoader(); + thread.setContextClassLoader(plugin.getClass().getClassLoader()); + try { + plugin.initialize(reason, pluginConnection); + } finally { + thread.setContextClassLoader(previous); + } + } + + @Override + public void shutdown(ShutdownReason reason) { + plugin.shutdown(reason); + } + + @Override + public double estimate(long subject, long predicate, long object, long context, PluginConnection pluginConnection, + RequestContext requestContext) { + return ((PatternInterpreter) plugin).estimate(subject, predicate, object, context, pluginConnection, + requestContext); + } + + @Override + public StatementIterator interpret(long subject, long predicate, long object, long context, + PluginConnection pluginConnection, RequestContext requestContext) { + return ((PatternInterpreter) plugin).interpret(subject, predicate, object, context, pluginConnection, + requestContext); + } + + @Override + public long[] getPredicatesToListenFor() { + return ((UpdateInterpreter) plugin).getPredicatesToListenFor(); + } + + @Override + public boolean interpretUpdate(long subject, long predicate, long object, long context, boolean isAddition, + boolean isExplicit, PluginConnection pluginConnection) { + return ((UpdateInterpreter) plugin).interpretUpdate(subject, predicate, object, context, isAddition, isExplicit, + pluginConnection); + } + + @Override + public boolean statementAdded(long subject, long predicate, long object, long context, boolean explicit, + PluginConnection pluginConnection) { + return ((StatementListener) plugin).statementAdded(subject, predicate, object, context, explicit, + pluginConnection); + } + + @Override + public boolean statementRemoved(long subject, long predicate, long object, long context, boolean explicit, + PluginConnection pluginConnection) { + return ((StatementListener) plugin).statementRemoved(subject, predicate, object, context, explicit, + pluginConnection); + } + + @Override + public void transactionStarted(PluginConnection pluginConnection) { + ((ParallelTransactionListener) plugin).transactionStarted(pluginConnection); + } + + @Override + public void transactionCommit(PluginConnection pluginConnection) { + ((ParallelTransactionListener) plugin).transactionCommit(pluginConnection); + } + + @Override + public void transactionCompleted(PluginConnection pluginConnection) { + ((ParallelTransactionListener) plugin).transactionCompleted(pluginConnection); + } + + @Override + public void transactionAborted(PluginConnection pluginConnection) { + ((ParallelTransactionListener) plugin).transactionAborted(pluginConnection); + } + + @Override + public void transactionAbortedByUser(PluginConnection pluginConnection) { + ((ParallelTransactionListener) plugin).transactionAbortedByUser(pluginConnection); + } +} diff --git a/src/main/java/com/ontotext/trree/geosparql/PluginRuntimeClassLoader.java b/src/main/java/com/ontotext/trree/geosparql/PluginRuntimeClassLoader.java new file mode 100644 index 00000000..a4fb4330 --- /dev/null +++ b/src/main/java/com/ontotext/trree/geosparql/PluginRuntimeClassLoader.java @@ -0,0 +1,66 @@ +package com.ontotext.trree.geosparql; + +import javax.management.loading.PrivateClassLoader; +import java.io.IOException; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.Collections; +import java.util.Enumeration; +import java.util.List; + +/** + * Loads the GeoSPARQL plugin and its bundled dependencies once per JVM. + * + *

Classes and resources follow GraphDB's external plugin class loader: bundled jars take precedence over the host, + * so the plugin's Lucene, Jena, Apache SIS and Derby are used even when the host provides other versions. Classes the + * plugin does not bundle, including the GraphDB SDK, RDF4J and SLF4J, come from the host. Service descriptors for + * Lucene and ANTLR come only from the plugin jars, as GraphDB excludes host copies of those for external plugins. + * + *

The class loader is published as an MBean so every repository's entry point can find it. It is private to that + * lookup and does not join the MBean server's class loader repository. + */ +public final class PluginRuntimeClassLoader extends URLClassLoader implements PluginRuntimeClassLoaderMBean, + PrivateClassLoader { + private static final List PLUGIN_ONLY_RESOURCE_NAME_PARTS = List.of("org.apache.lucene", "org.antlr.v4.runtime"); + + static { + registerAsParallelCapable(); + } + + public PluginRuntimeClassLoader(URL[] jars, ClassLoader host) { + super("GeoSPARQL plugin runtime", jars, host); + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + synchronized (getClassLoadingLock(name)) { + Class type = findLoadedClass(name); + if (type == null) { + try { + type = findClass(name); + } catch (ClassNotFoundException notBundled) { + return super.loadClass(name, resolve); + } + } + if (resolve) { + resolveClass(type); + } + return type; + } + } + + @Override + public URL getResource(String name) { + URL resource = findResource(name); + return resource != null ? resource : super.getResource(name); + } + + @Override + public Enumeration getResources(String name) throws IOException { + List resources = Collections.list(findResources(name)); + if (PLUGIN_ONLY_RESOURCE_NAME_PARTS.stream().noneMatch(name::contains)) { + resources.addAll(Collections.list(getParent().getResources(name))); + } + return Collections.enumeration(resources); + } +} diff --git a/src/main/java/com/ontotext/trree/geosparql/PluginRuntimeClassLoaderMBean.java b/src/main/java/com/ontotext/trree/geosparql/PluginRuntimeClassLoaderMBean.java new file mode 100644 index 00000000..3d267866 --- /dev/null +++ b/src/main/java/com/ontotext/trree/geosparql/PluginRuntimeClassLoaderMBean.java @@ -0,0 +1,8 @@ +package com.ontotext.trree.geosparql; + +/** + * Management interface under which {@link GeoSparqlPluginFacade} publishes the JVM-wide {@link PluginRuntimeClassLoader} + * in the platform MBean server. It exposes no attributes or operations. + */ +public interface PluginRuntimeClassLoaderMBean { +} diff --git a/src/main/resources/META-INF/services/com.ontotext.trree.sdk.Plugin b/src/main/resources/META-INF/services/com.ontotext.trree.sdk.Plugin index 99aa3e3a..a873a196 100644 --- a/src/main/resources/META-INF/services/com.ontotext.trree.sdk.Plugin +++ b/src/main/resources/META-INF/services/com.ontotext.trree.sdk.Plugin @@ -1 +1 @@ -com.ontotext.trree.geosparql.GeoSparqlPlugin \ No newline at end of file +com.ontotext.trree.geosparql.GeoSparqlPluginFacade \ No newline at end of file diff --git a/src/test/java/com/ontotext/trree/geosparql/AbstractGeoSparqlPluginTest.java b/src/test/java/com/ontotext/trree/geosparql/AbstractGeoSparqlPluginTest.java index bd751841..9ec2dee1 100644 --- a/src/test/java/com/ontotext/trree/geosparql/AbstractGeoSparqlPluginTest.java +++ b/src/test/java/com/ontotext/trree/geosparql/AbstractGeoSparqlPluginTest.java @@ -6,6 +6,7 @@ import com.ontotext.test.TemporaryLocalFolder; import com.ontotext.test.TemporaryRepositoryManager; import com.ontotext.test.utils.OwlimSeRepositoryDescription; +import com.ontotext.trree.OwlimSchemaRepository; import org.eclipse.rdf4j.common.exception.RDF4JException; import org.eclipse.rdf4j.common.io.FileUtil; import org.eclipse.rdf4j.common.io.IOUtil; @@ -22,6 +23,7 @@ import org.eclipse.rdf4j.repository.RepositoryConnection; import org.eclipse.rdf4j.repository.RepositoryException; import org.eclipse.rdf4j.repository.config.RepositoryConfig; +import org.eclipse.rdf4j.repository.sail.SailRepository; import org.eclipse.rdf4j.rio.RDFFormat; import org.eclipse.rdf4j.rio.RDFParseException; import org.junit.*; @@ -80,6 +82,12 @@ public void closeConn() throws RepositoryException { connection.close(); } + /** Returns the GeoSPARQL plugin instance that serves {@link #repository}. */ + protected GeoSparqlPlugin activePlugin() { + return (GeoSparqlPlugin) ((GeoSparqlPluginFacade) ((OwlimSchemaRepository) ((SailRepository) repository).getSail()) + .getPlugin("GeoSPARQL")).plugin(); + } + protected void restartRepository() throws RepositoryException { connection.close(); repository.shutDown(); diff --git a/src/test/java/com/ontotext/trree/geosparql/GeoSparqlCrashProcess.java b/src/test/java/com/ontotext/trree/geosparql/GeoSparqlCrashProcess.java index 6b40d953..2a35c928 100644 --- a/src/test/java/com/ontotext/trree/geosparql/GeoSparqlCrashProcess.java +++ b/src/test/java/com/ontotext/trree/geosparql/GeoSparqlCrashProcess.java @@ -85,8 +85,8 @@ private static void executeControl(RepositoryConnection connection, IRI predicat } private static GeoSparqlPlugin activePlugin(Repository repository) { - return (GeoSparqlPlugin) ((OwlimSchemaRepository) ((SailRepository) repository).getSail()) - .getPlugin("GeoSPARQL"); + return (GeoSparqlPlugin) ((GeoSparqlPluginFacade) ((OwlimSchemaRepository) ((SailRepository) repository).getSail()) + .getPlugin("GeoSPARQL")).plugin(); } private static void installCrashIndexer(GeoSparqlPlugin plugin, CrashBoundary boundary) throws Exception { diff --git a/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginFacadeTest.java b/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginFacadeTest.java new file mode 100644 index 00000000..2a1fd74c --- /dev/null +++ b/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginFacadeTest.java @@ -0,0 +1,70 @@ +package com.ontotext.trree.geosparql; + +import com.ontotext.trree.sdk.PluginBase; +import org.junit.Test; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +public class GeoSparqlPluginFacadeTest { + private static final String SDK_PACKAGE = PluginBase.class.getPackageName(); + + @Test + public void entryPointImplementsTheGraphDbSdkInterfacesOfThePlugin() { + assertEquals(sdkInterfaces(GeoSparqlPlugin.class), sdkInterfaces(GeoSparqlPluginFacade.class)); + } + + @Test + public void entryPointOverridesEveryGraphDbSdkMethodOfThePlugin() { + for (Method method : sdkMethods(GeoSparqlPlugin.class)) { + try { + GeoSparqlPluginFacade.class.getDeclaredMethod(method.getName(), method.getParameterTypes()); + } catch (NoSuchMethodException e) { + fail("The GraphDB entry point does not override " + method); + } + } + } + + private static Set> sdkInterfaces(Class type) { + Set> interfaces = new LinkedHashSet<>(); + for (Class current = type; current != null; current = current.getSuperclass()) { + collectSdkInterfaces(current.getInterfaces(), interfaces); + } + return interfaces; + } + + private static void collectSdkInterfaces(Class[] candidates, Set> interfaces) { + for (Class candidate : candidates) { + if (candidate.getPackageName().equals(SDK_PACKAGE)) { + interfaces.add(candidate); + } + collectSdkInterfaces(candidate.getInterfaces(), interfaces); + } + } + + /** Methods GraphDB can call: every SDK interface method, including defaults, and the public {@link PluginBase} API. */ + private static List sdkMethods(Class type) { + List methods = new ArrayList<>(); + for (Class sdkInterface : sdkInterfaces(type)) { + for (Method method : sdkInterface.getMethods()) { + if (!Modifier.isStatic(method.getModifiers())) { + methods.add(method); + } + } + } + for (Method method : PluginBase.class.getDeclaredMethods()) { + if (Modifier.isPublic(method.getModifiers()) && !Modifier.isStatic(method.getModifiers()) + && !method.getName().equals("toString")) { + methods.add(method); + } + } + return methods; + } +} diff --git a/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginLifecycleProcess.java b/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginLifecycleProcess.java new file mode 100644 index 00000000..7df787e2 --- /dev/null +++ b/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginLifecycleProcess.java @@ -0,0 +1,459 @@ +package com.ontotext.trree.geosparql; + +import com.ontotext.graphdb.Config; +import com.ontotext.graphdb.GraphDBRepositoryManager; +import com.ontotext.test.utils.OwlimSeRepositoryDescription; +import com.ontotext.trree.OwlimSchemaRepository; +import com.ontotext.trree.sdk.Entities; +import com.ontotext.trree.sdk.InitReason; +import com.ontotext.trree.sdk.Plugin; +import com.ontotext.trree.sdk.PluginBase; +import com.ontotext.trree.sdk.PluginConnection; +import com.ontotext.trree.sdk.ShutdownReason; +import com.ontotext.trree.sdk.impl.ServiceLocator; +import org.slf4j.LoggerFactory; +import org.eclipse.rdf4j.query.QueryLanguage; +import org.eclipse.rdf4j.query.algebra.evaluation.function.FunctionRegistry; +import org.eclipse.rdf4j.repository.Repository; +import org.eclipse.rdf4j.repository.RepositoryConnection; +import org.eclipse.rdf4j.repository.sail.SailRepository; + +import javax.management.ObjectName; +import java.io.Closeable; +import java.lang.management.ManagementFactory; +import java.lang.ref.WeakReference; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; +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.TreeMap; +import java.util.stream.Collectors; + +/** Exercises repository lifetimes with dependencies loaded from the assembled plugin archive. */ +public final class GeoSparqlPluginLifecycleProcess { + private static final String INTERSECTS = "http://www.opengis.net/def/function/geosparql/sfIntersects"; + private static final String INTERSECTS_QUERY = """ + PREFIX geo: + PREFIX geof: + ASK { + VALUES (?left ?right) { ("POINT(1 1)"^^geo:wktLiteral "POINT(1 1)"^^geo:wktLiteral) } + FILTER(geof:sfIntersects(?left, ?right)) + } + """; + private static final String INSERT_EPSG_GEOMETRIES = """ + PREFIX geo: + PREFIX ex: + INSERT DATA { + ex:container geo:hasDefaultGeometry ex:containerGeometry . + ex:containerGeometry geo:asWKT " \ + POLYGON((521990 6703990,521990 6704010,522010 6704010,522010 6703990,521990 6703990))"^^geo:wktLiteral . + ex:thing geo:hasDefaultGeometry ex:thingGeometry . + ex:thingGeometry geo:asWKT " POINT(522000 6704000)"^^geo:wktLiteral . + } + """; + private static final String ENABLE_GEOSPARQL = """ + PREFIX plugin: + INSERT DATA { [] plugin:enabled true } + """; + private static final String EPSG_WITHIN_QUERY = """ + PREFIX geo: + PREFIX ex: + ASK { ex:thing geo:sfWithin ex:container } + """; + private static final String EPSG_DISTANCE_QUERY = """ + PREFIX geo: + PREFIX geof: + PREFIX uom: + ASK { + BIND(geof:distance( + " POINT(522000 6704000)"^^geo:wktLiteral, + " POINT(522003 6704004)"^^geo:wktLiteral, + uom:metre) AS ?distance) + FILTER(abs(?distance - 5.0) < 0.01) + } + """; + + private GeoSparqlPluginLifecycleProcess() { + } + + public static void main(String[] arguments) throws Exception { + Path work = Path.of(arguments[1]); + System.setProperty("graphdb.home.work", work.resolve("work").toString()); + System.setProperty("register-external-plugins", arguments[2]); + System.setProperty("graphdb.stats.default", "disabled"); + Config.reset(); + GraphDBRepositoryManager manager = new GraphDBRepositoryManager(work.toFile()); + manager.init(); + try { + switch (arguments[0]) { + case "deleted-repositories" -> assertDeletedRepositoriesCollected(manager); + case "close-oldest" -> assertOtherRepositoryRemainsUsable(manager, false); + case "close-newest" -> assertOtherRepositoryRemainsUsable(manager, true); + case "restart" -> assertRestartRemainsUsable(manager); + case "disable-enable" -> assertPluginCanBeEnabledAgain(manager); + case "initialization-failure" -> assertFailedInitializationCollected(Path.of(arguments[2]), work); + case "unstopped-sis" -> assertUnstoppedSisRetainsPluginLoader(Path.of(arguments[2])); + case "indexed-epsg-repositories" -> assertIndexedEpsgRepositoriesCollected(manager); + case "isolated-epsg-database" -> assertIsolatedRuntimesCannotShareEpsgDatabase(Path.of(arguments[2])); + case "first-loader-retained" -> assertHostRetainsLoader(manager, true); + case "last-loader-retained" -> assertHostRetainsLoader(manager, false); + default -> throw new IllegalArgumentException("Unknown lifecycle scenario: " + arguments[0]); + } + } finally { + manager.shutDown(); + } + } + + private static Repository create(GraphDBRepositoryManager manager, String id) { + OwlimSeRepositoryDescription description = new OwlimSeRepositoryDescription(); + description.setId(id); + description.getOwlimSailConfig().setRuleset("empty"); + manager.addRepositoryConfig(description.getRepositoryConfig()); + Repository repository = manager.getRepository(id); + if (!loader(repository).getClass().getName().contains("ExternalPluginLoader")) { + throw new AssertionError("Expected the GraphDB external plugin loader"); + } + return repository; + } + + private static ClassLoader loader(Repository repository) { + return ((OwlimSchemaRepository) ((SailRepository) repository).getSail()).getPlugin("GeoSPARQL") + .getClass().getClassLoader(); + } + + /** Returns the plugin instance that serves the repository behind GraphDB's entry point. */ + private static Object servingPlugin(Repository repository) throws Exception { + Object servingPlugin = forwardedPlugin(((OwlimSchemaRepository) ((SailRepository) repository).getSail()) + .getPlugin("GeoSPARQL")); + if (servingPlugin.getClass().getClassLoader() != runtimeClassLoader()) { + throw new AssertionError("Repositories must share one plugin runtime class loader"); + } + return servingPlugin; + } + + /** Returns the plugin instance that a GraphDB entry point forwards to. */ + private static Object forwardedPlugin(Object entryPoint) throws Exception { + var plugin = entryPoint.getClass().getDeclaredMethod("plugin"); + plugin.setAccessible(true); + return plugin.invoke(entryPoint); + } + + private static ClassLoader runtimeClassLoader() throws Exception { + var server = ManagementFactory.getPlatformMBeanServer(); + var names = server.queryNames(new ObjectName(GeoSparqlPluginFacade.RUNTIME_CLASS_LOADER_NAME_PREFIX + ",*"), null); + if (names.size() != 1) { + throw new AssertionError("Expected one plugin runtime class loader: " + names); + } + return server.getClassLoader(names.iterator().next()); + } + + /** Weak references to a deleted repository's plugin class loader and the plugin instance that served it. */ + private record Retired(WeakReference loader, WeakReference plugin) { + } + + /** + * Selects the references that must be collected: every serving plugin and every repository class loader except + * the first and the last {@code keptByHost}. The JDK keeps the class loader of the repository whose calls created + * the plugin runtime, which defines only the GraphDB entry point, and GraphDB keeps the class loader of the last + * repository a thread queried; the {@code first-loader-retained} and {@code last-loader-retained} scenarios cover + * both. + */ + private static List> collectable(List retired, int keptByHost) { + List> references = new ArrayList<>(); + for (int index = 0; index < retired.size(); index++) { + references.add(retired.get(index).plugin()); + if (index > 0 && index < retired.size() - keptByHost) { + references.add(retired.get(index).loader()); + } + } + return references; + } + + /** Deletes the repository and returns weak references to its plugin class loader and serving plugin instance. */ + private static Retired delete(GraphDBRepositoryManager manager, String id, Repository repository) + throws Exception { + Retired retired = new Retired(new WeakReference<>(loader(repository)), + new WeakReference<>(servingPlugin(repository))); + if (!manager.removeRepository(id)) { + throw new AssertionError("Repository must be deleted: " + id); + } + return retired; + } + + private static Retired createAndDelete(GraphDBRepositoryManager manager, String id) throws Exception { + return createAndDelete(manager, id, false); + } + + private static Retired createAndDelete(GraphDBRepositoryManager manager, String id, + boolean evaluateFunction) throws Exception { + Repository repository = create(manager, id); + if (evaluateFunction) { + assertFunction(repository); + } + return delete(manager, id, repository); + } + + private static void assertDeletedRepositoriesCollected(GraphDBRepositoryManager manager) throws Exception { + List retired = new ArrayList<>(); + for (int index = 0; index < 6; index++) { + retired.add(createAndDelete(manager, "repository-" + index, index % 2 != 0)); + } + ClassLoader currentFunctions = FunctionRegistry.getInstance().get(INTERSECTS).orElseThrow() + .getClass().getClassLoader(); + if (currentFunctions != runtimeClassLoader()) { + throw new AssertionError("Registered functions must belong to the shared plugin runtime"); + } + awaitCollection(collectable(retired, 1)); + } + + private static void assertIndexedEpsgRepositoriesCollected(GraphDBRepositoryManager manager) throws Exception { + List retired = new ArrayList<>(); + for (int index = 0; index < 4; index++) { + retired.add(indexEpsgDataAndDelete(manager, "indexed-" + index)); + } + awaitCollection(collectable(retired, 1)); + assertDependencyThreadsStartedOnce(); + } + + /** Apache SIS starts its daemon threads once for the class loader that owns it. */ + private static void assertDependencyThreadsStartedOnce() { + Map threads = Thread.getAllStackTraces().keySet().stream() + .collect(Collectors.groupingBy(thread -> thread.getName().replaceAll("\\d+", "#"), TreeMap::new, + Collectors.counting())); + System.out.println("thread-census " + threads); + for (String name : List.of("ReferenceQueueConsumer", "DelayedExecutor")) { + if (threads.getOrDefault(name, 0L) != 1) { + throw new AssertionError("Expected one " + name + " thread: " + threads); + } + } + } + + private static Retired indexEpsgDataAndDelete(GraphDBRepositoryManager manager, String id) + throws Exception { + Repository repository = create(manager, id); + try (RepositoryConnection connection = repository.getConnection()) { + connection.prepareUpdate(INSERT_EPSG_GEOMETRIES).execute(); + connection.prepareUpdate(ENABLE_GEOSPARQL).execute(); + if (!connection.prepareBooleanQuery(QueryLanguage.SPARQL, EPSG_WITHIN_QUERY).evaluate()) { + throw new AssertionError("Indexed EPSG:3006 property relation must match: " + id); + } + if (!connection.prepareBooleanQuery(QueryLanguage.SPARQL, EPSG_DISTANCE_QUERY).evaluate()) { + throw new AssertionError("EPSG:3006 distance must be five metres: " + id); + } + } + return delete(manager, id, repository); + } + + private static void awaitCollection(List> retired) throws Exception { + for (int attempt = 0; attempt < 30; attempt++) { + System.gc(); + Thread.sleep(100); + if (retired.stream().allMatch(reference -> reference.refersTo(null))) { + return; + } + } + long retained = retired.stream().filter(reference -> !reference.refersTo(null)).count(); + throw new AssertionError("Deleted repositories' plugin class loaders or instances remain reachable: " + retained + + " / " + retired.size()); + } + + private static void assertOtherRepositoryRemainsUsable(GraphDBRepositoryManager manager, + boolean closeNewest) throws Exception { + Repository first = create(manager, "first"); + Repository second = create(manager, "second"); + Repository survivor = closeNewest ? first : second; + String deleted = closeNewest ? "second" : "first"; + try (RepositoryConnection connection = survivor.getConnection()) { + var query = connection.prepareBooleanQuery(QueryLanguage.SPARQL, INTERSECTS_QUERY); + if (!query.evaluate()) { + throw new AssertionError("GeoSPARQL filter function must match before repository deletion"); + } + manager.removeRepository(deleted); + if (!query.evaluate()) { + throw new AssertionError("GeoSPARQL filter function must match after repository deletion"); + } + assertFunction(survivor); + createAndDelete(manager, "replacement"); + if (!query.evaluate()) { + throw new AssertionError("GeoSPARQL filter function must match after function replacement"); + } + assertFunction(survivor); + } + } + + private static void assertFailedInitializationCollected(Path plugins, Path work) throws Exception { + List retired = new ArrayList<>(); + for (int index = 0; index < 3; index++) { + retired.add(failInitialization(plugins.resolve("geosparql-plugin"), work.resolve("failed-" + index), false)); + retired.add(failInitialization(plugins.resolve("geosparql-plugin"), work.resolve("failed-early-" + index), true)); + } + awaitCollection(collectable(retired, 0)); + } + + private static Retired failInitialization(Path pluginDirectory, Path data, + boolean beforeGeometryInitialization) throws Exception { + PluginBase plugin = locatePlugin(pluginDirectory); + ClassLoader loader = plugin.getClass().getClassLoader(); + WeakReference serving = new WeakReference<>(forwardedPlugin(plugin)); + plugin.setDataDir(Files.createDirectories(data).toFile()); + plugin.setLogger(LoggerFactory.getLogger("GeoSPARQL")); + Entities entities = (Entities) Proxy.newProxyInstance(Entities.class.getClassLoader(), + new Class[]{Entities.class}, (proxy, method, arguments) -> { + if (method.getName().equals("put")) { + if (beforeGeometryInitialization + || arguments[0].toString().equals("http://www.opengis.net/ont/geosparql#asWKT")) { + throw new IllegalStateException("Entity allocation failed"); + } + return 1L; + } + throw new AssertionError("Unexpected entity operation: " + method.getName()); + }); + PluginConnection connection = (PluginConnection) Proxy.newProxyInstance(PluginConnection.class.getClassLoader(), + new Class[]{PluginConnection.class}, (proxy, method, arguments) -> { + if (method.getName().equals("getEntities")) { + return entities; + } + throw new AssertionError("Unexpected connection operation: " + method.getName()); + }); + ClassLoader previous = Thread.currentThread().getContextClassLoader(); + Thread.currentThread().setContextClassLoader(loader); + try { + try { + plugin.initialize(InitReason.DEFAULT, connection); + throw new AssertionError("Initialization must reject entity allocation failure"); + } catch (IllegalStateException expected) { + if (!expected.getMessage().equals("Entity allocation failed")) { + throw expected; + } + } + plugin.shutdown(ShutdownReason.DEFAULT); + } finally { + Thread.currentThread().setContextClassLoader(previous); + ((Closeable) loader).close(); + } + return new Retired(new WeakReference<>(loader), serving); + } + + private static PluginBase locatePlugin(Path pluginDirectory) throws Exception { + for (Plugin candidate : ServiceLocator.locateExternal(pluginDirectory.toFile(), Plugin.class)) { + if (candidate.getName().equals("GeoSPARQL")) { + return (PluginBase) candidate; + } + } + throw new AssertionError("Expected the GeoSPARQL plugin from the archive"); + } + + private static void assertUnstoppedSisRetainsPluginLoader(Path plugins) throws Exception { + Path pluginDirectory = plugins.resolve("geosparql-plugin"); + // The first entry point creates the runtime class loader, which keeps that repository class loader reachable. + locatePlugin(pluginDirectory); + awaitCollection(List.of(initializeAndClose(pluginDirectory, true))); + WeakReference unstopped = initializeAndClose(pluginDirectory, false); + for (int attempt = 0; attempt < 10; attempt++) { + System.gc(); + Thread.sleep(100); + } + if (unstopped.refersTo(null)) { + throw new AssertionError("Apache SIS released the plugin class loader without Shutdown.stop"); + } + } + + /** Initializes the archive's Jena and SIS runtime without plugin initialization, shuts down Jena, and closes the loader. */ + private static WeakReference initializeAndClose(Path pluginDirectory, boolean stopSis) throws Exception { + ClassLoader loader = locatePlugin(pluginDirectory).getClass().getClassLoader(); + Class adapter = Class.forName("com.ontotext.trree.geosparql.jena.JenaGeometryAdapter", true, loader); + adapter.getMethod("initialize").invoke(null); + Class.forName("org.apache.jena.sys.JenaSystem", true, loader).getMethod("shutdown").invoke(null); + if (stopSis) { + Class.forName("org.apache.sis.system.Shutdown", true, loader).getMethod("stop", Class.class) + .invoke(null, adapter); + } + ((Closeable) loader).close(); + return new WeakReference<>(loader); + } + + private static void assertHostRetainsLoader(GraphDBRepositoryManager manager, boolean first) throws Exception { + List retired = new ArrayList<>(); + for (int index = 0; index < 3; index++) { + retired.add(createAndDelete(manager, "repository-" + index, true)); + } + awaitCollection(collectable(retired, 1)); + Retired kept = first ? retired.getFirst() : retired.getLast(); + if (kept.loader().refersTo(null)) { + throw new AssertionError("The " + (first ? "first" : "last") + " repository class loader was collected"); + } + } + + /** A second class loader cannot open the EPSG database that another class loader's Derby engine has open. */ + private static void assertIsolatedRuntimesCannotShareEpsgDatabase(Path plugins) throws Exception { + Path pluginDirectory = plugins.resolve("geosparql-plugin"); + ClassLoader first = locatePlugin(pluginDirectory).getClass().getClassLoader(); + ClassLoader second = locatePlugin(pluginDirectory).getClass().getClassLoader(); + if (!resolvesEpsg3006(first)) { + throw new AssertionError("The first class loader must resolve EPSG:3006 from SIS_DATA"); + } + if (resolvesEpsg3006(second)) { + throw new AssertionError("A second Derby engine opened the EPSG database another engine has open"); + } + String derbyLog = Files.readString(Path.of(System.getProperty("derby.stream.error.file"))); + if (!derbyLog.contains("XSDB6")) { + throw new AssertionError("Expected Derby to report the database as already booted:\n" + derbyLog); + } + } + + private static boolean resolvesEpsg3006(ClassLoader loader) throws Exception { + Thread thread = Thread.currentThread(); + ClassLoader previous = thread.getContextClassLoader(); + thread.setContextClassLoader(loader); + try { + Class.forName("org.apache.sis.referencing.CRS", true, loader).getMethod("forCode", String.class) + .invoke(null, "EPSG:3006"); + return true; + } catch (InvocationTargetException unresolved) { + return false; + } finally { + thread.setContextClassLoader(previous); + } + } + + private static void assertPluginCanBeEnabledAgain(GraphDBRepositoryManager manager) throws Exception { + Repository first = create(manager, "first"); + Repository second = create(manager, "second"); + var plugins = ((OwlimSchemaRepository) ((SailRepository) first).getSail()).getPluginManager(); + ClassLoader original = loader(first); + try (RepositoryConnection connection = first.getConnection()) { + connection.prepareUpdate("INSERT DATA { [] \"GeoSPARQL\" }") + .execute(); + if (plugins.getPlugin("GeoSPARQL") != null) { + throw new AssertionError("GeoSPARQL plugin must be disabled"); + } + System.gc(); + assertFunction(second); + connection.prepareUpdate("INSERT DATA { [] \"GeoSPARQL\" }") + .execute(); + } + if (loader(first) != original) { + throw new AssertionError("Plugin must be enabled with its existing class loader"); + } + assertFunction(first); + assertFunction(second); + } + + private static void assertRestartRemainsUsable(GraphDBRepositoryManager manager) { + Repository repository = create(manager, "restart"); + assertFunction(repository); + manager.restartRepository("restart"); + assertFunction(manager.getRepository("restart")); + } + + private static void assertFunction(Repository repository) { + try (RepositoryConnection connection = repository.getConnection()) { + if (!connection.prepareBooleanQuery(QueryLanguage.SPARQL, INTERSECTS_QUERY).evaluate()) { + throw new AssertionError("GeoSPARQL filter function must match"); + } + } + } +} diff --git a/src/test/java/com/ontotext/trree/geosparql/GraphDbPluginArchiveIT.java b/src/test/java/com/ontotext/trree/geosparql/GraphDbPluginArchiveIT.java index aca7a1f2..a56bd472 100644 --- a/src/test/java/com/ontotext/trree/geosparql/GraphDbPluginArchiveIT.java +++ b/src/test/java/com/ontotext/trree/geosparql/GraphDbPluginArchiveIT.java @@ -2,12 +2,20 @@ import org.junit.Test; +import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Comparator; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; import java.util.zip.ZipFile; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; @@ -15,6 +23,7 @@ public class GraphDbPluginArchiveIT { private static final String PLUGIN_ARCHIVE_PROPERTY = "graphdb.pluginArchive"; private static final String PLUGIN_DIRECTORY = "geosparql-plugin/"; + private static final Pattern GEOMETRY_RUNTIME_JAR = Pattern.compile("(jena-|sis-|derby|geoapi-).*\\.jar"); @Test public void assembledPluginContainsRequiredCrsRuntimeOnly() { @@ -35,6 +44,168 @@ public void assembledPluginContainsRequiredCrsRuntimeOnly() { } } + @Test + public void deletedRepositoriesReleaseTheirPluginClassLoaders() throws Exception { + assertLifecycle("deleted-repositories"); + } + + @Test + public void geoSparqlFilterFunctionsRemainAvailableAfterEitherRepositoryCloses() throws Exception { + assertLifecycle("close-oldest"); + assertLifecycle("close-newest"); + } + + @Test + public void repositoryRestartPreservesGeoSparqlFilterFunctions() throws Exception { + assertLifecycle("restart"); + } + + @Test + public void disabledPluginCanBeEnabledWithAnotherRepositoryActive() throws Exception { + assertLifecycle("disable-enable"); + } + + @Test + public void failedInitializationReleasesPluginClassLoader() throws Exception { + assertLifecycle("initialization-failure"); + } + + /** + * Dependency limitation: Apache SIS 1.6, observed with GraphDB 10.8.12 and 11.0 on Java 21, starts a reference-queue + * thread and registers a JVM shutdown hook that keep the class loader that loaded it reachable after GraphDB closes + * it, unless {@code Shutdown.stop} runs. Once SIS releases a closed class loader without {@code Shutdown.stop}, this + * test fails, and a closed class loader that loaded SIS can be collected. ADR 0022 records why the plugin loads SIS + * once per JVM. + */ + @Test + public void unstoppedSisRetainsClosedPluginClassLoader() throws Exception { + assertLifecycle("unstopped-sis"); + } + + /** + * Dependency limitation: an embedded Apache Derby 10.15 engine, observed with GraphDB 10.8.12 and 11.0 on Java 21, + * locks the SIS_DATA EPSG database for its JVM. A Derby engine in a second class loader cannot open it and reports + * {@code XSDB6}, and Apache SIS 1.6 falls back to its built-in CRS subset. Once Derby engines in different class + * loaders can share the database, this test fails, and a second class loader resolves EPSG:3006. + */ + @Test + public void isolatedPluginRuntimesCannotShareEpsgDatabase() throws Exception { + assertLifecycle("isolated-epsg-database", true); + } + + @Test + public void indexedEpsgRepositoriesShareCrsDataAndReleaseTheirPluginClassLoaders() throws Exception { + assertLifecycle("indexed-epsg-repositories", true); + } + + /** + * Dependency limitation: on Java 21.0.11 a URL class loader, and a thread, capture the AccessControlContext of the + * call that creates them, including the protection domain of the GraphDB entry point on the stack. The plugin runtime + * class loader and the dependency threads started by the first repository's calls therefore keep that closed + * repository class loader, which defines only the entry point, reachable once per JVM. Derby's threads do the same + * for the repository that first opens the SIS_DATA EPSG database. Once the JDK no longer retains these class + * loaders, this test fails, and the first repository's class loader can be collected. + */ + @Test + public void jdkRetainsFirstRepositoryClassLoader() throws Exception { + assertLifecycle("first-loader-retained"); + } + + /** + * Dependency limitation: GraphDB 11.0 keeps the last repository a thread queried in that thread's repository + * statistics thread-local, and with it the repository's plugin class loader, after the repository is deleted. Once + * GraphDB releases deleted repositories, this test fails, and the last queried repository's class loader can be + * collected. + */ + @Test + public void graphDbRetainsLastQueriedRepositoryClassLoader() throws Exception { + assertLifecycle("last-loader-retained"); + } + + private void assertLifecycle(String scenario) throws Exception { + assertLifecycle(scenario, false); + } + + private void assertLifecycle(String scenario, boolean epsgData) throws Exception { + Path work = Files.createTempDirectory("geosparql-lifecycle-"); + Process process = null; + try { + Path plugins = work.resolve("plugins"); + extract(requiredPluginArchive(), "", plugins); + Path output = work.resolve("process.log"); + // Clear JVM soft caches so collection measures strong ownership of plugin runtimes. + ProcessBuilder builder = new ProcessBuilder( + Path.of(System.getProperty("java.home"), "bin", "java").toString(), "-Xmx512m", + "-XX:SoftRefLRUPolicyMSPerMB=0", + "-Dderby.stream.error.file=" + work.resolve("derby.log"), "-Dderby.infolog.append=true", + "-cp", externalHostClassPath(), GeoSparqlPluginLifecycleProcess.class.getName(), + scenario, work.resolve("repositories").toString(), plugins.toString()) + .redirectErrorStream(true).redirectOutput(output.toFile()); + if (epsgData) { + // The archive excludes the EPSG dataset; deployments supply its SIS-DATA directory through SIS_DATA. + Path sisData = work.resolve("sis-data"); + extract(sisEmbeddedData(), "SIS-DATA/", sisData); + builder.environment().put("SIS_DATA", sisData.toString()); + } + process = builder.start(); + assertTrue("Lifecycle process timed out: " + output, process.waitFor(120, TimeUnit.SECONDS)); + String diagnostics = Files.readString(output); + diagnostics.lines().filter(line -> line.startsWith("thread-census ")).forEach(System.out::println); + assertEquals("Lifecycle process failed (" + scenario + "):\n" + + diagnostics.substring(Math.max(0, diagnostics.length() - 12000)), 0, process.exitValue()); + } finally { + if (process != null && process.isAlive()) { + process.destroyForcibly().waitFor(); + } + try (var paths = Files.walk(work)) { + for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) { + Files.deleteIfExists(path); + } + } + } + } + + private static void extract(Path zip, String prefix, Path target) throws IOException { + try (ZipFile archive = new ZipFile(zip.toFile())) { + for (var entries = archive.entries(); entries.hasMoreElements();) { + var entry = entries.nextElement(); + if (entry.isDirectory() || !entry.getName().startsWith(prefix)) { + continue; + } + Path destination = target.resolve(entry.getName().substring(prefix.length())).normalize(); + assertTrue("Archive entry must stay inside the extraction directory", destination.startsWith(target)); + Files.createDirectories(destination.getParent()); + try (var input = archive.getInputStream(entry)) { + Files.copy(input, destination); + } + } + } + } + + private String externalHostClassPath() throws Exception { + Path pluginClasses = Path.of("target", "classes").toAbsolutePath().normalize(); + Path pluginArtifact = Path.of(GeoSparqlPlugin.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + // A GraphDB host provides neither the plugin nor its geometry runtime; the archive supplies both. + return testClassPath() + .filter(entry -> !entry.toAbsolutePath().normalize().equals(pluginClasses)) + .filter(entry -> !entry.toAbsolutePath().normalize().equals(pluginArtifact)) + .filter(entry -> !GEOMETRY_RUNTIME_JAR.matcher(entry.getFileName().toString()).matches()) + .map(Path::toString) + .collect(Collectors.joining(File.pathSeparator)); + } + + private Path sisEmbeddedData() { + return testClassPath() + .filter(entry -> entry.getFileName().toString().startsWith("sis-embedded-data-")) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing sis-embedded-data on the test class path")); + } + + private Stream testClassPath() { + String classPath = System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")); + return Arrays.stream(classPath.split(Pattern.quote(File.pathSeparator))).map(Path::of); + } + private Path requiredPluginArchive() { String value = System.getProperty(PLUGIN_ARCHIVE_PROPERTY); assertNotNull("Missing system property " + PLUGIN_ARCHIVE_PROPERTY diff --git a/src/test/java/com/ontotext/trree/geosparql/TestChangeSettings.java b/src/test/java/com/ontotext/trree/geosparql/TestChangeSettings.java index 29b7cc5a..e7634202 100644 --- a/src/test/java/com/ontotext/trree/geosparql/TestChangeSettings.java +++ b/src/test/java/com/ontotext/trree/geosparql/TestChangeSettings.java @@ -285,10 +285,6 @@ public void multipleSettingsUpdatePrefixTreeAndPrecision() throws Exception { assertSetting("precision", "precision", "20"); } - private GeoSparqlPlugin activePlugin() { - return (GeoSparqlPlugin) ((OwlimSchemaRepository) ((SailRepository) repository).getSail()) - .getPlugin("GeoSPARQL"); - } private static final class FailingFreshIndexIndexer extends LuceneGeoIndexer { private FailingFreshIndexIndexer(GeoSparqlPlugin parent) { diff --git a/src/test/java/com/ontotext/trree/geosparql/TestIndexCommitFailure.java b/src/test/java/com/ontotext/trree/geosparql/TestIndexCommitFailure.java index 7fbd5011..111cc42b 100644 --- a/src/test/java/com/ontotext/trree/geosparql/TestIndexCommitFailure.java +++ b/src/test/java/com/ontotext/trree/geosparql/TestIndexCommitFailure.java @@ -1,6 +1,5 @@ package com.ontotext.trree.geosparql; -import com.ontotext.trree.OwlimSchemaRepository; import com.ontotext.trree.geosparql.jena.IndexGeometry; import com.ontotext.trree.geosparql.jena.SourceGeometryLiteral; import com.ontotext.trree.geosparql.vocabulary.GeoConstants; @@ -9,7 +8,6 @@ import org.eclipse.rdf4j.model.Literal; import org.eclipse.rdf4j.repository.RepositoryConnection; import org.eclipse.rdf4j.repository.RepositoryException; -import org.eclipse.rdf4j.repository.sail.SailRepository; import org.junit.Test; import java.io.IOException; @@ -28,8 +26,7 @@ public void ignoreErrorsDoesNotHideIndexCommitFailure() { enablePlugin(); executePluginControl(GeoSparqlPlugin.IGNORE_ERRORS_PREDICATE_IRI, VF.createLiteral(true)); - GeoSparqlPlugin plugin = (GeoSparqlPlugin) ((OwlimSchemaRepository) ((SailRepository) repository).getSail()) - .getPlugin("GeoSPARQL"); + GeoSparqlPlugin plugin = activePlugin(); IOException commitFailure = new IOException("Simulated index commit failure"); FailingCommitIndexer indexer = new FailingCommitIndexer(commitFailure); plugin.indexer = indexer; @@ -63,8 +60,7 @@ public void ignoreErrorsDoesNotHideIncrementalIndexWriteFailure() { connection.add(geometry, GeoConstants.GEO_AS_WKT, original); connection.commit(); - GeoSparqlPlugin plugin = (GeoSparqlPlugin) ((OwlimSchemaRepository) ((SailRepository) repository).getSail()) - .getPlugin("GeoSPARQL"); + GeoSparqlPlugin plugin = activePlugin(); IOException writeFailure = new IOException("Simulated Lucene addDocuments failure"); FailingIndexGeometryListIndexer indexer = new FailingIndexGeometryListIndexer(writeFailure); plugin.indexer = indexer; diff --git a/src/test/java/com/ontotext/trree/geosparql/TestPluginEnableTransactionLifecycle.java b/src/test/java/com/ontotext/trree/geosparql/TestPluginEnableTransactionLifecycle.java index 24e0d07a..41cab1d8 100644 --- a/src/test/java/com/ontotext/trree/geosparql/TestPluginEnableTransactionLifecycle.java +++ b/src/test/java/com/ontotext/trree/geosparql/TestPluginEnableTransactionLifecycle.java @@ -1,6 +1,5 @@ package com.ontotext.trree.geosparql; -import com.ontotext.trree.OwlimSchemaRepository; import com.ontotext.trree.geosparql.jena.SourceGeometryLiteral; import com.ontotext.trree.geosparql.lucene.LuceneGeoIndexer; import com.ontotext.trree.geosparql.util.GeoSparqlUtils; @@ -8,7 +7,6 @@ import org.eclipse.rdf4j.model.IRI; import org.eclipse.rdf4j.model.Literal; import org.eclipse.rdf4j.query.QueryLanguage; -import org.eclipse.rdf4j.repository.sail.SailRepository; import org.junit.Test; import org.slf4j.LoggerFactory; @@ -227,10 +225,6 @@ private List persistedSourceLiterals() throws Exception { return lexicalForms; } - private GeoSparqlPlugin activePlugin() { - return (GeoSparqlPlugin) ((OwlimSchemaRepository) ((SailRepository) repository).getSail()) - .getPlugin("GeoSPARQL"); - } private static final class CountingLuceneGeoIndexer extends LuceneGeoIndexer { private int beginCount; From a16950ea7f502c7fb876f0d3b25789f4a02a7cb2 Mon Sep 17 00:00:00 2001 From: Edmond Chuc Date: Wed, 21 Oct 2026 14:53:57 +1000 Subject: [PATCH 02/15] test: check repositories share external CRS data in the packaging smoke test --- .../geosparql/GraphDbPackagingSmokeIT.java | 54 ++++++++++++++----- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/src/test/java/com/ontotext/trree/geosparql/GraphDbPackagingSmokeIT.java b/src/test/java/com/ontotext/trree/geosparql/GraphDbPackagingSmokeIT.java index 6d50bcb2..efecee2b 100644 --- a/src/test/java/com/ontotext/trree/geosparql/GraphDbPackagingSmokeIT.java +++ b/src/test/java/com/ontotext/trree/geosparql/GraphDbPackagingSmokeIT.java @@ -109,18 +109,36 @@ public class GraphDbPackagingSmokeIT { @Test public void assembledPluginUsesExternalCrsDataForIndexedPropertyRelationAndDistanceFunction() throws Exception { - createRepository(); - executeUpdate(INSERT_GEOMETRIES, "insert smoke-test geometries"); - executeUpdate(ENABLE_GEOSPARQL, "enable GeoSPARQL and build its index"); + createRepository(REPOSITORY_ID); + executeUpdate(REPOSITORY_ID, INSERT_GEOMETRIES, "insert smoke-test geometries"); + executeUpdate(REPOSITORY_ID, ENABLE_GEOSPARQL, "enable GeoSPARQL and build its index"); - assertAskTrue(WITHIN_QUERY, "execute indexed geo:sfWithin query"); - assertAskTrue(EPSG_3006_WITHIN_QUERY, "execute EPSG:3006 indexed geo:sfWithin query"); - assertAskTrue(EPSG_3006_DISTANCE_QUERY, "evaluate EPSG:3006 five-metre distance function"); + assertAskTrue(REPOSITORY_ID, WITHIN_QUERY, "execute indexed geo:sfWithin query"); + assertAskTrue(REPOSITORY_ID, EPSG_3006_WITHIN_QUERY, "execute EPSG:3006 indexed geo:sfWithin query"); + assertAskTrue(REPOSITORY_ID, EPSG_3006_DISTANCE_QUERY, "evaluate EPSG:3006 five-metre distance function"); } - private void assertAskTrue(String query, String operation) throws Exception { + @Test + public void repositoriesShareExternalCrsDataAcrossRepositoryCreationAndDeletion() throws Exception { + assertIndexedEpsg3006("shared-crs-first"); + assertIndexedEpsg3006("shared-crs-second"); + deleteRepository("shared-crs-first"); + assertAskTrue("shared-crs-second", EPSG_3006_WITHIN_QUERY, + "execute EPSG:3006 indexed geo:sfWithin query in shared-crs-second after deleting shared-crs-first"); + assertIndexedEpsg3006("shared-crs-third"); + } + + private void assertIndexedEpsg3006(String repositoryId) throws Exception { + createRepository(repositoryId); + executeUpdate(repositoryId, INSERT_GEOMETRIES, "insert geometries into " + repositoryId); + executeUpdate(repositoryId, ENABLE_GEOSPARQL, "enable GeoSPARQL and build the index of " + repositoryId); + assertAskTrue(repositoryId, EPSG_3006_WITHIN_QUERY, "execute EPSG:3006 indexed geo:sfWithin query in " + + repositoryId); + } + + private void assertAskTrue(String repositoryId, String query, String operation) throws Exception { HttpResponse response = send(HttpRequest.newBuilder( - endpoint("/repositories/" + REPOSITORY_ID + "?query=" + encode(query))) + endpoint("/repositories/" + repositoryId + "?query=" + encode(query))) .header("Accept", "application/sparql-results+json") .timeout(Duration.ofMinutes(2)) .GET() @@ -130,8 +148,11 @@ private void assertAskTrue(String query, String operation) throws Exception { TRUE_BOOLEAN_RESULT.matcher(response.body()).find()); } - private void createRepository() throws Exception { - byte[] repositoryConfig = readResource("/graphdb-packaging-smoke-repository.ttl"); + private void createRepository(String repositoryId) throws Exception { + byte[] repositoryConfig = new String(readResource("/graphdb-packaging-smoke-repository.ttl"), + StandardCharsets.UTF_8) + .replace("rep:repositoryID \"" + REPOSITORY_ID + "\"", "rep:repositoryID \"" + repositoryId + "\"") + .getBytes(StandardCharsets.UTF_8); String boundary = "GraphDbPackagingSmokeBoundary"; ByteArrayOutputStream body = new ByteArrayOutputStream(); body.write(("--" + boundary + "\r\n" @@ -145,11 +166,18 @@ private void createRepository() throws Exception { .header("Content-Type", "multipart/form-data; boundary=" + boundary) .timeout(Duration.ofMinutes(2)) .POST(HttpRequest.BodyPublishers.ofByteArray(body.toByteArray())) - .build(), "create ephemeral GraphDB repository"); + .build(), "create ephemeral GraphDB repository " + repositoryId); + } + + private void deleteRepository(String repositoryId) throws Exception { + send(HttpRequest.newBuilder(endpoint("/rest/repositories/" + repositoryId)) + .timeout(Duration.ofMinutes(2)) + .DELETE() + .build(), "delete GraphDB repository " + repositoryId); } - private void executeUpdate(String update, String operation) throws Exception { - send(HttpRequest.newBuilder(endpoint("/repositories/" + REPOSITORY_ID + "/statements")) + private void executeUpdate(String repositoryId, String update, String operation) throws Exception { + send(HttpRequest.newBuilder(endpoint("/repositories/" + repositoryId + "/statements")) .header("Content-Type", "application/x-www-form-urlencoded") .timeout(Duration.ofMinutes(2)) .POST(HttpRequest.BodyPublishers.ofString("update=" + encode(update))) From 0ca5714a7f080c91a2d05e5b31b761fc29b40308 Mon Sep 17 00:00:00 2001 From: Edmond Chuc Date: Wed, 21 Oct 2026 14:53:57 +1000 Subject: [PATCH 03/15] docs: describe CRS data sharing across repositories and GraphDB processes --- README.md | 3 ++- docs/geosparql-crs-deployment.md | 23 ++++++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8e1cd10f..7d40f43f 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,8 @@ The built plugin can be found in the `target` directory: The opt-in packaging smoke test requires Docker. It builds a temporary GraphDB 10.8.12 image with Java 21, installs the assembled plugin ZIP, and uses externally supplied Apache SIS data to run an EPSG:3006 GeoSPARQL property relation -and five-metre distance filter function alongside a CRS84 property relation: +and five-metre distance filter function alongside a CRS84 property relation. It also checks that repositories created +and deleted in the same GraphDB process each resolve EPSG:3006: ```bash mvn -Pgraphdb-packaging-smoke verify diff --git a/docs/geosparql-crs-deployment.md b/docs/geosparql-crs-deployment.md index 5f55d301..82cdd4ef 100644 --- a/docs/geosparql-crs-deployment.md +++ b/docs/geosparql-crs-deployment.md @@ -151,6 +151,19 @@ Environment=SIS_DATA=/var/lib/graphdb/sis-data The exact path is up to you. It must be visible to the JVM that runs GraphDB. Do not mount the Derby database read-only unless it has been prepared for read-only use and Derby has a writable temporary directory. +### Repositories and GraphDB processes + +All repositories in a GraphDB process that use the same GeoSPARQL plugin installation share one plugin runtime, +including one embedded Derby engine for the EPSG database in `SIS_DATA`. Any number of repositories in the process can +use projected CRSes, and creating, restarting, or deleting a repository does not affect the others. Install the plugin +in one directory: each separately installed GeoSPARQL plugin directory runs its own runtime and Derby engine. + +The embedded Derby database in `SIS_DATA` can be open for writing in only one process at a time. When several GraphDB +processes use the same CRS data, for example cluster nodes or containers on one host, either: + +- give each process its own copy of the `SIS_DATA` directory, or +- mount one copy read-only in every process, prepared for read-only use as described above. + ## CRS Data Contents The required data depends on the CRSes you need to support. @@ -184,7 +197,7 @@ Recommended validation checks: ### Repository packaging smoke test -The repository includes an opt-in smoke test that packages the plugin and supplies an external `SIS_DATA` directory. It runs a CRS84 GeoSPARQL property relation, an indexed EPSG:3006 GeoSPARQL property relation, and an EPSG:3006 five-metre distance filter function in GraphDB: +The repository includes an opt-in smoke test that packages the plugin and supplies an external `SIS_DATA` directory. It runs a CRS84 GeoSPARQL property relation, an indexed EPSG:3006 GeoSPARQL property relation, and an EPSG:3006 five-metre distance filter function in GraphDB. It also creates and deletes repositories in the same GraphDB process and checks that each one resolves EPSG:3006: ```bash mvn -Pgraphdb-packaging-smoke verify @@ -278,6 +291,14 @@ Cannot find NTv2 file named "OSTN02_NTv2.gsb". then a CRS operation needs a grid-shift file that is not present in the deployed CRS data. Install the required file, restart GraphDB, and rerun known-point validation. +If `derby.log` in the GraphDB working directory contains: + +```text +ERROR XSDB6: Another instance of Derby may have already booted the database +``` + +then another process, or a second GeoSPARQL plugin installation in the same GraphDB process, has the `SIS_DATA` EPSG database open for writing, and Apache SIS falls back to its built-in CRS subset. Give each GraphDB process its own copy of the directory or mount it read-only, keep one plugin installation, and restart GraphDB. + If a geometry fails with an unsupported CRS error, treat it as one of: - The CRS URI is not supported by Jena/SIS. From b218b6cc8ade261a793b0ddb5767647c094beea0 Mon Sep 17 00:00:00 2001 From: Edmond Chuc Date: Tue, 20 Oct 2026 02:43:30 +1000 Subject: [PATCH 04/15] test: redirect crash process output to a file The crash-recovery test read the child process output only after waitFor returned, so a child that filled the pipe buffer blocked on write while the test waited for it to exit. The abort-restoration boundary logs about 9 KB of transaction-failure stack traces, which exceeds the reduced pipe buffer the kernel assigns once the per-user pipe limit is reached. Write the output to a file, forcibly stop a child that does not terminate, and include its output in the timeout failure message. --- .../geosparql/GeoSparqlProcessCrashRecoveryTest.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/ontotext/trree/geosparql/GeoSparqlProcessCrashRecoveryTest.java b/src/test/java/com/ontotext/trree/geosparql/GeoSparqlProcessCrashRecoveryTest.java index 155dd816..e1ad4c9f 100644 --- a/src/test/java/com/ontotext/trree/geosparql/GeoSparqlProcessCrashRecoveryTest.java +++ b/src/test/java/com/ontotext/trree/geosparql/GeoSparqlProcessCrashRecoveryTest.java @@ -57,13 +57,20 @@ public void restartFailsClosedDuringAbortRestoration() throws Exception { private void assertRestartFailsClosed(GeoSparqlCrashProcess.CrashBoundary boundary) throws Exception { Path managerDir = tmpFolder.newFolder(boundary.name().toLowerCase()).toPath(); + // A file, unlike a pipe, never blocks the crash process on output the test has not yet read. + Path outputFile = managerDir.resolveSibling(boundary.name().toLowerCase() + ".log"); Process process = new ProcessBuilder( Path.of(System.getProperty("java.home"), "bin", "java").toString(), "-cp", testClassPath(), GeoSparqlCrashProcess.class.getName(), boundary.name(), managerDir.toString()) .redirectErrorStream(true) + .redirectOutput(outputFile.toFile()) .start(); - assertTrue("Crash process did not terminate", process.waitFor(30, TimeUnit.SECONDS)); - String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + boolean terminated = process.waitFor(30, TimeUnit.SECONDS); + if (!terminated) { + process.destroyForcibly().waitFor(); + } + String output = Files.readString(outputFile, StandardCharsets.UTF_8); + assertTrue("Crash process did not terminate:\n" + output, terminated); assertEquals(output, GeoSparqlCrashProcess.HALT_CODE, process.exitValue()); Path dataDir = findPluginDataDir(managerDir); From 8add22f9f5cc8fd692b69941a97d8305b1e6505c Mon Sep 17 00:00:00 2001 From: Edmond Chuc Date: Wed, 21 Oct 2026 15:28:30 +1000 Subject: [PATCH 05/15] test: require repository deletion and cover separate plugin installations The repository-availability scenario now fails if the repository is not deleted. A new archive lifecycle scenario checks that entry points from one plugin directory share a runtime and that a separately installed directory loads its own. --- .../GeoSparqlPluginLifecycleProcess.java | 33 ++++++++++++++++++- .../geosparql/GraphDbPluginArchiveIT.java | 5 +++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginLifecycleProcess.java b/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginLifecycleProcess.java index 7df787e2..c7bd9414 100644 --- a/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginLifecycleProcess.java +++ b/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginLifecycleProcess.java @@ -98,6 +98,7 @@ public static void main(String[] arguments) throws Exception { case "unstopped-sis" -> assertUnstoppedSisRetainsPluginLoader(Path.of(arguments[2])); case "indexed-epsg-repositories" -> assertIndexedEpsgRepositoriesCollected(manager); case "isolated-epsg-database" -> assertIsolatedRuntimesCannotShareEpsgDatabase(Path.of(arguments[2])); + case "separate-installations" -> assertSeparateInstallationsUseSeparateRuntimes(Path.of(arguments[2])); case "first-loader-retained" -> assertHostRetainsLoader(manager, true); case "last-loader-retained" -> assertHostRetainsLoader(manager, false); default -> throw new IllegalArgumentException("Unknown lifecycle scenario: " + arguments[0]); @@ -271,7 +272,9 @@ private static void assertOtherRepositoryRemainsUsable(GraphDBRepositoryManager if (!query.evaluate()) { throw new AssertionError("GeoSPARQL filter function must match before repository deletion"); } - manager.removeRepository(deleted); + if (!manager.removeRepository(deleted)) { + throw new AssertionError("Repository must be deleted: " + deleted); + } if (!query.evaluate()) { throw new AssertionError("GeoSPARQL filter function must match after repository deletion"); } @@ -375,6 +378,34 @@ private static WeakReference initializeAndClose(Path pluginDirector return new WeakReference<>(loader); } + /** Entry points from one plugin directory share a runtime; a separately installed directory loads its own. */ + private static void assertSeparateInstallationsUseSeparateRuntimes(Path plugins) throws Exception { + Path installation = plugins.resolve("geosparql-plugin"); + Path separateInstallation = plugins.resolveSibling("separate-plugins").resolve("geosparql-plugin"); + try (var paths = Files.walk(installation)) { + for (Path source : paths.toList()) { + Path target = separateInstallation.resolve(installation.relativize(source).toString()); + if (Files.isDirectory(source)) { + Files.createDirectories(target); + } else { + Files.copy(source, target); + } + } + } + ClassLoader runtime = forwardedPlugin(locatePlugin(installation)).getClass().getClassLoader(); + if (forwardedPlugin(locatePlugin(installation)).getClass().getClassLoader() != runtime) { + throw new AssertionError("Entry points from one plugin directory must share a runtime"); + } + if (forwardedPlugin(locatePlugin(separateInstallation)).getClass().getClassLoader() == runtime) { + throw new AssertionError("A separately installed plugin directory must load its own runtime"); + } + var names = ManagementFactory.getPlatformMBeanServer() + .queryNames(new ObjectName(GeoSparqlPluginFacade.RUNTIME_CLASS_LOADER_NAME_PREFIX + ",*"), null); + if (names.size() != 2) { + throw new AssertionError("Expected one runtime class loader per plugin directory: " + names); + } + } + private static void assertHostRetainsLoader(GraphDBRepositoryManager manager, boolean first) throws Exception { List retired = new ArrayList<>(); for (int index = 0; index < 3; index++) { diff --git a/src/test/java/com/ontotext/trree/geosparql/GraphDbPluginArchiveIT.java b/src/test/java/com/ontotext/trree/geosparql/GraphDbPluginArchiveIT.java index a56bd472..bb6ab516 100644 --- a/src/test/java/com/ontotext/trree/geosparql/GraphDbPluginArchiveIT.java +++ b/src/test/java/com/ontotext/trree/geosparql/GraphDbPluginArchiveIT.java @@ -93,6 +93,11 @@ public void isolatedPluginRuntimesCannotShareEpsgDatabase() throws Exception { assertLifecycle("isolated-epsg-database", true); } + @Test + public void separatePluginInstallationsUseSeparateRuntimes() throws Exception { + assertLifecycle("separate-installations"); + } + @Test public void indexedEpsgRepositoriesShareCrsDataAndReleaseTheirPluginClassLoaders() throws Exception { assertLifecycle("indexed-epsg-repositories", true); From 0772b0d4be1110f3560634e298169635564160e4 Mon Sep 17 00:00:00 2001 From: Edmond Chuc Date: Wed, 21 Oct 2026 16:19:18 +1000 Subject: [PATCH 06/15] test: cover concurrent EPSG use across repositories Four repositories with different projected CRSes build their indexes at the same time and then query concurrently, so first-time and repeated EPSG lookups all go through the shared SIS and Derby runtime. --- .../GeoSparqlPluginLifecycleProcess.java | 127 ++++++++++++++++++ .../geosparql/GraphDbPluginArchiveIT.java | 5 + 2 files changed, 132 insertions(+) diff --git a/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginLifecycleProcess.java b/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginLifecycleProcess.java index c7bd9414..4eb3628f 100644 --- a/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginLifecycleProcess.java +++ b/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginLifecycleProcess.java @@ -30,6 +30,13 @@ import java.util.List; import java.util.Map; import java.util.TreeMap; +import java.util.concurrent.Callable; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; /** Exercises repository lifetimes with dependencies loaded from the assembled plugin archive. */ @@ -76,6 +83,53 @@ public final class GeoSparqlPluginLifecycleProcess { } """; + private static final int QUERY_THREADS_PER_REPOSITORY = 4; + private static final int QUERIES_PER_THREAD = 20; + + /** Projected CRSes that need the SIS_DATA EPSG database, each with a point inside its area of use. */ + private static final List PROJECTED_POINTS = List.of( + new ProjectedPoint(3006, 522000, 6704000), + new ProjectedPoint(2154, 652000, 6862000), + new ProjectedPoint(3035, 4321000, 3210000), + new ProjectedPoint(2193, 1750000, 5430000)); + + private record ProjectedPoint(int epsgCode, long easting, long northing) { + String insertGeometries() { + return """ + PREFIX geo: + PREFIX ex: + INSERT DATA { + ex:container geo:hasDefaultGeometry ex:containerGeometry . + ex:containerGeometry geo:asWKT "%s"^^geo:wktLiteral . + ex:thing geo:hasDefaultGeometry ex:thingGeometry . + ex:thingGeometry geo:asWKT "%s"^^geo:wktLiteral . + } + """.formatted( + wkt("POLYGON((%d %d,%d %d,%d %d,%d %d,%d %d))".formatted( + easting - 10, northing - 10, easting - 10, northing + 10, easting + 10, northing + 10, + easting + 10, northing - 10, easting - 10, northing - 10)), + wkt("POINT(%d %d)".formatted(easting, northing))); + } + + /** Asks whether two points 3 and 4 metres apart in this CRS are five metres apart. */ + String fiveMetreDistanceQuery() { + return """ + PREFIX geo: + PREFIX geof: + PREFIX uom: + ASK { + BIND(geof:distance("%s"^^geo:wktLiteral, "%s"^^geo:wktLiteral, uom:metre) AS ?distance) + FILTER(abs(?distance - 5.0) < 0.01) + } + """.formatted(wkt("POINT(%d %d)".formatted(easting, northing)), + wkt("POINT(%d %d)".formatted(easting + 3, northing + 4))); + } + + private String wkt(String geometry) { + return " " + geometry; + } + } + private GeoSparqlPluginLifecycleProcess() { } @@ -97,6 +151,7 @@ public static void main(String[] arguments) throws Exception { case "initialization-failure" -> assertFailedInitializationCollected(Path.of(arguments[2]), work); case "unstopped-sis" -> assertUnstoppedSisRetainsPluginLoader(Path.of(arguments[2])); case "indexed-epsg-repositories" -> assertIndexedEpsgRepositoriesCollected(manager); + case "concurrent-epsg-repositories" -> assertRepositoriesUseEpsgDataConcurrently(manager); case "isolated-epsg-database" -> assertIsolatedRuntimesCannotShareEpsgDatabase(Path.of(arguments[2])); case "separate-installations" -> assertSeparateInstallationsUseSeparateRuntimes(Path.of(arguments[2])); case "first-loader-retained" -> assertHostRetainsLoader(manager, true); @@ -219,6 +274,78 @@ private static void assertIndexedEpsgRepositoriesCollected(GraphDBRepositoryMana assertDependencyThreadsStartedOnce(); } + /** + * Repositories that each use a different projected CRS build their indexes at the same time, then query + * concurrently, so first-time EPSG lookups and repeated lookups all go through the shared SIS and Derby runtime. + */ + private static void assertRepositoriesUseEpsgDataConcurrently(GraphDBRepositoryManager manager) throws Exception { + List repositories = new ArrayList<>(); + for (ProjectedPoint point : PROJECTED_POINTS) { + Repository repository = create(manager, "concurrent-" + point.epsgCode()); + try (RepositoryConnection connection = repository.getConnection()) { + connection.prepareUpdate(point.insertGeometries()).execute(); + } + repositories.add(repository); + } + ExecutorService executor = Executors.newFixedThreadPool(repositories.size() * QUERY_THREADS_PER_REPOSITORY); + try { + CyclicBarrier indexing = new CyclicBarrier(repositories.size()); + List> enable = new ArrayList<>(); + for (Repository repository : repositories) { + enable.add(() -> { + indexing.await(); + try (RepositoryConnection connection = repository.getConnection()) { + connection.prepareUpdate(ENABLE_GEOSPARQL).execute(); + } + return null; + }); + } + awaitAll(executor.invokeAll(enable, 60, TimeUnit.SECONDS), "concurrent index builds"); + + CyclicBarrier querying = new CyclicBarrier(repositories.size() * QUERY_THREADS_PER_REPOSITORY); + List> queries = new ArrayList<>(); + for (int index = 0; index < repositories.size(); index++) { + Repository repository = repositories.get(index); + ProjectedPoint point = PROJECTED_POINTS.get(index); + for (int thread = 0; thread < QUERY_THREADS_PER_REPOSITORY; thread++) { + queries.add(() -> { + querying.await(); + try (RepositoryConnection connection = repository.getConnection()) { + for (int query = 0; query < QUERIES_PER_THREAD; query++) { + if (!connection.prepareBooleanQuery(QueryLanguage.SPARQL, EPSG_WITHIN_QUERY).evaluate()) { + throw new AssertionError("Indexed EPSG:" + point.epsgCode() + + " property relation must match"); + } + if (!connection.prepareBooleanQuery(QueryLanguage.SPARQL, point.fiveMetreDistanceQuery()) + .evaluate()) { + throw new AssertionError("EPSG:" + point.epsgCode() + " distance must be five metres"); + } + } + } + return null; + }); + } + } + awaitAll(executor.invokeAll(queries, 60, TimeUnit.SECONDS), "concurrent queries"); + } finally { + executor.shutdownNow(); + } + assertDependencyThreadsStartedOnce(); + } + + private static void awaitAll(List> tasks, String work) throws Exception { + for (Future task : tasks) { + if (task.isCancelled()) { + throw new AssertionError("The " + work + " did not finish in time"); + } + try { + task.get(); + } catch (ExecutionException failure) { + throw new AssertionError("The " + work + " failed", failure.getCause()); + } + } + } + /** Apache SIS starts its daemon threads once for the class loader that owns it. */ private static void assertDependencyThreadsStartedOnce() { Map threads = Thread.getAllStackTraces().keySet().stream() diff --git a/src/test/java/com/ontotext/trree/geosparql/GraphDbPluginArchiveIT.java b/src/test/java/com/ontotext/trree/geosparql/GraphDbPluginArchiveIT.java index bb6ab516..3a381be6 100644 --- a/src/test/java/com/ontotext/trree/geosparql/GraphDbPluginArchiveIT.java +++ b/src/test/java/com/ontotext/trree/geosparql/GraphDbPluginArchiveIT.java @@ -93,6 +93,11 @@ public void isolatedPluginRuntimesCannotShareEpsgDatabase() throws Exception { assertLifecycle("isolated-epsg-database", true); } + @Test + public void repositoriesUseEpsgDataConcurrently() throws Exception { + assertLifecycle("concurrent-epsg-repositories", true); + } + @Test public void separatePluginInstallationsUseSeparateRuntimes() throws Exception { assertLifecycle("separate-installations"); From fa1fa3637ce815f9c35847593580783f9016fb09 Mon Sep 17 00:00:00 2001 From: Edmond Chuc Date: Wed, 21 Oct 2026 16:25:06 +1000 Subject: [PATCH 07/15] test: keep lifecycle timeout output and cover concurrent runtime creation A timed-out lifecycle process is now stopped and its log tail is included in the failure before the work directory is removed. A new scenario loads several entry points from one plugin directory at the same instant and checks that they create and share one published runtime. --- .../GeoSparqlPluginLifecycleProcess.java | 36 +++++++++++++++++-- .../geosparql/GraphDbPluginArchiveIT.java | 15 ++++++-- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginLifecycleProcess.java b/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginLifecycleProcess.java index 4eb3628f..13014ea3 100644 --- a/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginLifecycleProcess.java +++ b/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginLifecycleProcess.java @@ -154,6 +154,7 @@ public static void main(String[] arguments) throws Exception { case "concurrent-epsg-repositories" -> assertRepositoriesUseEpsgDataConcurrently(manager); case "isolated-epsg-database" -> assertIsolatedRuntimesCannotShareEpsgDatabase(Path.of(arguments[2])); case "separate-installations" -> assertSeparateInstallationsUseSeparateRuntimes(Path.of(arguments[2])); + case "concurrent-runtime-creation" -> assertEntryPointsLoadedTogetherShareOneRuntime(Path.of(arguments[2])); case "first-loader-retained" -> assertHostRetainsLoader(manager, true); case "last-loader-retained" -> assertHostRetainsLoader(manager, false); default -> throw new IllegalArgumentException("Unknown lifecycle scenario: " + arguments[0]); @@ -333,17 +334,19 @@ private static void assertRepositoriesUseEpsgDataConcurrently(GraphDBRepositoryM assertDependencyThreadsStartedOnce(); } - private static void awaitAll(List> tasks, String work) throws Exception { - for (Future task : tasks) { + private static List awaitAll(List> tasks, String work) throws Exception { + List results = new ArrayList<>(); + for (Future task : tasks) { if (task.isCancelled()) { throw new AssertionError("The " + work + " did not finish in time"); } try { - task.get(); + results.add(task.get()); } catch (ExecutionException failure) { throw new AssertionError("The " + work + " failed", failure.getCause()); } } + return results; } /** Apache SIS starts its daemon threads once for the class loader that owns it. */ @@ -533,6 +536,33 @@ private static void assertSeparateInstallationsUseSeparateRuntimes(Path plugins) } } + /** Entry points that GraphDB loads from one plugin directory at the same instant create and share one runtime. */ + private static void assertEntryPointsLoadedTogetherShareOneRuntime(Path plugins) throws Exception { + Path installation = plugins.resolve("geosparql-plugin"); + int entryPoints = 8; + CyclicBarrier loading = new CyclicBarrier(entryPoints); + ExecutorService executor = Executors.newFixedThreadPool(entryPoints); + List runtimes; + try { + List> loads = new ArrayList<>(); + for (int index = 0; index < entryPoints; index++) { + loads.add(() -> { + loading.await(); + return forwardedPlugin(locatePlugin(installation)).getClass().getClassLoader(); + }); + } + runtimes = awaitAll(executor.invokeAll(loads, 60, TimeUnit.SECONDS), "concurrent entry point loads"); + } finally { + executor.shutdownNow(); + } + if (runtimes.stream().anyMatch(runtime -> runtime != runtimes.getFirst())) { + throw new AssertionError("Entry points loaded together must share one runtime"); + } + if (runtimeClassLoader() != runtimes.getFirst()) { + throw new AssertionError("The published runtime must be the one the entry points use"); + } + } + private static void assertHostRetainsLoader(GraphDBRepositoryManager manager, boolean first) throws Exception { List retired = new ArrayList<>(); for (int index = 0; index < 3; index++) { diff --git a/src/test/java/com/ontotext/trree/geosparql/GraphDbPluginArchiveIT.java b/src/test/java/com/ontotext/trree/geosparql/GraphDbPluginArchiveIT.java index 3a381be6..1095b068 100644 --- a/src/test/java/com/ontotext/trree/geosparql/GraphDbPluginArchiveIT.java +++ b/src/test/java/com/ontotext/trree/geosparql/GraphDbPluginArchiveIT.java @@ -98,6 +98,11 @@ public void repositoriesUseEpsgDataConcurrently() throws Exception { assertLifecycle("concurrent-epsg-repositories", true); } + @Test + public void entryPointsLoadedTogetherShareOneRuntime() throws Exception { + assertLifecycle("concurrent-runtime-creation"); + } + @Test public void separatePluginInstallationsUseSeparateRuntimes() throws Exception { assertLifecycle("separate-installations"); @@ -158,11 +163,15 @@ private void assertLifecycle(String scenario, boolean epsgData) throws Exception builder.environment().put("SIS_DATA", sisData.toString()); } process = builder.start(); - assertTrue("Lifecycle process timed out: " + output, process.waitFor(120, TimeUnit.SECONDS)); + boolean terminated = process.waitFor(120, TimeUnit.SECONDS); + if (!terminated) { + process.destroyForcibly().waitFor(); + } String diagnostics = Files.readString(output); + String tail = diagnostics.substring(Math.max(0, diagnostics.length() - 12000)); + assertTrue("Lifecycle process timed out (" + scenario + "):\n" + tail, terminated); diagnostics.lines().filter(line -> line.startsWith("thread-census ")).forEach(System.out::println); - assertEquals("Lifecycle process failed (" + scenario + "):\n" - + diagnostics.substring(Math.max(0, diagnostics.length() - 12000)), 0, process.exitValue()); + assertEquals("Lifecycle process failed (" + scenario + "):\n" + tail, 0, process.exitValue()); } finally { if (process != null && process.isAlive()) { process.destroyForcibly().waitFor(); From 64982e0c9baf0677e92298cc26de7580bca3e44f Mon Sep 17 00:00:00 2001 From: Edmond Chuc Date: Wed, 21 Oct 2026 16:47:35 +1000 Subject: [PATCH 08/15] fix: warn when the GeoSPARQL runtime cannot be shared If GraphDB's plugin class loader does not expose the plugin jars, the entry point loads the plugin in each repository's class loader. Log a warning at initialization in that case, since repositories then cannot share the SIS_DATA EPSG database and deleted repositories keep their runtime. --- .../com/ontotext/trree/geosparql/GeoSparqlPluginFacade.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/com/ontotext/trree/geosparql/GeoSparqlPluginFacade.java b/src/main/java/com/ontotext/trree/geosparql/GeoSparqlPluginFacade.java index 5832efe1..9df242b4 100644 --- a/src/main/java/com/ontotext/trree/geosparql/GeoSparqlPluginFacade.java +++ b/src/main/java/com/ontotext/trree/geosparql/GeoSparqlPluginFacade.java @@ -140,6 +140,12 @@ public void setFingerprint(long fingerprint) { /** Initializes the plugin with its runtime as the context class loader, so dependency service lookups use it. */ @Override public void initialize(InitReason reason, PluginConnection pluginConnection) { + ClassLoader repositoryLoader = GeoSparqlPluginFacade.class.getClassLoader(); + if (plugin.getClass().getClassLoader() == repositoryLoader && PluginBase.class.getClassLoader() != repositoryLoader) { + getLogger().warn("GeoSPARQL loads its runtime for each repository because GraphDB's plugin class loader does " + + "not expose the plugin jars. Repositories cannot share the SIS_DATA EPSG database, and deleted " + + "repositories keep their runtime in memory."); + } Thread thread = Thread.currentThread(); ClassLoader previous = thread.getContextClassLoader(); thread.setContextClassLoader(plugin.getClass().getClassLoader()); From d9d9e788f9ae26ec15da6805c1a9b845a4e5eb1e Mon Sep 17 00:00:00 2001 From: Edmond Chuc Date: Wed, 21 Oct 2026 16:58:05 +1000 Subject: [PATCH 09/15] test: cover losing a concurrent runtime publication Move publication of the runtime class loader into a method that takes the MBean server, and test with a stub server that reports another repository's registration that the created class loader is closed and the published one is used. --- .../geosparql/GeoSparqlPluginFacade.java | 30 +++++++++----- .../geosparql/GeoSparqlPluginFacadeTest.java | 40 +++++++++++++++++++ 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/ontotext/trree/geosparql/GeoSparqlPluginFacade.java b/src/main/java/com/ontotext/trree/geosparql/GeoSparqlPluginFacade.java index 9df242b4..9290958d 100644 --- a/src/main/java/com/ontotext/trree/geosparql/GeoSparqlPluginFacade.java +++ b/src/main/java/com/ontotext/trree/geosparql/GeoSparqlPluginFacade.java @@ -22,6 +22,7 @@ import java.lang.management.ManagementFactory; import java.net.URL; import java.net.URLClassLoader; +import java.util.function.Supplier; /** * GraphDB plugin entry point that forwards each repository's calls to a {@link GeoSparqlPlugin} loaded by one JVM-wide @@ -66,24 +67,33 @@ private static ClassLoader runtimeClassLoader() { || PluginBase.class.getClassLoader() == repositoryLoader) { return repositoryLoader; } - MBeanServer server = ManagementFactory.getPlatformMBeanServer(); try { ObjectName name = new ObjectName(RUNTIME_CLASS_LOADER_NAME_PREFIX + ",location=" + ObjectName.quote(pluginJar().toString())); - if (!server.isRegistered(name)) { - ClassLoader created = newRuntimeClassLoader(pluginJars.getURLs(), repositoryLoader.getParent()); - try { - server.registerMBean(created, name); - } catch (InstanceAlreadyExistsException publishedConcurrently) { - ((Closeable) created).close(); - } - } - return server.getClassLoader(name); + return publishedRuntimeClassLoader(ManagementFactory.getPlatformMBeanServer(), name, + () -> newRuntimeClassLoader(pluginJars.getURLs(), repositoryLoader.getParent())); } catch (JMException | IOException e) { throw new IllegalStateException("Cannot publish the GeoSPARQL plugin runtime class loader.", e); } } + /** + * Returns the runtime class loader published under {@code name}, first publishing one from {@code create} if none + * is. When another repository publishes first, the created class loader is closed and the published one is used. + */ + static ClassLoader publishedRuntimeClassLoader(MBeanServer server, ObjectName name, Supplier create) + throws JMException, IOException { + if (!server.isRegistered(name)) { + ClassLoader created = create.get(); + try { + server.registerMBean(created, name); + } catch (InstanceAlreadyExistsException publishedConcurrently) { + ((Closeable) created).close(); + } + } + return server.getClassLoader(name); + } + /** * Defines the runtime class loader's own class outside the repository's class loader, which GraphDB closes when the * repository shuts down. diff --git a/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginFacadeTest.java b/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginFacadeTest.java index 2a1fd74c..1dbe9555 100644 --- a/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginFacadeTest.java +++ b/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginFacadeTest.java @@ -3,14 +3,23 @@ import com.ontotext.trree.sdk.PluginBase; import org.junit.Test; +import javax.management.InstanceAlreadyExistsException; +import javax.management.MBeanServer; +import javax.management.ObjectName; +import java.io.IOException; import java.lang.reflect.Method; import java.lang.reflect.Modifier; +import java.lang.reflect.Proxy; +import java.net.URL; +import java.net.URLClassLoader; import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; public class GeoSparqlPluginFacadeTest { @@ -32,6 +41,37 @@ public void entryPointOverridesEveryGraphDbSdkMethodOfThePlugin() { } } + @Test + public void runtimePublishedByAnotherRepositoryIsUsedAndTheCreatedOneClosed() throws Exception { + ClassLoader published = new URLClassLoader(new URL[0]); + ObjectName name = new ObjectName(GeoSparqlPluginFacade.RUNTIME_CLASS_LOADER_NAME_PREFIX + ",location=\"jar\""); + MBeanServer server = (MBeanServer) Proxy.newProxyInstance(MBeanServer.class.getClassLoader(), + new Class[]{MBeanServer.class}, (proxy, method, arguments) -> switch (method.getName()) { + case "isRegistered" -> false; + case "registerMBean" -> throw new InstanceAlreadyExistsException(name.toString()); + case "getClassLoader" -> published; + default -> throw new AssertionError("Unexpected MBean server call: " + method.getName()); + }); + ClosingClassLoader created = new ClosingClassLoader(); + + assertSame(published, GeoSparqlPluginFacade.publishedRuntimeClassLoader(server, name, () -> created)); + assertTrue("The class loader that lost the publication must be closed", created.closed); + } + + private static final class ClosingClassLoader extends URLClassLoader { + private boolean closed; + + ClosingClassLoader() { + super(new URL[0]); + } + + @Override + public void close() throws IOException { + closed = true; + super.close(); + } + } + private static Set> sdkInterfaces(Class type) { Set> interfaces = new LinkedHashSet<>(); for (Class current = type; current != null; current = current.getSuperclass()) { From 097a2164f4ba76e67e313269055e9e4005575211 Mon Sep 17 00:00:00 2001 From: Edmond Chuc Date: Wed, 21 Oct 2026 18:06:09 +1000 Subject: [PATCH 10/15] test: cover concurrent creation, in-flight deletion, recreation, and recovery Four repositories created through the repository manager at the same instant must share the one published runtime, with one set of SIS threads, while each indexes its own projected CRS. The repository whose calls created the runtime and opened the EPSG database is deleted while three others run EPSG queries on worker threads; those queries keep succeeding, the deleted plugin instance is collected, and the thread census is unchanged. A repository created with a deleted repository's ID indexes and queries EPSG data again, which needs the shared Lucene lock registry to have released the deleted index's lock. After failed initializations, a repository created through the manager evaluates filter functions and indexes EPSG:3006 data. The EPSG query loop, the index-and-query steps, and the failing initializations move into helpers shared with the existing scenarios, whose behaviour is unchanged. --- .../GeoSparqlPluginLifecycleProcess.java | 186 +++++++++++++----- .../geosparql/GraphDbPluginArchiveIT.java | 20 ++ 2 files changed, 154 insertions(+), 52 deletions(-) diff --git a/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginLifecycleProcess.java b/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginLifecycleProcess.java index 13014ea3..e98cf403 100644 --- a/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginLifecycleProcess.java +++ b/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginLifecycleProcess.java @@ -50,17 +50,6 @@ public final class GeoSparqlPluginLifecycleProcess { FILTER(geof:sfIntersects(?left, ?right)) } """; - private static final String INSERT_EPSG_GEOMETRIES = """ - PREFIX geo: - PREFIX ex: - INSERT DATA { - ex:container geo:hasDefaultGeometry ex:containerGeometry . - ex:containerGeometry geo:asWKT " \ - POLYGON((521990 6703990,521990 6704010,522010 6704010,522010 6703990,521990 6703990))"^^geo:wktLiteral . - ex:thing geo:hasDefaultGeometry ex:thingGeometry . - ex:thingGeometry geo:asWKT " POINT(522000 6704000)"^^geo:wktLiteral . - } - """; private static final String ENABLE_GEOSPARQL = """ PREFIX plugin: INSERT DATA { [] plugin:enabled true } @@ -70,18 +59,6 @@ public final class GeoSparqlPluginLifecycleProcess { PREFIX ex: ASK { ex:thing geo:sfWithin ex:container } """; - private static final String EPSG_DISTANCE_QUERY = """ - PREFIX geo: - PREFIX geof: - PREFIX uom: - ASK { - BIND(geof:distance( - " POINT(522000 6704000)"^^geo:wktLiteral, - " POINT(522003 6704004)"^^geo:wktLiteral, - uom:metre) AS ?distance) - FILTER(abs(?distance - 5.0) < 0.01) - } - """; private static final int QUERY_THREADS_PER_REPOSITORY = 4; private static final int QUERIES_PER_THREAD = 20; @@ -152,6 +129,11 @@ public static void main(String[] arguments) throws Exception { case "unstopped-sis" -> assertUnstoppedSisRetainsPluginLoader(Path.of(arguments[2])); case "indexed-epsg-repositories" -> assertIndexedEpsgRepositoriesCollected(manager); case "concurrent-epsg-repositories" -> assertRepositoriesUseEpsgDataConcurrently(manager); + case "concurrent-repository-creation" -> assertRepositoriesCreatedTogetherShareOneRuntime(manager); + case "delete-during-epsg-queries" -> assertFirstEpsgRepositoryDeletedWhileOthersQuery(manager); + case "recreated-repository" -> assertRecreatedRepositoryIndexesEpsgData(manager); + case "initialization-failure-recovery" -> + assertRuntimeUsableAfterFailedInitializations(manager, Path.of(arguments[2]), work); case "isolated-epsg-database" -> assertIsolatedRuntimesCannotShareEpsgDatabase(Path.of(arguments[2])); case "separate-installations" -> assertSeparateInstallationsUseSeparateRuntimes(Path.of(arguments[2])); case "concurrent-runtime-creation" -> assertEntryPointsLoadedTogetherShareOneRuntime(Path.of(arguments[2])); @@ -306,25 +288,8 @@ private static void assertRepositoriesUseEpsgDataConcurrently(GraphDBRepositoryM CyclicBarrier querying = new CyclicBarrier(repositories.size() * QUERY_THREADS_PER_REPOSITORY); List> queries = new ArrayList<>(); for (int index = 0; index < repositories.size(); index++) { - Repository repository = repositories.get(index); - ProjectedPoint point = PROJECTED_POINTS.get(index); for (int thread = 0; thread < QUERY_THREADS_PER_REPOSITORY; thread++) { - queries.add(() -> { - querying.await(); - try (RepositoryConnection connection = repository.getConnection()) { - for (int query = 0; query < QUERIES_PER_THREAD; query++) { - if (!connection.prepareBooleanQuery(QueryLanguage.SPARQL, EPSG_WITHIN_QUERY).evaluate()) { - throw new AssertionError("Indexed EPSG:" + point.epsgCode() - + " property relation must match"); - } - if (!connection.prepareBooleanQuery(QueryLanguage.SPARQL, point.fiveMetreDistanceQuery()) - .evaluate()) { - throw new AssertionError("EPSG:" + point.epsgCode() + " distance must be five metres"); - } - } - } - return null; - }); + queries.add(epsgQueries(repositories.get(index), PROJECTED_POINTS.get(index), querying)); } } awaitAll(executor.invokeAll(queries, 60, TimeUnit.SECONDS), "concurrent queries"); @@ -334,6 +299,88 @@ private static void assertRepositoriesUseEpsgDataConcurrently(GraphDBRepositoryM assertDependencyThreadsStartedOnce(); } + /** Runs the repository's property-relation and distance queries repeatedly once the barrier opens. */ + private static Callable epsgQueries(Repository repository, ProjectedPoint point, CyclicBarrier querying) { + return () -> { + querying.await(); + try (RepositoryConnection connection = repository.getConnection()) { + for (int query = 0; query < QUERIES_PER_THREAD; query++) { + assertEpsgQueries(connection, point); + } + } + return null; + }; + } + + /** + * Repositories created at the same instant share the published runtime, and each indexes its own projected CRS + * through it, which starts the runtime's dependency threads once. + */ + private static void assertRepositoriesCreatedTogetherShareOneRuntime(GraphDBRepositoryManager manager) + throws Exception { + CyclicBarrier creating = new CyclicBarrier(PROJECTED_POINTS.size()); + ExecutorService executor = Executors.newFixedThreadPool(PROJECTED_POINTS.size()); + List repositories; + try { + List> creations = new ArrayList<>(); + for (ProjectedPoint point : PROJECTED_POINTS) { + creations.add(() -> { + creating.await(); + return create(manager, "created-" + point.epsgCode()); + }); + } + repositories = awaitAll(executor.invokeAll(creations, 60, TimeUnit.SECONDS), + "concurrent repository creations"); + } finally { + executor.shutdownNow(); + } + ClassLoader runtime = runtimeClassLoader(); + for (int index = 0; index < repositories.size(); index++) { + if (servingPlugin(repositories.get(index)).getClass().getClassLoader() != runtime) { + throw new AssertionError("Repositories created together must share the published runtime"); + } + indexEpsgData(repositories.get(index), PROJECTED_POINTS.get(index)); + } + assertDependencyThreadsStartedOnce(); + } + + /** + * The repository whose calls created the runtime and opened the EPSG database is deleted while the other + * repositories query their projected CRSes on worker threads. Those queries keep succeeding, the deleted + * repository's plugin instance is released, and the runtime keeps its one set of dependency threads. + */ + private static void assertFirstEpsgRepositoryDeletedWhileOthersQuery(GraphDBRepositoryManager manager) + throws Exception { + ProjectedPoint firstPoint = PROJECTED_POINTS.getFirst(); + String firstId = "epsg-" + firstPoint.epsgCode(); + Repository first = indexEpsgData(manager, firstId, firstPoint); + List points = PROJECTED_POINTS.subList(1, PROJECTED_POINTS.size()); + List repositories = new ArrayList<>(); + for (ProjectedPoint point : points) { + repositories.add(indexEpsgData(manager, "epsg-" + point.epsgCode(), point)); + } + assertDependencyThreadsStartedOnce(); + ExecutorService executor = Executors.newFixedThreadPool(repositories.size() * QUERY_THREADS_PER_REPOSITORY); + Retired retired; + try { + // The deleting thread joins the barrier, so the deletion starts once every query thread is querying. + CyclicBarrier querying = new CyclicBarrier(repositories.size() * QUERY_THREADS_PER_REPOSITORY + 1); + List> queries = new ArrayList<>(); + for (int index = 0; index < repositories.size(); index++) { + for (int thread = 0; thread < QUERY_THREADS_PER_REPOSITORY; thread++) { + queries.add(executor.submit(epsgQueries(repositories.get(index), points.get(index), querying))); + } + } + querying.await(); + retired = delete(manager, firstId, first); + awaitAll(queries, "queries during repository deletion"); + } finally { + executor.shutdownNow(); + } + awaitCollection(List.of(retired.plugin())); + assertDependencyThreadsStartedOnce(); + } + private static List awaitAll(List> tasks, String work) throws Exception { List results = new ArrayList<>(); for (Future task : tasks) { @@ -362,20 +409,42 @@ private static void assertDependencyThreadsStartedOnce() { } } - private static Retired indexEpsgDataAndDelete(GraphDBRepositoryManager manager, String id) - throws Exception { + private static Retired indexEpsgDataAndDelete(GraphDBRepositoryManager manager, String id) throws Exception { + return delete(manager, id, indexEpsgData(manager, id, PROJECTED_POINTS.getFirst())); + } + + private static Repository indexEpsgData(GraphDBRepositoryManager manager, String id, ProjectedPoint point) { Repository repository = create(manager, id); + indexEpsgData(repository, point); + return repository; + } + + /** Indexes the point's geometries and checks its property relation and distance. */ + private static void indexEpsgData(Repository repository, ProjectedPoint point) { try (RepositoryConnection connection = repository.getConnection()) { - connection.prepareUpdate(INSERT_EPSG_GEOMETRIES).execute(); + connection.prepareUpdate(point.insertGeometries()).execute(); connection.prepareUpdate(ENABLE_GEOSPARQL).execute(); - if (!connection.prepareBooleanQuery(QueryLanguage.SPARQL, EPSG_WITHIN_QUERY).evaluate()) { - throw new AssertionError("Indexed EPSG:3006 property relation must match: " + id); - } - if (!connection.prepareBooleanQuery(QueryLanguage.SPARQL, EPSG_DISTANCE_QUERY).evaluate()) { - throw new AssertionError("EPSG:3006 distance must be five metres: " + id); - } + assertEpsgQueries(connection, point); } - return delete(manager, id, repository); + } + + private static void assertEpsgQueries(RepositoryConnection connection, ProjectedPoint point) { + if (!connection.prepareBooleanQuery(QueryLanguage.SPARQL, EPSG_WITHIN_QUERY).evaluate()) { + throw new AssertionError("Indexed EPSG:" + point.epsgCode() + " property relation must match"); + } + if (!connection.prepareBooleanQuery(QueryLanguage.SPARQL, point.fiveMetreDistanceQuery()).evaluate()) { + throw new AssertionError("EPSG:" + point.epsgCode() + " distance must be five metres"); + } + } + + /** + * A repository created with a deleted repository's ID indexes and queries EPSG data again. Every repository's + * index uses the runtime's one Lucene lock registry, so the deleted repository's index lock must have been released. + */ + private static void assertRecreatedRepositoryIndexesEpsgData(GraphDBRepositoryManager manager) throws Exception { + Retired deleted = indexEpsgDataAndDelete(manager, "recreated"); + indexEpsgData(manager, "recreated", PROJECTED_POINTS.getFirst()); + awaitCollection(List.of(deleted.plugin())); } private static void awaitCollection(List> retired) throws Exception { @@ -418,12 +487,25 @@ private static void assertOtherRepositoryRemainsUsable(GraphDBRepositoryManager } private static void assertFailedInitializationCollected(Path plugins, Path work) throws Exception { + awaitCollection(collectable(failInitializations(plugins, work), 0)); + } + + /** Failed initializations leave the shared runtime usable by a repository created afterwards. */ + private static void assertRuntimeUsableAfterFailedInitializations(GraphDBRepositoryManager manager, Path plugins, + Path work) throws Exception { + failInitializations(plugins, work); + Repository repository = indexEpsgData(manager, "recovered", PROJECTED_POINTS.getFirst()); + assertFunction(repository); + } + + /** Fails initialization before and after geometry initialization, three times each. */ + private static List failInitializations(Path plugins, Path work) throws Exception { List retired = new ArrayList<>(); for (int index = 0; index < 3; index++) { retired.add(failInitialization(plugins.resolve("geosparql-plugin"), work.resolve("failed-" + index), false)); retired.add(failInitialization(plugins.resolve("geosparql-plugin"), work.resolve("failed-early-" + index), true)); } - awaitCollection(collectable(retired, 0)); + return retired; } private static Retired failInitialization(Path pluginDirectory, Path data, diff --git a/src/test/java/com/ontotext/trree/geosparql/GraphDbPluginArchiveIT.java b/src/test/java/com/ontotext/trree/geosparql/GraphDbPluginArchiveIT.java index 1095b068..7d92920f 100644 --- a/src/test/java/com/ontotext/trree/geosparql/GraphDbPluginArchiveIT.java +++ b/src/test/java/com/ontotext/trree/geosparql/GraphDbPluginArchiveIT.java @@ -70,6 +70,11 @@ public void failedInitializationReleasesPluginClassLoader() throws Exception { assertLifecycle("initialization-failure"); } + @Test + public void runtimeRemainsUsableAfterFailedInitializations() throws Exception { + assertLifecycle("initialization-failure-recovery", true); + } + /** * Dependency limitation: Apache SIS 1.6, observed with GraphDB 10.8.12 and 11.0 on Java 21, starts a reference-queue * thread and registers a JVM shutdown hook that keep the class loader that loaded it reachable after GraphDB closes @@ -98,11 +103,21 @@ public void repositoriesUseEpsgDataConcurrently() throws Exception { assertLifecycle("concurrent-epsg-repositories", true); } + @Test + public void firstEpsgRepositoryCanBeDeletedWhileOthersQuery() throws Exception { + assertLifecycle("delete-during-epsg-queries", true); + } + @Test public void entryPointsLoadedTogetherShareOneRuntime() throws Exception { assertLifecycle("concurrent-runtime-creation"); } + @Test + public void repositoriesCreatedTogetherShareOneRuntime() throws Exception { + assertLifecycle("concurrent-repository-creation", true); + } + @Test public void separatePluginInstallationsUseSeparateRuntimes() throws Exception { assertLifecycle("separate-installations"); @@ -113,6 +128,11 @@ public void indexedEpsgRepositoriesShareCrsDataAndReleaseTheirPluginClassLoaders assertLifecycle("indexed-epsg-repositories", true); } + @Test + public void recreatedRepositoryIndexesEpsgData() throws Exception { + assertLifecycle("recreated-repository", true); + } + /** * Dependency limitation: on Java 21.0.11 a URL class loader, and a thread, capture the AccessControlContext of the * call that creates them, including the protection domain of the GraphDB entry point on the stack. The plugin runtime From cbbf729b42b4d734f9b30a4a1103174bf724bf6c Mon Sep 17 00:00:00 2001 From: Edmond Chuc Date: Wed, 21 Oct 2026 19:25:09 +1000 Subject: [PATCH 11/15] fix: log an unshareable GeoSPARQL runtime as an error A per-repository runtime reintroduces the per-repository memory retention and loses shared SIS_DATA EPSG access, so report it at error level where monitoring alerts. --- .../com/ontotext/trree/geosparql/GeoSparqlPluginFacade.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/ontotext/trree/geosparql/GeoSparqlPluginFacade.java b/src/main/java/com/ontotext/trree/geosparql/GeoSparqlPluginFacade.java index 9290958d..b60d17b2 100644 --- a/src/main/java/com/ontotext/trree/geosparql/GeoSparqlPluginFacade.java +++ b/src/main/java/com/ontotext/trree/geosparql/GeoSparqlPluginFacade.java @@ -152,7 +152,7 @@ public void setFingerprint(long fingerprint) { public void initialize(InitReason reason, PluginConnection pluginConnection) { ClassLoader repositoryLoader = GeoSparqlPluginFacade.class.getClassLoader(); if (plugin.getClass().getClassLoader() == repositoryLoader && PluginBase.class.getClassLoader() != repositoryLoader) { - getLogger().warn("GeoSPARQL loads its runtime for each repository because GraphDB's plugin class loader does " + getLogger().error("GeoSPARQL loads its runtime for each repository because GraphDB's plugin class loader does " + "not expose the plugin jars. Repositories cannot share the SIS_DATA EPSG database, and deleted " + "repositories keep their runtime in memory."); } From 211ad19d883923cfd8549f802ebcf1e909cc4a42 Mon Sep 17 00:00:00 2001 From: Edmond Chuc Date: Wed, 21 Oct 2026 19:37:46 +1000 Subject: [PATCH 12/15] docs: describe runtime class loading as rules rather than archive contents --- .../trree/geosparql/PluginRuntimeClassLoader.java | 9 +++++---- .../trree/geosparql/GeoSparqlPluginLifecycleProcess.java | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/ontotext/trree/geosparql/PluginRuntimeClassLoader.java b/src/main/java/com/ontotext/trree/geosparql/PluginRuntimeClassLoader.java index a4fb4330..0b684e6b 100644 --- a/src/main/java/com/ontotext/trree/geosparql/PluginRuntimeClassLoader.java +++ b/src/main/java/com/ontotext/trree/geosparql/PluginRuntimeClassLoader.java @@ -11,10 +11,11 @@ /** * Loads the GeoSPARQL plugin and its bundled dependencies once per JVM. * - *

Classes and resources follow GraphDB's external plugin class loader: bundled jars take precedence over the host, - * so the plugin's Lucene, Jena, Apache SIS and Derby are used even when the host provides other versions. Classes the - * plugin does not bundle, including the GraphDB SDK, RDF4J and SLF4J, come from the host. Service descriptors for - * Lucene and ANTLR come only from the plugin jars, as GraphDB excludes host copies of those for external plugins. + *

Classes and resources follow GraphDB's external plugin class loader: bundled jars take precedence over GraphDB's, + * so the plugin uses its own versions of libraries that GraphDB also provides. Classes that are not in the plugin jars, + * including the GraphDB SDK, RDF4J and SLF4J, come from GraphDB, so both sides of the entry point use the same types. + * Service descriptors for Lucene and ANTLR come only from the plugin jars, as GraphDB excludes host copies of those for + * external plugins. * *

The class loader is published as an MBean so every repository's entry point can find it. It is private to that * lookup and does not join the MBean server's class loader repository. diff --git a/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginLifecycleProcess.java b/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginLifecycleProcess.java index e98cf403..6833758b 100644 --- a/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginLifecycleProcess.java +++ b/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginLifecycleProcess.java @@ -498,7 +498,7 @@ private static void assertRuntimeUsableAfterFailedInitializations(GraphDBReposit assertFunction(repository); } - /** Fails initialization before and after geometry initialization, three times each. */ + /** Fails initialization before and after geometry initialization. */ private static List failInitializations(Path plugins, Path work) throws Exception { List retired = new ArrayList<>(); for (int index = 0; index < 3; index++) { From 87510aada8ed0069ae675de5caf1c8ae15d8339d Mon Sep 17 00:00:00 2001 From: Edmond Chuc Date: Wed, 21 Oct 2026 19:39:00 +1000 Subject: [PATCH 13/15] docs: explain why the plugin loads SIS once per JVM in the SIS limitation test --- .../com/ontotext/trree/geosparql/GraphDbPluginArchiveIT.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/ontotext/trree/geosparql/GraphDbPluginArchiveIT.java b/src/test/java/com/ontotext/trree/geosparql/GraphDbPluginArchiveIT.java index 7d92920f..5eadd921 100644 --- a/src/test/java/com/ontotext/trree/geosparql/GraphDbPluginArchiveIT.java +++ b/src/test/java/com/ontotext/trree/geosparql/GraphDbPluginArchiveIT.java @@ -79,8 +79,8 @@ public void runtimeRemainsUsableAfterFailedInitializations() throws Exception { * Dependency limitation: Apache SIS 1.6, observed with GraphDB 10.8.12 and 11.0 on Java 21, starts a reference-queue * thread and registers a JVM shutdown hook that keep the class loader that loaded it reachable after GraphDB closes * it, unless {@code Shutdown.stop} runs. Once SIS releases a closed class loader without {@code Shutdown.stop}, this - * test fails, and a closed class loader that loaded SIS can be collected. ADR 0022 records why the plugin loads SIS - * once per JVM. + * test fails, and a closed class loader that loaded SIS can be collected. {@link GeoSparqlPluginFacade} therefore + * loads SIS once per JVM, in a class loader that is never closed. */ @Test public void unstoppedSisRetainsClosedPluginClassLoader() throws Exception { From 9eae05aef8999bc70ec5d873e8cf8e5f1fbc1aa6 Mon Sep 17 00:00:00 2001 From: Edmond Chuc Date: Thu, 22 Oct 2026 12:09:22 +1000 Subject: [PATCH 14/15] test: cover Derby's retention of the first EPSG repository class loader Java 25 class loaders and threads capture no access-control context, so the lifecycle tests now require the class loader of the repository whose calls created the runtime to be collected. The class loader of the repository that first opens the EPSG database is still kept: Derby creates the static exceptions exceptionClose and NO_MEM of its embedded connection class when that class initializes, and their captured stack traces hold the entry point class. An expected-failure test covers that Derby limitation in place of the JDK one and fails once that class initializer creates no exception. The stopped-SIS control no longer needs a warm-up entry point, because the class loader whose calls created the runtime is now collected. Verified on OpenJDK 25.0.1 with GraphDB 11.0, because the GraphDB 12 artifacts need credentials for maven.ontotext.com. From a git archive copy of this commit with src/test/java/com/ontotext/trree/geosparql/TestGeoSparqlPluginWithRDFStarData.java removed, which needs RDF4J 6: JAVA_HOME= mvn -o -Dgraphdb.version=11.0-spring-6-TR18 -Dmaven.compiler.release=21 -Dmaven.test.additionalClasspath= -Dtest=NoSuchTest -Dsurefire.failIfNoSpecifiedTests=false package GraphDbPluginArchiveIT passes 18 of 18. The retention chain comes from a heap dump of the indexed-epsg-repositories scenario on that JDK: EmbedConnection.exceptionClose, its Throwable backtrace, the GeoSparqlPluginFacade class, the repository class loader; NO_MEM holds the class the same way. On JDK 25 javap shows no URLClassLoader.acc and no Thread.inheritedAccessControlContext, both present on JDK 21.0.11. --- .../GeoSparqlPluginLifecycleProcess.java | 43 +++++++++++-------- .../geosparql/GraphDbPluginArchiveIT.java | 36 ++++++++-------- 2 files changed, 44 insertions(+), 35 deletions(-) diff --git a/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginLifecycleProcess.java b/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginLifecycleProcess.java index 6833758b..70869bbd 100644 --- a/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginLifecycleProcess.java +++ b/src/test/java/com/ontotext/trree/geosparql/GeoSparqlPluginLifecycleProcess.java @@ -137,8 +137,8 @@ public static void main(String[] arguments) throws Exception { case "isolated-epsg-database" -> assertIsolatedRuntimesCannotShareEpsgDatabase(Path.of(arguments[2])); case "separate-installations" -> assertSeparateInstallationsUseSeparateRuntimes(Path.of(arguments[2])); case "concurrent-runtime-creation" -> assertEntryPointsLoadedTogetherShareOneRuntime(Path.of(arguments[2])); - case "first-loader-retained" -> assertHostRetainsLoader(manager, true); - case "last-loader-retained" -> assertHostRetainsLoader(manager, false); + case "first-epsg-loader-retained" -> assertFirstEpsgLoaderRetained(manager); + case "last-loader-retained" -> assertLastQueriedLoaderRetained(manager); default -> throw new IllegalArgumentException("Unknown lifecycle scenario: " + arguments[0]); } } finally { @@ -195,16 +195,15 @@ private record Retired(WeakReference loader, WeakReference /** * Selects the references that must be collected: every serving plugin and every repository class loader except - * the first and the last {@code keptByHost}. The JDK keeps the class loader of the repository whose calls created - * the plugin runtime, which defines only the GraphDB entry point, and GraphDB keeps the class loader of the last - * repository a thread queried; the {@code first-loader-retained} and {@code last-loader-retained} scenarios cover - * both. + * the first {@code keptByDerby} and the last {@code keptByHost}. Derby keeps the class loader of the repository + * that first opened the EPSG database, and GraphDB keeps the class loader of the last repository a thread queried; + * the {@code first-epsg-loader-retained} and {@code last-loader-retained} scenarios cover both. */ - private static List> collectable(List retired, int keptByHost) { + private static List> collectable(List retired, int keptByDerby, int keptByHost) { List> references = new ArrayList<>(); for (int index = 0; index < retired.size(); index++) { references.add(retired.get(index).plugin()); - if (index > 0 && index < retired.size() - keptByHost) { + if (index >= keptByDerby && index < retired.size() - keptByHost) { references.add(retired.get(index).loader()); } } @@ -245,7 +244,7 @@ private static void assertDeletedRepositoriesCollected(GraphDBRepositoryManager if (currentFunctions != runtimeClassLoader()) { throw new AssertionError("Registered functions must belong to the shared plugin runtime"); } - awaitCollection(collectable(retired, 1)); + awaitCollection(collectable(retired, 0, 1)); } private static void assertIndexedEpsgRepositoriesCollected(GraphDBRepositoryManager manager) throws Exception { @@ -253,7 +252,7 @@ private static void assertIndexedEpsgRepositoriesCollected(GraphDBRepositoryMana for (int index = 0; index < 4; index++) { retired.add(indexEpsgDataAndDelete(manager, "indexed-" + index)); } - awaitCollection(collectable(retired, 1)); + awaitCollection(collectable(retired, 1, 1)); assertDependencyThreadsStartedOnce(); } @@ -487,7 +486,7 @@ private static void assertOtherRepositoryRemainsUsable(GraphDBRepositoryManager } private static void assertFailedInitializationCollected(Path plugins, Path work) throws Exception { - awaitCollection(collectable(failInitializations(plugins, work), 0)); + awaitCollection(collectable(failInitializations(plugins, work), 0, 0)); } /** Failed initializations leave the shared runtime usable by a repository created afterwards. */ @@ -563,8 +562,6 @@ private static PluginBase locatePlugin(Path pluginDirectory) throws Exception { private static void assertUnstoppedSisRetainsPluginLoader(Path plugins) throws Exception { Path pluginDirectory = plugins.resolve("geosparql-plugin"); - // The first entry point creates the runtime class loader, which keeps that repository class loader reachable. - locatePlugin(pluginDirectory); awaitCollection(List.of(initializeAndClose(pluginDirectory, true))); WeakReference unstopped = initializeAndClose(pluginDirectory, false); for (int attempt = 0; attempt < 10; attempt++) { @@ -645,15 +642,25 @@ private static void assertEntryPointsLoadedTogetherShareOneRuntime(Path plugins) } } - private static void assertHostRetainsLoader(GraphDBRepositoryManager manager, boolean first) throws Exception { + private static void assertLastQueriedLoaderRetained(GraphDBRepositoryManager manager) throws Exception { List retired = new ArrayList<>(); for (int index = 0; index < 3; index++) { retired.add(createAndDelete(manager, "repository-" + index, true)); } - awaitCollection(collectable(retired, 1)); - Retired kept = first ? retired.getFirst() : retired.getLast(); - if (kept.loader().refersTo(null)) { - throw new AssertionError("The " + (first ? "first" : "last") + " repository class loader was collected"); + awaitCollection(collectable(retired, 0, 1)); + if (retired.getLast().loader().refersTo(null)) { + throw new AssertionError("The last queried repository class loader was collected"); + } + } + + private static void assertFirstEpsgLoaderRetained(GraphDBRepositoryManager manager) throws Exception { + List retired = new ArrayList<>(); + for (int index = 0; index < 3; index++) { + retired.add(indexEpsgDataAndDelete(manager, "indexed-" + index)); + } + awaitCollection(collectable(retired, 1, 1)); + if (retired.getFirst().loader().refersTo(null)) { + throw new AssertionError("The first EPSG repository class loader was collected"); } } diff --git a/src/test/java/com/ontotext/trree/geosparql/GraphDbPluginArchiveIT.java b/src/test/java/com/ontotext/trree/geosparql/GraphDbPluginArchiveIT.java index 5eadd921..95ff87c6 100644 --- a/src/test/java/com/ontotext/trree/geosparql/GraphDbPluginArchiveIT.java +++ b/src/test/java/com/ontotext/trree/geosparql/GraphDbPluginArchiveIT.java @@ -76,11 +76,11 @@ public void runtimeRemainsUsableAfterFailedInitializations() throws Exception { } /** - * Dependency limitation: Apache SIS 1.6, observed with GraphDB 10.8.12 and 11.0 on Java 21, starts a reference-queue - * thread and registers a JVM shutdown hook that keep the class loader that loaded it reachable after GraphDB closes - * it, unless {@code Shutdown.stop} runs. Once SIS releases a closed class loader without {@code Shutdown.stop}, this - * test fails, and a closed class loader that loaded SIS can be collected. {@link GeoSparqlPluginFacade} therefore - * loads SIS once per JVM, in a class loader that is never closed. + * Dependency limitation: Apache SIS 1.6, observed with GraphDB 10.8.12 and 11.0 on Java 21 and GraphDB 11.0 on + * Java 25, starts a reference-queue thread and registers a JVM shutdown hook that keep the class loader that loaded + * it reachable after GraphDB closes it, unless {@code Shutdown.stop} runs. Once SIS releases a closed class loader + * without {@code Shutdown.stop}, this test fails, and a closed class loader that loaded SIS can be collected. + * {@link GeoSparqlPluginFacade} therefore loads SIS once per JVM, in a class loader that is never closed. */ @Test public void unstoppedSisRetainsClosedPluginClassLoader() throws Exception { @@ -88,10 +88,11 @@ public void unstoppedSisRetainsClosedPluginClassLoader() throws Exception { } /** - * Dependency limitation: an embedded Apache Derby 10.15 engine, observed with GraphDB 10.8.12 and 11.0 on Java 21, - * locks the SIS_DATA EPSG database for its JVM. A Derby engine in a second class loader cannot open it and reports - * {@code XSDB6}, and Apache SIS 1.6 falls back to its built-in CRS subset. Once Derby engines in different class - * loaders can share the database, this test fails, and a second class loader resolves EPSG:3006. + * Dependency limitation: an embedded Apache Derby 10.15 engine, observed with GraphDB 10.8.12 and 11.0 on Java 21 + * and GraphDB 11.0 on Java 25, locks the SIS_DATA EPSG database for its JVM. A Derby engine in a second class + * loader cannot open it and reports {@code XSDB6}, and Apache SIS 1.6 falls back to its built-in CRS subset. Once + * Derby engines in different class loaders can share the database, this test fails, and a second class loader + * resolves EPSG:3006. */ @Test public void isolatedPluginRuntimesCannotShareEpsgDatabase() throws Exception { @@ -134,16 +135,17 @@ public void recreatedRepositoryIndexesEpsgData() throws Exception { } /** - * Dependency limitation: on Java 21.0.11 a URL class loader, and a thread, capture the AccessControlContext of the - * call that creates them, including the protection domain of the GraphDB entry point on the stack. The plugin runtime - * class loader and the dependency threads started by the first repository's calls therefore keep that closed - * repository class loader, which defines only the entry point, reachable once per JVM. Derby's threads do the same - * for the repository that first opens the SIS_DATA EPSG database. Once the JDK no longer retains these class - * loaders, this test fails, and the first repository's class loader can be collected. + * Dependency limitation: Apache Derby 10.15, observed with GraphDB 11.0 on Java 25, creates the static exceptions + * of its embedded connection class, {@code exceptionClose} and {@code NO_MEM}, when that class initializes, and the + * JVM keeps every class on a captured stack trace reachable. Derby initializes the class when the SIS_DATA EPSG + * database is first opened, with the GraphDB entry point of the repository that opened it on the stack, so that + * repository's closed class loader, which defines only the entry point, stays reachable once per JVM. Once that + * class initializer creates no exception, this test fails, and the first EPSG repository's class loader can be + * collected. */ @Test - public void jdkRetainsFirstRepositoryClassLoader() throws Exception { - assertLifecycle("first-loader-retained"); + public void derbyRetainsFirstEpsgRepositoryClassLoader() throws Exception { + assertLifecycle("first-epsg-loader-retained", true); } /** From ab2aa2a34bb360bd41895905bd8199981ddbc30c Mon Sep 17 00:00:00 2001 From: Edmond Chuc Date: Thu, 22 Oct 2026 13:14:42 +1000 Subject: [PATCH 15/15] ci: build on Java 25 to match the compiler release The pom compiles for release 25, which the Java 21 toolchain cannot build. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d58027c..f7970cdb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: uses: actions/setup-java@v5 with: distribution: temurin - java-version: "21" + java-version: "25" cache: maven - name: Run test suite and packaging smoke test