Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 22 additions & 1 deletion docs/geosparql-crs-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
238 changes: 238 additions & 0 deletions src/main/java/com/ontotext/trree/geosparql/GeoSparqlPluginFacade.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
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;
import java.util.function.Supplier;

/**
* GraphDB plugin entry point that forwards each repository's calls to a {@link GeoSparqlPlugin} loaded by one JVM-wide
* {@link PluginRuntimeClassLoader}.
*
* <p>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.
*
* <p>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;
}
try {
ObjectName name = new ObjectName(RUNTIME_CLASS_LOADER_NAME_PREFIX + ",location="
+ ObjectName.quote(pluginJar().toString()));
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<ClassLoader> 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.
*/
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) {
ClassLoader repositoryLoader = GeoSparqlPluginFacade.class.getClassLoader();
if (plugin.getClass().getClassLoader() == repositoryLoader && PluginBase.class.getClassLoader() != repositoryLoader) {
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.");
}
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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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.
*
* <p>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.
*
* <p>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<String> 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<URL> getResources(String name) throws IOException {
List<URL> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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 {
}
Original file line number Diff line number Diff line change
@@ -1 +1 @@
com.ontotext.trree.geosparql.GeoSparqlPlugin
com.ontotext.trree.geosparql.GeoSparqlPluginFacade
Loading
Loading