You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@jena.apache.org by an...@apache.org on 2025/06/09 10:38:30 UTC

(jena) 01/02: GH-3026: spatial index per graph and kryo serialization

This is an automated email from the ASF dual-hosted git repository.

andy pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/jena.git

commit ccc23d7823b5e32e9867054ce8d86989ec77ecf5
Author: Claus Stadler <Ra...@googlemail.com>
AuthorDate: Sun Apr 20 14:03:45 2025 +0200

    GH-3026: spatial index per graph and kryo serialization
---
 .../java/org/apache/jena/sparql/util/Context.java  |   9 +-
 .../main/java/org/apache/jena/system/AutoTxn.java  |  55 ++
 .../src/main/java/org/apache/jena/system/Txn.java  |  44 ++
 .../src/main/java/org/apache/jena/web/HttpSC.java  |   2 +-
 .../test/java/org/apache/jena/system/TestTxn.java  |  77 ++-
 jena-benchmarks/jena-benchmarks-jmh/pom.xml        |   7 +
 .../spatial/index/BenchmarkSpatialIndex.java       | 148 ++++++
 .../geosparql/spatial/index/SpatialIndex510.java   |  94 ++++
 .../spatial/index/SpatialIndexCurrent.java         |  96 ++++
 .../spatial/index/SpatialIndexLifeCycle.java       |  21 +-
 .../jena-benchmarks-shadedJena510/pom.xml          |   5 +
 jena-fuseki2/jena-fuseki-geosparql/pom.xml         |  12 +-
 .../jena/fuseki/geosparql/DatasetOperations.java   |   4 +-
 jena-fuseki2/jena-fuseki-main/pom.xml              |  24 +-
 jena-fuseki2/jena-fuseki-mod-geosparql/.gitignore  |   1 +
 jena-fuseki2/jena-fuseki-mod-geosparql/pom.xml     | 169 ++++++
 .../fuseki/mod/geosparql/FMod_SpatialIndexer.java  | 127 +++++
 .../mod/geosparql/SpatialIndexComputeService.java  | 583 +++++++++++++++++++++
 .../jena/fuseki/mod/geosparql/TaskTracker.java     | 128 +++++
 ...rg.apache.jena.fuseki.main.sys.FusekiAutoModule |   1 +
 .../src/main/resources/spatial-indexer/index.html  | 378 +++++++++++++
 .../mod/geosparql/TestFMod_SpatialIndexer.java     | 165 ++++++
 .../src/test/resources/log4j2-test.properties      |  56 ++
 jena-fuseki2/jena-fuseki-server/pom.xml            |  12 +-
 .../MainPlaygroundFusekiModGeoSparql.java          |  46 ++
 jena-fuseki2/pom.xml                               |   2 +
 jena-geosparql/pom.xml                             |  28 +-
 .../org/apache/jena/geosparql/InitGeoSPARQL.java   |   2 +-
 .../jena/geosparql/assembler/GeoAssembler.java     |  37 +-
 .../jena/geosparql/assembler/VocabGeoSPARQL.java   |   4 +
 .../geosparql/configuration/GeoSPARQLConfig.java   |  33 +-
 .../configuration/GeoSPARQLOperations.java         |  54 +-
 .../jena/geosparql/configuration/SrsException.java |   1 +
 .../geo/topological/GenericPropertyFunction.java   |  16 +-
 .../topological/SpatialObjectGeometryLiteral.java  |   3 -
 .../geosparql/implementation/DimensionInfo.java    |   5 +-
 .../geosparql/implementation/GeometryWrapper.java  |  11 +-
 .../geosparql/implementation/SRSInfoException.java |   1 +
 .../implementation/UnitsConversionException.java   |   1 +
 .../geosparql/implementation/UnitsOfMeasure.java   |   6 +-
 .../implementation/datatype/GMLDatatype.java       |   7 +-
 .../implementation/datatype/GeoJSONDatatype.java   |   6 -
 .../implementation/datatype/WKTDatatype.java       |   5 -
 .../jts/CustomCoordinateSequence.java              |   5 +-
 .../jts/CustomCoordinateSequenceFactory.java       |   2 +
 .../implementation/jts/CustomGeometryFactory.java  |   5 +-
 .../implementation/jts/GeometryTransformation.java |   9 +-
 .../registry/MathTransformRegistry.java            |   4 -
 .../implementation/registry/SRSRegistry.java       |   5 +-
 .../implementation/registry/UnitsURIException.java |   1 +
 .../jena/geosparql/kryo/EnvelopeSerializer.java    |  50 ++
 .../geosparql/kryo/GeometrySerializerJtsWkb.java   |  72 +++
 .../apache/jena/geosparql/kryo/NodeSerializer.java | 346 ++++++++++++
 .../jena/geosparql/kryo/TripleSerializer.java      |  45 ++
 .../jena/geosparql/spatial/SearchEnvelope.java     |  29 +-
 .../jena/geosparql/spatial/SpatialIndex.java       | 519 +-----------------
 .../geosparql/spatial/SpatialIndexException.java   |   1 +
 .../geosparql/spatial/SpatialIndexFindUtils.java   | 185 +++++++
 .../jena/geosparql/spatial/SpatialIndexItem.java   |  24 +-
 .../geosparql/spatial/SpatialIndexStorage.java     |  14 +-
 .../spatial/index/compat/SpatialIndexIo.java       |  66 +++
 .../spatial/index/v1/SpatialIndexAdapterV1.java    |  84 +++
 .../v1/SpatialIndexV1.java}                        |  91 ++--
 .../spatial/index/v2/GeometryGenerator.java        | 175 +++++++
 .../index/v2/KryoRegistratorSpatialIndexV2.java    | 101 ++++
 .../spatial/index/v2/STRtreePerGraph.java          | 193 +++++++
 .../index/v2/STRtreePerGraphSerializer.java        |  50 ++
 .../geosparql/spatial/index/v2/STRtreeUtils.java   | 117 +++++
 .../spatial/index/v2/SpatialIndexHeader.java       |  83 +++
 .../spatial/index/v2/SpatialIndexIoKryo.java       | 207 ++++++++
 .../spatial/index/v2/SpatialIndexPerGraph.java     | 160 ++++++
 .../spatial/index/v2/SpatialIndexUtils.java        | 215 ++++++++
 .../index/v2/SpatialIndexerComputation.java        | 173 ++++++
 .../GenericSpatialPropertyFunction.java            |  16 +-
 .../geosparql/spatial/task/AbortableThread.java    | 199 +++++++
 .../jena/geosparql/spatial/task/TaskControl.java   |  53 ++
 .../task/TaskControlOverAbortableThread.java       | 105 ++++
 .../jts/index/strtree/STRtreeSerializer.java       | 168 ++++++
 .../geosparql/geo/topological/CancelQueryTest.java |   5 +-
 .../topological/GenericPropertyFunctionTest.java   |  93 ++--
 .../geo/topological/QueryRewriteTestData.java      | 107 ++--
 .../SpatialObjectGeometryLiteralTest.java          |  25 +-
 .../AbstractSpatialIndexGraphLookpTest.java        | 199 +++++++
 .../jena/geosparql/spatial/SearchEnvelopeTest.java |  18 +-
 .../geosparql/spatial/SpatialIndexTestData.java    |  44 +-
 .../spatial/TestSpatialIndexGraphLookupV1.java}    |  42 +-
 .../spatial/TestSpatialIndexGraphLookupV2.java}    |  19 +-
 .../spatial/index/v2/SimpleGraphSerializer.java    |  57 ++
 .../spatial/index/v2/SpatialIndexTest.java         | 139 +++++
 .../spatial/index/v2/TestNodeSerializer.java       | 118 +++++
 .../GenericSpatialPropertyFunctionTest.java        |   8 +-
 .../cardinal/EastGeomPFTest.java                   |  19 +-
 .../property_functions/cardinal/EastPFTest.java    |  15 +-
 .../cardinal/WestGeomPFTest.java                   |  16 +-
 .../property_functions/cardinal/WestPFTest.java    |  15 +-
 pom.xml                                            |  80 ++-
 96 files changed, 6127 insertions(+), 950 deletions(-)

diff --git a/jena-arq/src/main/java/org/apache/jena/sparql/util/Context.java b/jena-arq/src/main/java/org/apache/jena/sparql/util/Context.java
index 63fd6e8da0..7a410e5b40 100644
--- a/jena-arq/src/main/java/org/apache/jena/sparql/util/Context.java
+++ b/jena-arq/src/main/java/org/apache/jena/sparql/util/Context.java
@@ -18,10 +18,12 @@
 
 package org.apache.jena.sparql.util;
 
-import java.util.*;
+import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.function.BiConsumer;
+import java.util.function.BiFunction;
 
 import org.apache.jena.atlas.lib.Lib;
 import org.apache.jena.atlas.logging.Log;
@@ -387,6 +389,11 @@ public class Context {
         context.clear();
     }
 
+    /** Atomic compute. */
+    public <V> Object compute(Symbol key, BiFunction<Symbol, Object, V> remappingFunction) {
+        return context.compute(key, remappingFunction);
+    }
+
     @Override
     public String toString() {
         String x = "";
diff --git a/jena-arq/src/main/java/org/apache/jena/system/AutoTxn.java b/jena-arq/src/main/java/org/apache/jena/system/AutoTxn.java
new file mode 100644
index 0000000000..15ff33ee4b
--- /dev/null
+++ b/jena-arq/src/main/java/org/apache/jena/system/AutoTxn.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jena.system;
+
+import org.apache.jena.sparql.core.Transactional;
+
+/**
+ * Transaction wrapper object for use with try-with-resources.
+ * See {@linkplain Txn#autoTxn(Transactional, org.apache.jena.query.TxnType)}.
+ */
+public class AutoTxn
+    implements AutoCloseable
+{
+    private Transactional txn;
+    private boolean isTxnStartedHere;
+
+    /** This constructor is called from {@linkplain Txn#autoTxn(Transactional, org.apache.jena.query.TxnType)}. */
+    AutoTxn(Transactional txn, boolean isTxnStartedHere) {
+        super();
+        this.txn = txn;
+        this.isTxnStartedHere = isTxnStartedHere;
+    }
+
+    public void commit() {
+        if ( txn.isInTransaction() ) {
+            // May have been explicit commit or abort.
+            txn.commit();
+        }
+    }
+
+    @Override
+    public void close() {
+        if ( isTxnStartedHere ) {
+            if ( txn.isInTransaction() )
+                // May have been explicit commit or abort.
+                txn.abort();
+            txn.end();
+        }
+    }
+}
diff --git a/jena-arq/src/main/java/org/apache/jena/system/Txn.java b/jena-arq/src/main/java/org/apache/jena/system/Txn.java
index 603f0fe145..ff51ab85c2 100644
--- a/jena-arq/src/main/java/org/apache/jena/system/Txn.java
+++ b/jena-arq/src/main/java/org/apache/jena/system/Txn.java
@@ -18,8 +18,10 @@
 
 package org.apache.jena.system;
 
+import java.util.Objects;
 import java.util.function.Supplier;
 
+import org.apache.jena.query.ReadWrite;
 import org.apache.jena.query.TxnType;
 import org.apache.jena.sparql.core.Transactional;
 
@@ -137,4 +139,46 @@ public class Txn {
             txn.end();
         } catch (Throwable th2) { th.addSuppressed(th2); }
     }
+
+    /**
+     * Begins a transaction and returns a transaction control instance suitable
+     * for use with try-with-resources blocks.
+     * See {@link #autoTxn(Transactional, TxnType)}.
+     */
+    public static AutoTxn autoTxn(Transactional txn, ReadWrite readWrite) {
+        return autoTxn(txn, TxnType.convert(readWrite));
+    }
+
+    /**
+     * Begins a transaction and returns a {@linkplain AutoTxn} instance suitable
+     * for use with try-with-resources blocks.
+     * This allows for raising checked exceptions in an idiomatic way.
+     * Closing the AutoTxn instance will abort the transaction unless there has
+     * been an explicit call to {@linkplain AutoTxn#commit()}.
+     * <p>
+     *
+     * Usage example:
+     * <pre>
+     * public void myMethod() throws IOException {
+     *   try (AutoTxn txn = Txn.autoTxn(dataset, TxnType.WRITE)) {
+     *     // Do work.
+     *     if (someError) {
+     *         throw new IOException();
+     *     }
+     *     // Explicitly call commit on success.
+     *     txn.commit();
+     *   }
+     * }
+     * </pre>
+     */
+    public static AutoTxn autoTxn(Transactional txn, TxnType txnType) {
+        Objects.requireNonNull(txn);
+        Objects.requireNonNull(txnType);
+        boolean isTxnStartedHere = !txn.isInTransaction();
+        if ( !isTxnStartedHere )
+            TxnOp.compatibleWithPromote(txnType, txn);
+        else
+            txn.begin(txnType);
+        return new AutoTxn(txn, isTxnStartedHere);
+    }
 }
diff --git a/jena-arq/src/main/java/org/apache/jena/web/HttpSC.java b/jena-arq/src/main/java/org/apache/jena/web/HttpSC.java
index cf0607dfe6..2d9a69a527 100644
--- a/jena-arq/src/main/java/org/apache/jena/web/HttpSC.java
+++ b/jena-arq/src/main/java/org/apache/jena/web/HttpSC.java
@@ -306,7 +306,7 @@ public class HttpSC {
       */
      public static Code getCode(int code)
      {
-         if (code <= MAX_CODE)
+         if (code >= 0 && code <= MAX_CODE)
          {
              return codeMap[code];
          }
diff --git a/jena-arq/src/test/java/org/apache/jena/system/TestTxn.java b/jena-arq/src/test/java/org/apache/jena/system/TestTxn.java
index f098f95c07..7f174058cd 100644
--- a/jena-arq/src/test/java/org/apache/jena/system/TestTxn.java
+++ b/jena-arq/src/test/java/org/apache/jena/system/TestTxn.java
@@ -21,6 +21,7 @@ package org.apache.jena.system;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
 
 import org.apache.jena.query.ReadWrite;
 import org.apache.jena.query.TxnType;
@@ -31,7 +32,7 @@ import org.junit.Test ;
 
 public class TestTxn {
 
-    TxnCounter counter = new TxnCounter(0) ; 
+    TxnCounter counter = new TxnCounter(0) ;
 
     @Test public void txn_basic_01() {
         long v1 = counter.get() ;
@@ -42,7 +43,7 @@ public class TestTxn {
     }
 
     @Test public void txn_basic_02() {
-        long x = 
+        long x =
             Txn.calculateRead(counter, () -> {
                 assertEquals("In R, value()", 0, counter.value()) ;
                 assertEquals("In R, get()", 0, counter.get()) ;
@@ -53,7 +54,7 @@ public class TestTxn {
 
     @Test public void txn_basic_03() {
         Txn.executeWrite(counter, counter::inc) ;
-        long x = 
+        long x =
             Txn.calculateRead(counter, () -> {
                 assertEquals("In R, value()", 1, counter.value()) ;
                 assertEquals("In R, get()", 1, counter.get()) ;
@@ -63,7 +64,7 @@ public class TestTxn {
     }
 
     @Test public void txn_basic_05() {
-        long x = 
+        long x =
             Txn.calculateWrite(counter, () -> {
                 counter.inc() ;
                 assertEquals("In W, value()", 0, counter.value()) ;
@@ -74,7 +75,7 @@ public class TestTxn {
     }
 
     @Test public void txn_write_01() {
-        long x = 
+        long x =
             Txn.calculateWrite(counter, () -> {
                 counter.inc() ;
                 assertEquals("In W, value()", 0, counter.value()) ;
@@ -87,7 +88,7 @@ public class TestTxn {
     }
 
     @Test public void txn_write_02() {
-        long x = 
+        long x =
             Txn.calculateWrite(counter, () -> {
                 counter.inc() ;
                 assertEquals("In W, value()", 0, counter.value()) ;
@@ -121,13 +122,13 @@ public class TestTxn {
 
     @Test public void txn_rw_1() {
         assertEquals(0, counter.get()) ;
-        
+
         Txn.executeWrite(counter, () -> {
             counter.inc() ;
             assertEquals("In W, value()", 0, counter.value()) ;
             assertEquals("In W, get()",1, counter.get()) ;
         }) ;
-        
+
         assertEquals("Direct value()", 1, counter.value()) ;
         assertEquals("Direct get()", 1, counter.get()) ;
 
@@ -148,7 +149,7 @@ public class TestTxn {
             assertEquals("In W, value()", 0, counter.value()) ;
             assertEquals("In W, get()",1, counter.get()) ;
         }) ;
-        
+
         assertEquals("Direct value()", 1, counter.get()) ;
         assertEquals("Direct get()", 1, counter.get()) ;
 
@@ -160,11 +161,11 @@ public class TestTxn {
 
     @Test public void txn_continue_1() {
         Txn.executeWrite(counter, ()->counter.set(91)) ;
-        
+
         Txn.executeWrite(counter, ()-> {
             assertEquals("In txn, value()", 91, counter.value()) ;
             assertEquals("In txn, read()", 91, counter.read()) ;
-            counter.inc(); 
+            counter.inc();
             Txn.executeWrite(counter, ()->{
                 assertEquals("In txn, value()", 91, counter.value()) ;
                 assertEquals("In txn, get()", 92, counter.read()) ;
@@ -175,11 +176,11 @@ public class TestTxn {
 
     @Test public void txn_continue_2() {
         Txn.executeWrite(counter, ()->counter.set(91)) ;
-        
+
         Txn.executeWrite(counter, ()-> {
             assertEquals("In txn, value()", 91, counter.value()) ;
             assertEquals("In txn, read()", 91, counter.read()) ;
-            counter.inc(); 
+            counter.inc();
             Txn.executeWrite(counter, ()->{
                 assertEquals("In txn, value()", 91, counter.value()) ;
                 assertEquals("In txn, get()", 92, counter.read()) ;
@@ -195,7 +196,7 @@ public class TestTxn {
     @Test(expected=ExceptionFromTest.class)
     public void txn_exception_01() {
         Txn.executeWrite(counter, counter::inc) ;
-        
+
         Txn.executeWrite(counter, () -> {
             counter.inc() ;
             assertEquals("In W, value()", 1, counter.value()) ;
@@ -207,7 +208,7 @@ public class TestTxn {
     @Test
     public void txn_exception_02() {
         Txn.executeWrite(counter, ()->counter.set(8)) ;
-    
+
         try {
             Txn.executeWrite(counter, () -> {
                 counter.inc();
@@ -327,7 +328,7 @@ public class TestTxn {
             Txn.exec(counter, TxnType.WRITE, ()->{});
         });
     }
-    
+
     @Test(expected=JenaTransactionException.class)
     public void txn_nested_12() {
         Txn.exec(counter, TxnType.READ_PROMOTE, ()->{
@@ -339,7 +340,7 @@ public class TestTxn {
             Txn.exec(counter, TxnType.WRITE, ()->{});
         });
     }
-    
+
     @Test
     public void txn_nested_13() {
         Txn.exec(counter, TxnType.READ_COMMITTED_PROMOTE, ()->{
@@ -378,34 +379,34 @@ public class TestTxn {
     @Test
     public void txn_threaded_02() {
         //Transactional tx = DatasetGraphFactory.createTxnMem();
-        Transactional tx = counter; 
-        
+        Transactional tx = counter;
+
         // Start and enter the W transaction.
         ThreadAction a = ThreadTxn.threadTxnWrite(tx, ()->{});
 
         // ThreadAction started ... in W transaction.
         Txn.exec(tx, TxnType.READ_PROMOTE, ()->{
             // ... have the thread action complete.
-            a.run(); 
+            a.run();
             // Blocks promotion.
             boolean b = tx.promote();
             assertFalse(b);
             assertEquals(ReadWrite.READ, tx.transactionMode());
         });
     }
-    
+
     @Test
     public void txn_threaded_03() {
         Transactional tx = DatasetGraphFactory.createTxnMem();
-        //Transactional tx = counter; 
-        
+        //Transactional tx = counter;
+
         // Start and enter the W transaction.
         ThreadAction a = ThreadTxn.threadTxnWriteAbort(tx, ()->{});
 
         // ThreadAction started ... in W transaction.
         Txn.exec(tx, TxnType.READ_PROMOTE, ()->{
             // ... have the thread action abort..
-            a.run(); 
+            a.run();
             // Does not block promotion.
             boolean b = tx.promote();
             assertTrue(b);
@@ -413,6 +414,32 @@ public class TestTxn {
         });
     }
 
+    @Test public void autoTxn_write_01() {
+        long actualValue;
+        try (AutoTxn txn = Txn.autoTxn(counter, TxnType.WRITE)) {
+            counter.inc() ;
+            assertEquals("In W, value()", 0, counter.value()) ;
+            assertEquals("In W, get()",1, counter.get()) ;
+            actualValue = counter.get() ;
+            counter.commit() ;
+        }
+        long expectedValue = counter.get();
+        assertEquals("Outside W", expectedValue, actualValue) ;
+    }
+
+    @Test public void autoTxn_write_02() {
+        long expectedValue = counter.get();
+        try (AutoTxn txn = Txn.autoTxn(counter, TxnType.WRITE)) {
+            counter.inc() ;
+            assertEquals("In W, value()", 0, counter.value()) ;
+            assertEquals("In W, get()",1, counter.get()) ;
+            // Intermediate value will be reverted.
+            long intermediateValue = counter.get() ;
+            assertNotEquals(expectedValue, intermediateValue);
+            // no commit - auto-close is expected to abort.
+        }
+        long actualValue = counter.get();
+        assertEquals("Outside W", expectedValue, actualValue) ;
+    }
 }
 
- 
\ No newline at end of file
diff --git a/jena-benchmarks/jena-benchmarks-jmh/pom.xml b/jena-benchmarks/jena-benchmarks-jmh/pom.xml
index 958e0bc338..8204b6963b 100644
--- a/jena-benchmarks/jena-benchmarks-jmh/pom.xml
+++ b/jena-benchmarks/jena-benchmarks-jmh/pom.xml
@@ -71,6 +71,13 @@
             <scope>test</scope>
         </dependency>
 
+        <dependency>
+            <groupId>org.apache.jena</groupId>
+            <artifactId>jena-geosparql</artifactId>
+            <version>5.5.0-SNAPSHOT</version>
+            <scope>test</scope>
+        </dependency>
+
         <dependency>
             <groupId>org.apache.jena</groupId>
             <artifactId>jena-arq</artifactId>
diff --git a/jena-benchmarks/jena-benchmarks-jmh/src/test/java/org/apache/jena/geosparql/spatial/index/BenchmarkSpatialIndex.java b/jena-benchmarks/jena-benchmarks-jmh/src/test/java/org/apache/jena/geosparql/spatial/index/BenchmarkSpatialIndex.java
new file mode 100644
index 0000000000..e49902adb7
--- /dev/null
+++ b/jena-benchmarks/jena-benchmarks-jmh/src/test/java/org/apache/jena/geosparql/spatial/index/BenchmarkSpatialIndex.java
@@ -0,0 +1,148 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jena.geosparql.spatial.index;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.jena.geosparql.implementation.vocabulary.SRS_URI;
+import org.apache.jena.geosparql.spatial.index.v2.GeometryGenerator;
+import org.apache.jena.geosparql.spatial.index.v2.GeometryGenerator.GeometryType;
+import org.apache.jena.graph.Graph;
+import org.apache.jena.riot.RDFDataMgr;
+import org.apache.jena.riot.RDFFormat;
+import org.apache.jena.sparql.graph.GraphFactory;
+import org.locationtech.jts.geom.Envelope;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.TearDown;
+import org.openjdk.jmh.results.format.ResultFormatType;
+import org.openjdk.jmh.runner.Runner;
+import org.openjdk.jmh.runner.RunnerException;
+import org.openjdk.jmh.runner.options.ChainedOptionsBuilder;
+import org.openjdk.jmh.runner.options.Options;
+import org.openjdk.jmh.runner.options.OptionsBuilder;
+import org.openjdk.jmh.runner.options.TimeValue;
+
+/**
+ * Benchmarking of the spatial index.
+ * Evaluates the time it takes to load an index from disk.
+ */
+@State(Scope.Benchmark)
+public class BenchmarkSpatialIndex {
+    @Param({
+        "current",
+        "5.1.0"
+    })
+    public String param0_jenaVersion;
+
+    @Param({
+          "1000",
+         "10000",
+        "100000",
+    })
+    public long param1_geometryMixes;
+
+    @Param({
+        // "",
+        SRS_URI.DEFAULT_WKT_CRS84
+    })
+    public String param2_srs;
+
+    private SpatialIndexLifeCycle spatialIndexLifeCycle;
+
+    @Benchmark
+    public void load() throws Exception {
+        spatialIndexLifeCycle.load();
+    }
+
+    @Setup(Level.Trial)
+    public void setupTrial() throws Exception {
+        Envelope envelope = new Envelope(-175, 175, -85, 85);
+        Map<GeometryType, Number> config = GeometryGenerator.createConfig(param1_geometryMixes);
+        Graph graph = GraphFactory.createDefaultGraph();
+        GeometryGenerator.generateGraph(graph, envelope, config);
+
+        String data;
+        RDFFormat fmt = RDFFormat.TURTLE_PRETTY;
+        try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+            RDFDataMgr.write(out, graph, fmt);
+            out.flush();
+            data = new String(out.toByteArray(), StandardCharsets.UTF_8);
+        }
+
+        String srs = param2_srs.isEmpty() ? null : param2_srs;
+
+        switch (param0_jenaVersion) {
+        case "current":
+            spatialIndexLifeCycle = SpatialIndexCurrent.setup(data, envelope, srs, false);
+            break;
+        case "5.1.0":
+            spatialIndexLifeCycle = SpatialIndex510.setup(data, envelope, srs, false);
+            break;
+        default:
+            throw new RuntimeException("No task registered for this jena version:" + param0_jenaVersion);
+        }
+
+        spatialIndexLifeCycle.init();
+        spatialIndexLifeCycle.findSrs();
+        spatialIndexLifeCycle.build();
+    }
+
+    @TearDown(Level.Trial)
+    public void tearDownTrial() throws Exception {
+        spatialIndexLifeCycle.close();
+    }
+
+    public static ChainedOptionsBuilder getDefaults(Class<?> c) {
+        return new OptionsBuilder()
+                // Specify which benchmarks to run.
+                // You can be more specific if you'd like to run only one benchmark per test.
+                .include(c.getName())
+                // Set the following options as needed
+                .mode(Mode.AverageTime)
+                .timeUnit(TimeUnit.SECONDS)
+                .warmupTime(TimeValue.NONE)
+                .warmupIterations(5)
+                .measurementIterations(5)
+                .measurementTime(TimeValue.NONE)
+                .threads(1)
+                .forks(1)
+                .shouldFailOnError(true)
+                .shouldDoGC(true)
+                //.jvmArgs("-XX:+UnlockDiagnosticVMOptions", "-XX:+PrintInlining")
+                .jvmArgs("-Xmx8G")
+                //.addProfiler(WinPerfAsmProfiler.class)
+                .resultFormat(ResultFormatType.JSON)
+                .result(c.getSimpleName() + "_" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + ".json");
+    }
+
+    public static void main(String[] args) throws RunnerException {
+        Options opt = getDefaults(BenchmarkSpatialIndex.class).build();
+        new Runner(opt).run();
+    }
+}
diff --git a/jena-benchmarks/jena-benchmarks-jmh/src/test/java/org/apache/jena/geosparql/spatial/index/SpatialIndex510.java b/jena-benchmarks/jena-benchmarks-jmh/src/test/java/org/apache/jena/geosparql/spatial/index/SpatialIndex510.java
new file mode 100644
index 0000000000..69243681b0
--- /dev/null
+++ b/jena-benchmarks/jena-benchmarks-jmh/src/test/java/org/apache/jena/geosparql/spatial/index/SpatialIndex510.java
@@ -0,0 +1,94 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jena.geosparql.spatial.index;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.apache.shadedJena510.geosparql.configuration.GeoSPARQLOperations;
+import org.apache.shadedJena510.geosparql.spatial.SpatialIndex;
+import org.apache.shadedJena510.geosparql.spatial.SpatialIndexException;
+import org.apache.shadedJena510.query.Dataset;
+import org.apache.shadedJena510.riot.Lang;
+import org.apache.shadedJena510.riot.RDFParserBuilder;
+import org.junit.Assert;
+import org.locationtech.jts.geom.Envelope;
+
+public class SpatialIndex510
+    implements SpatialIndexLifeCycle
+{
+    protected Dataset ds;
+    protected Envelope envelope;
+    protected String srs;
+    protected boolean validate;
+
+    public SpatialIndex510(Dataset ds, Envelope envelope, String srs, boolean validate) {
+        super();
+        this.ds = ds;
+        this.envelope = envelope;
+        this.validate = validate;
+        this.srs = srs;
+    }
+
+    public static SpatialIndex510 setup(String data, Envelope envelope, String srs, boolean validate) throws SpatialIndexException {
+        Dataset ds = RDFParserBuilder.create().fromString(data).lang(Lang.TURTLE).toDataset();
+        return new SpatialIndex510(ds, envelope, srs, validate);
+    }
+
+    protected Path indexFile;
+    protected String finalSrs = null;
+    protected SpatialIndex indexA;
+    protected SpatialIndex indexB;
+
+    @Override
+    public void init() {
+        ds.getContext().remove(SpatialIndex.SPATIAL_INDEX_SYMBOL);
+    }
+
+    @Override
+    public void findSrs() {
+        finalSrs = srs == null
+            ? GeoSPARQLOperations.findModeSRS(ds)
+            : srs;
+    }
+
+    @Override
+    public void build() throws Exception {
+        indexFile = Files.createTempFile("jena-", ".spatial-index");
+        Files.deleteIfExists(indexFile); // buildSpatialIndex in v1 will attempt to load the file first
+
+        indexA = SpatialIndex.buildSpatialIndex(ds, finalSrs, indexFile.toFile());
+    }
+
+    @Override
+    public void load() throws Exception {
+        indexB = SpatialIndex.load(indexFile.toFile());
+    }
+
+    @Override
+    public void close() throws Exception {
+        Files.deleteIfExists(indexFile);
+
+        if (validate) {
+            int itemCountA = indexA.query(envelope).size();
+            int itemCountB = indexB.query(envelope).size();
+            // Assert.assertTrue(itemCountA > 0);
+            Assert.assertEquals(itemCountA, itemCountB);
+        }
+    }
+}
diff --git a/jena-benchmarks/jena-benchmarks-jmh/src/test/java/org/apache/jena/geosparql/spatial/index/SpatialIndexCurrent.java b/jena-benchmarks/jena-benchmarks-jmh/src/test/java/org/apache/jena/geosparql/spatial/index/SpatialIndexCurrent.java
new file mode 100644
index 0000000000..3817c467d3
--- /dev/null
+++ b/jena-benchmarks/jena-benchmarks-jmh/src/test/java/org/apache/jena/geosparql/spatial/index/SpatialIndexCurrent.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jena.geosparql.spatial.index;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.apache.jena.geosparql.configuration.GeoSPARQLOperations;
+import org.apache.jena.geosparql.spatial.SpatialIndexException;
+import org.apache.jena.geosparql.spatial.index.v2.SpatialIndexIoKryo;
+import org.apache.jena.geosparql.spatial.index.v2.SpatialIndexPerGraph;
+import org.apache.jena.geosparql.spatial.index.v2.SpatialIndexUtils;
+import org.apache.jena.query.DatasetFactory;
+import org.apache.jena.riot.Lang;
+import org.apache.jena.riot.RDFParserBuilder;
+import org.apache.jena.sparql.core.DatasetGraph;
+import org.junit.Assert;
+import org.locationtech.jts.geom.Envelope;
+
+public class SpatialIndexCurrent
+    implements SpatialIndexLifeCycle
+{
+    protected DatasetGraph dsg;
+    protected Envelope envelope;
+    protected String srs;
+    protected boolean validate;
+
+    public SpatialIndexCurrent(DatasetGraph dsg, Envelope envelope, String srs, boolean validate) {
+        super();
+        this.dsg = dsg;
+        this.envelope = envelope;
+        this.validate = validate;
+        this.srs = srs;
+    }
+
+    public static SpatialIndexCurrent setup(String data, Envelope envelope, String srs, boolean validate) throws SpatialIndexException {
+        DatasetGraph dsg = RDFParserBuilder.create().fromString(data).lang(Lang.TURTLE).toDatasetGraph();
+        return new SpatialIndexCurrent(dsg, envelope, srs, validate);
+    }
+
+    protected Path indexFile;
+    protected String finalSrs = null;
+    protected SpatialIndexPerGraph indexA;
+    protected SpatialIndexPerGraph indexB;
+
+    @Override
+    public void init() {
+        SpatialIndexUtils.setSpatialIndex(dsg.getContext(), null);
+    }
+
+    @Override
+    public void findSrs() {
+        finalSrs = srs == null
+            ? GeoSPARQLOperations.findModeSRS(DatasetFactory.wrap(dsg))
+            : srs;
+    }
+
+    @Override
+    public void build() throws Exception {
+        indexFile = Files.createTempFile("jena-", ".spatial-index");
+        indexA = (SpatialIndexPerGraph)SpatialIndexUtils.buildSpatialIndex(dsg, finalSrs);
+        SpatialIndexIoKryo.save(indexFile, indexA);
+    }
+
+    @Override
+    public void load() throws Exception {
+        indexB = SpatialIndexIoKryo.load(indexFile);
+    }
+
+    @Override
+    public void close() throws Exception {
+        Files.deleteIfExists(indexFile);
+
+        if (validate) {
+            int itemCountA = indexA.query(envelope, null).size();
+            int itemCountB = indexB.query(envelope, null).size();
+            // Assert.assertTrue(itemCountA > 0);
+            Assert.assertEquals(itemCountA, itemCountB);
+        }
+    }
+}
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/configuration/SrsException.java b/jena-benchmarks/jena-benchmarks-jmh/src/test/java/org/apache/jena/geosparql/spatial/index/SpatialIndexLifeCycle.java
similarity index 74%
copy from jena-geosparql/src/main/java/org/apache/jena/geosparql/configuration/SrsException.java
copy to jena-benchmarks/jena-benchmarks-jmh/src/test/java/org/apache/jena/geosparql/spatial/index/SpatialIndexLifeCycle.java
index ebc7d5fb16..443e8885c1 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/configuration/SrsException.java
+++ b/jena-benchmarks/jena-benchmarks-jmh/src/test/java/org/apache/jena/geosparql/spatial/index/SpatialIndexLifeCycle.java
@@ -15,19 +15,12 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.jena.geosparql.configuration;
+package org.apache.jena.geosparql.spatial.index;
 
-/**
- *
- *
- */
-public class SrsException extends RuntimeException {
-
-    public SrsException(String msg) {
-        super(msg);
-    }
-
-    public SrsException(String msg, Throwable cause) {
-        super(msg, cause);
-    }
+public interface SpatialIndexLifeCycle {
+    void init()    throws Exception;
+    void findSrs() throws Exception;
+    void build()   throws Exception;
+    void load()    throws Exception;
+    void close()   throws Exception;
 }
diff --git a/jena-benchmarks/jena-benchmarks-shadedJena510/pom.xml b/jena-benchmarks/jena-benchmarks-shadedJena510/pom.xml
index ee141a3d9b..28fa89c783 100644
--- a/jena-benchmarks/jena-benchmarks-shadedJena510/pom.xml
+++ b/jena-benchmarks/jena-benchmarks-shadedJena510/pom.xml
@@ -71,6 +71,11 @@
             <artifactId>jena-arq</artifactId>
             <version>5.1.0</version>
         </dependency>
+        <dependency>
+            <groupId>org.apache.jena</groupId>
+            <artifactId>jena-geosparql</artifactId>
+            <version>5.1.0</version>
+        </dependency>
     </dependencies>
 
     <build>
diff --git a/jena-fuseki2/jena-fuseki-geosparql/pom.xml b/jena-fuseki2/jena-fuseki-geosparql/pom.xml
index 44102f7d35..2d3dcf545a 100644
--- a/jena-fuseki2/jena-fuseki-geosparql/pom.xml
+++ b/jena-fuseki2/jena-fuseki-geosparql/pom.xml
@@ -25,7 +25,7 @@
       <artifactId>jena-fuseki</artifactId>
       <version>5.5.0-SNAPSHOT</version>
     </parent>
-    
+
     <description>GeoSPARQL with Fuseki</description>
 
     <properties>
@@ -71,20 +71,22 @@
         <artifactId>jul-to-slf4j</artifactId>
       </dependency>
 
+      <!-- Put in to ensure the dependency isn't optional. -->
       <dependency>
         <groupId>org.apache.logging.log4j</groupId>
-        <artifactId>log4j-slf4j2-impl</artifactId>
+        <artifactId>log4j-api</artifactId>
       </dependency>
 
-      <!-- Put in to ensure the dependency isn't optional. -->
       <dependency>
         <groupId>org.apache.logging.log4j</groupId>
-        <artifactId>log4j-api</artifactId>
+        <artifactId>log4j-core</artifactId>
+        <scope>test</scope>
       </dependency>
 
       <dependency>
         <groupId>org.apache.logging.log4j</groupId>
-        <artifactId>log4j-core</artifactId>
+        <artifactId>log4j-slf4j2-impl</artifactId>
+        <scope>test</scope>
       </dependency>
 
       <dependency>
diff --git a/jena-fuseki2/jena-fuseki-geosparql/src/main/java/org/apache/jena/fuseki/geosparql/DatasetOperations.java b/jena-fuseki2/jena-fuseki-geosparql/src/main/java/org/apache/jena/fuseki/geosparql/DatasetOperations.java
index b467c1ed58..57a6d02302 100644
--- a/jena-fuseki2/jena-fuseki-geosparql/src/main/java/org/apache/jena/fuseki/geosparql/DatasetOperations.java
+++ b/jena-fuseki2/jena-fuseki-geosparql/src/main/java/org/apache/jena/fuseki/geosparql/DatasetOperations.java
@@ -229,10 +229,10 @@ public class DatasetOperations {
         if (!isEmpty) {
             if (argsConfig.getSpatialIndexFile() != null) {
                 File spatialIndexFile = argsConfig.getSpatialIndexFile();
-                GeoSPARQLConfig.setupSpatialIndex(dataset, spatialIndexFile);
+                GeoSPARQLConfig.setupSpatialIndex(dataset, spatialIndexFile.toPath());
             } else if (argsConfig.isTDBFileSetup()) {
                 File spatialIndexFile = new File(argsConfig.getTdbFile(), SPATIAL_INDEX_FILE);
-                GeoSPARQLConfig.setupSpatialIndex(dataset, spatialIndexFile);
+                GeoSPARQLConfig.setupSpatialIndex(dataset, spatialIndexFile.toPath());
             } else {
                 GeoSPARQLConfig.setupSpatialIndex(dataset);
             }
diff --git a/jena-fuseki2/jena-fuseki-main/pom.xml b/jena-fuseki2/jena-fuseki-main/pom.xml
index e3c17baa7f..a8fd7e53b5 100644
--- a/jena-fuseki2/jena-fuseki-main/pom.xml
+++ b/jena-fuseki2/jena-fuseki-main/pom.xml
@@ -26,7 +26,7 @@
     <groupId>org.apache.jena</groupId>
     <artifactId>jena-fuseki</artifactId>
     <version>5.5.0-SNAPSHOT</version>
-  </parent> 
+  </parent>
 
   <packaging>jar</packaging>
 
@@ -34,7 +34,7 @@
     <automatic.module.name>org.apache.jena.fuseki.main</automatic.module.name>
   </properties>
 
-  <dependencies> 
+  <dependencies>
 
     <dependency>
       <groupId>org.apache.jena</groupId>
@@ -92,7 +92,7 @@
       <groupId>org.eclipse.jetty</groupId>
       <artifactId>jetty-security</artifactId>
     </dependency>
-    
+
     <!-- For parsing Jetty configuration files -->
     <dependency>
       <groupId>org.eclipse.jetty</groupId>
@@ -104,13 +104,13 @@
       <groupId>org.apache.shiro</groupId>
       <artifactId>shiro-core</artifactId>
     </dependency>
-    
+
     <dependency>
       <groupId>org.apache.shiro</groupId>
       <artifactId>shiro-web</artifactId>
       <classifier>jakarta</classifier>
     </dependency>
-    
+
     <!-- Testing -->
     <dependency>
       <groupId>org.junit.vintage</groupId>
@@ -129,14 +129,14 @@
       <artifactId>junit-jupiter-params</artifactId>
       <scope>test</scope>
     </dependency>
-    
+
     <dependency>
       <groupId>org.junit.platform</groupId>
       <artifactId>junit-platform-suite</artifactId>
       <scope>test</scope>
     </dependency>
 
-<!-- 
+<!--
    Only needed if the Junit5 artifacts don't pick the right runner
    <dependency>
       <groupId>junit</groupId>
@@ -144,7 +144,7 @@
       <version>${ver.junit}</version>
       <scope>test</scope>
     </dependency>
--->    
+-->
 
 
     <dependency>
@@ -172,7 +172,7 @@
       <scope>test</scope>
       <type>pom</type>
     </dependency>
-    
+
   </dependencies>
 
   <build>
@@ -198,11 +198,11 @@
       <plugin>
         <groupId>org.apache.maven.plugins</groupId>
         <artifactId>maven-source-plugin</artifactId>
-        <executions> 
+        <executions>
           <execution>
-            <id>attach-sources-test</id> 
+            <id>attach-sources-test</id>
             <goals>
-              <goal>test-jar-no-fork</goal> 
+              <goal>test-jar-no-fork</goal>
             </goals>
           </execution>
         </executions>
diff --git a/jena-fuseki2/jena-fuseki-mod-geosparql/.gitignore b/jena-fuseki2/jena-fuseki-mod-geosparql/.gitignore
new file mode 100644
index 0000000000..b83d22266a
--- /dev/null
+++ b/jena-fuseki2/jena-fuseki-mod-geosparql/.gitignore
@@ -0,0 +1 @@
+/target/
diff --git a/jena-fuseki2/jena-fuseki-mod-geosparql/pom.xml b/jena-fuseki2/jena-fuseki-mod-geosparql/pom.xml
new file mode 100644
index 0000000000..4dce750138
--- /dev/null
+++ b/jena-fuseki2/jena-fuseki-mod-geosparql/pom.xml
@@ -0,0 +1,169 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+   Licensed to the Apache Software Foundation (ASF) under one or more
+   contributor license agreements.  See the NOTICE file distributed with
+   this work for additional information regarding copyright ownership.
+   The ASF licenses this file to You under the Apache License, Version 2.0
+   (the "License"); you may not use this file except in compliance with
+   the License.  You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+-->
+
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+  <parent>
+    <groupId>org.apache.jena</groupId>
+    <artifactId>jena-fuseki</artifactId>
+    <version>5.5.0-SNAPSHOT</version>
+  </parent>
+
+  <artifactId>jena-fuseki-mod-geosparql</artifactId>
+
+  <name>Apache Jena - Fuseki MOD - GeoSPARQL</name>
+  <description>GeoSPARQL Module for Fuseki</description>
+
+  <properties>
+    <automatic.module.name>org.apache.jena.fuseki.mod.geosparql</automatic.module.name>
+  </properties>
+
+  <dependencies>
+
+    <dependency>
+      <groupId>org.apache.jena</groupId>
+      <artifactId>jena-geosparql</artifactId>
+      <version>5.5.0-SNAPSHOT</version>
+    </dependency>
+
+    <dependency>
+      <groupId>org.apache.jena</groupId>
+      <artifactId>jena-fuseki-main</artifactId>
+      <version>5.5.0-SNAPSHOT</version>
+      <!-- Note: the 'bundle' profile sets this dependency's scope to 'provided'. -->
+    </dependency>
+
+    <!-- Testing -->
+    <dependency>
+      <groupId>org.junit.vintage</groupId>
+      <artifactId>junit-vintage-engine</artifactId>
+      <scope>test</scope>
+    </dependency>
+
+    <dependency>
+      <groupId>org.junit.jupiter</groupId>
+      <artifactId>junit-jupiter</artifactId>
+      <scope>test</scope>
+    </dependency>
+
+    <dependency>
+      <groupId>org.junit.jupiter</groupId>
+      <artifactId>junit-jupiter-params</artifactId>
+      <scope>test</scope>
+    </dependency>
+
+    <dependency>
+      <groupId>org.junit.platform</groupId>
+      <artifactId>junit-platform-suite</artifactId>
+      <scope>test</scope>
+    </dependency>
+
+    <dependency>
+      <groupId>org.apache.logging.log4j</groupId>
+      <artifactId>log4j-core</artifactId>
+      <scope>test</scope>
+    </dependency>
+
+    <dependency>
+      <groupId>org.apache.logging.log4j</groupId>
+      <artifactId>log4j-slf4j2-impl</artifactId>
+      <scope>test</scope>
+    </dependency>
+
+    <dependency>
+      <groupId>org.seleniumhq.selenium</groupId>
+      <artifactId>selenium-java</artifactId>
+      <scope>test</scope>
+    </dependency>
+
+    <dependency>
+      <groupId>io.github.bonigarcia</groupId>
+      <artifactId>webdrivermanager</artifactId>
+      <scope>test</scope>
+    </dependency>
+  </dependencies>
+
+  <profiles>
+    <profile>
+      <id>bundle</id>
+      <!-- Profile to create a shaded jar suitable for use as an extra drop-in jar for Fuseki. -->
+      <dependencies>
+        <dependency>
+          <groupId>org.apache.jena</groupId>
+          <artifactId>jena-fuseki-main</artifactId>
+          <version>5.5.0-SNAPSHOT</version>
+          <scope>provided</scope>
+        </dependency>
+
+	<!-- For the bundle, jena-geosparql (compile) declared in this pom is needed.
+             However, an override is needed for its transitive dependency on jena-arq. -->
+        <dependency>
+          <groupId>org.apache.jena</groupId>
+          <artifactId>jena-arq</artifactId>
+          <version>5.5.0-SNAPSHOT</version>
+          <scope>provided</scope>
+        </dependency>
+      </dependencies>
+      <build>
+        <plugins>
+          <plugin>
+            <groupId>org.apache.maven.plugins</groupId>
+            <artifactId>maven-shade-plugin</artifactId>
+            <configuration>
+              <transformers>
+                <transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer" />
+                <transformer implementation="org.apache.maven.plugins.shade.resource.ApacheLicenseResourceTransformer" />
+                <transformer implementation="org.apache.maven.plugins.shade.resource.ApacheNoticeResourceTransformer">
+                  <addHeader>false</addHeader>
+                </transformer>
+              </transformers>
+              <filters>
+                <filter>
+                  <artifact>*:*</artifact>
+                  <excludes>
+                    <!-- Some jars are signed but shading breaks
+                    that. Don't include signing files. -->
+                    <exclude>META-INF/*.SF</exclude>
+                    <exclude>META-INF/*.DSA</exclude>
+                    <exclude>META-INF/*.RSA</exclude>
+                    <exclude>META-INF/DEPENDENCIES</exclude>
+                    <exclude>META-INF/MANIFEST.MF</exclude>
+                    <exclude>**/module-info.class</exclude>
+                    <exclude>META-INF/versions/9/OSGI-INF/MANIFEST.MF</exclude>
+                  </excludes>
+                </filter>
+              </filters>
+            </configuration>
+            <executions>
+              <execution>
+                <phase>package</phase>
+                <!--<phase /><!- - Switch off -->
+                <goals>
+                  <goal>shade</goal>
+                </goals>
+              </execution>
+            </executions>
+          </plugin>
+        </plugins>
+      </build>
+    </profile>
+  </profiles>
+
+</project>
diff --git a/jena-fuseki2/jena-fuseki-mod-geosparql/src/main/java/org/apache/jena/fuseki/mod/geosparql/FMod_SpatialIndexer.java b/jena-fuseki2/jena-fuseki-mod-geosparql/src/main/java/org/apache/jena/fuseki/mod/geosparql/FMod_SpatialIndexer.java
new file mode 100644
index 0000000000..d80e9c41d9
--- /dev/null
+++ b/jena-fuseki2/jena-fuseki-mod-geosparql/src/main/java/org/apache/jena/fuseki/mod/geosparql/FMod_SpatialIndexer.java
@@ -0,0 +1,127 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jena.fuseki.mod.geosparql;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+import org.apache.jena.fuseki.Fuseki;
+import org.apache.jena.fuseki.auth.AuthPolicy;
+import org.apache.jena.fuseki.main.FusekiServer;
+import org.apache.jena.fuseki.main.sys.FusekiAutoModule;
+import org.apache.jena.fuseki.server.DataAccessPoint;
+import org.apache.jena.fuseki.server.DataAccessPointRegistry;
+import org.apache.jena.fuseki.server.DataService;
+import org.apache.jena.fuseki.server.Endpoint;
+import org.apache.jena.fuseki.server.Operation;
+import org.apache.jena.rdf.model.Model;
+
+public class FMod_SpatialIndexer implements FusekiAutoModule {
+
+    private Operation spatialOperation = null;
+
+    public Operation getOperation() {
+        if (spatialOperation == null) {
+            synchronized (this) {
+                if (spatialOperation == null) {
+                    Fuseki.configLog.info(name() + ": Add spatial indexer operation into global registry.");
+                    spatialOperation = Operation.alloc("http://org.apache.jena/spatial-index-service",
+                            "spatial-indexer",
+                            "Spatial index computation service");
+                }
+            }
+        }
+        return spatialOperation;
+    }
+
+    public FMod_SpatialIndexer() {
+        super();
+    }
+
+    @Override
+    public String name() {
+        return "Spatial Indexer";
+    }
+
+    @Override
+    public void start() {
+    }
+
+    @Override
+    public void prepare(FusekiServer.Builder builder, Set<String> datasetNames, Model configModel) {
+        Fuseki.configLog.info(name() + ": Registering spatial index operation and servlet.");
+        Operation op = getOperation();
+        builder.registerOperation(op, new SpatialIndexComputeService());
+    }
+
+    /**
+     * The spatial indexer endpoint is created for every SPARQL update endpoint.
+     * The update endpoint's auth policy is inherited.
+     */
+    @Override
+    public void configured(FusekiServer.Builder serverBuilder, DataAccessPointRegistry dapRegistry, Model configModel) {
+        FusekiAutoModule.super.configured(serverBuilder, dapRegistry, configModel);
+
+        List<DataAccessPoint> newDataAccessPoints = new ArrayList<>();
+        // Register the spatial indexer for each update endpoint and inherit its auth policy.
+        for (DataAccessPoint dap : dapRegistry.accessPoints()) {
+            String dapName = dap.getName();
+            String logPrefix = name() + " - " + dapName + ": ";
+
+            DataService dataService = dap.getDataService();
+            List<Endpoint> updateEndpoints = Optional.ofNullable(dataService.getEndpoints(Operation.Update))
+                .orElse(List.of());
+
+            List<Endpoint> spatialEndpoints = new ArrayList<>();
+            for (Endpoint updateEndpoint : updateEndpoints) {
+                String updateEndpointName = updateEndpoint.getName();
+                String spatialEndpointName = updateEndpointName + "-spatial";
+                AuthPolicy authPolicy = updateEndpoint.getAuthPolicy();
+
+                Fuseki.configLog.info(logPrefix + "Registering spatial indexer endpoint: " + spatialEndpointName);
+
+                Operation op = getOperation();
+                Endpoint spatialEndpoint = Endpoint.create()
+                        .operation(op)
+                        .endpointName(spatialEndpointName)
+                        .authPolicy(authPolicy)
+                        .build();
+
+                spatialEndpoints.add(spatialEndpoint);
+            }
+
+            // Create new DataService based on existing one with the spatial indexer endpoints attached.
+            DataService.Builder dSrvBuilder = DataService.newBuilder(dataService);
+            spatialEndpoints.forEach(dSrvBuilder::addEndpoint);
+            DataService newDataService = dSrvBuilder.build();
+            newDataAccessPoints.add(new DataAccessPoint(dapName, newDataService));
+        }
+
+        // "replace" each DataAccessPoint
+        newDataAccessPoints.forEach(dap -> {
+            dapRegistry.remove(dap.getName());
+            dapRegistry.register(dap);
+        });
+    }
+
+    @Override
+    public void serverAfterStarting(FusekiServer server) {
+    }
+}
diff --git a/jena-fuseki2/jena-fuseki-mod-geosparql/src/main/java/org/apache/jena/fuseki/mod/geosparql/SpatialIndexComputeService.java b/jena-fuseki2/jena-fuseki-mod-geosparql/src/main/java/org/apache/jena/fuseki/mod/geosparql/SpatialIndexComputeService.java
new file mode 100644
index 0000000000..1f96e5ba8f
--- /dev/null
+++ b/jena-fuseki2/jena-fuseki-mod-geosparql/src/main/java/org/apache/jena/fuseki/mod/geosparql/SpatialIndexComputeService.java
@@ -0,0 +1,583 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.jena.fuseki.mod.geosparql;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.PrintWriter;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentHashMap.KeySetView;
+import java.util.stream.Stream;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.lang3.exception.ExceptionUtils;
+import org.apache.jena.atlas.iterator.Iter;
+import org.apache.jena.atlas.lib.DateTimeUtils;
+import org.apache.jena.fuseki.FusekiException;
+import org.apache.jena.fuseki.servlets.BaseActionREST;
+import org.apache.jena.fuseki.servlets.HttpAction;
+import org.apache.jena.geosparql.spatial.SpatialIndex;
+import org.apache.jena.geosparql.spatial.SpatialIndexException;
+import org.apache.jena.geosparql.spatial.index.v2.STRtreePerGraph;
+import org.apache.jena.geosparql.spatial.index.v2.SpatialIndexIoKryo;
+import org.apache.jena.geosparql.spatial.index.v2.SpatialIndexPerGraph;
+import org.apache.jena.geosparql.spatial.index.v2.SpatialIndexUtils;
+import org.apache.jena.geosparql.spatial.index.v2.SpatialIndexerComputation;
+import org.apache.jena.geosparql.spatial.task.AbortableThread;
+import org.apache.jena.geosparql.spatial.task.TaskControl;
+import org.apache.jena.geosparql.spatial.task.TaskControlOverAbortableThread;
+import org.apache.jena.graph.Node;
+import org.apache.jena.graph.NodeFactory;
+import org.apache.jena.riot.WebContent;
+import org.apache.jena.riot.web.HttpNames;
+import org.apache.jena.sparql.core.DatasetGraph;
+import org.apache.jena.sparql.core.Quad;
+import org.apache.jena.sparql.util.Context;
+import org.apache.jena.system.Txn;
+import org.apache.jena.web.HttpSC;
+import org.locationtech.jts.index.strtree.STRtree;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.reflect.TypeToken;
+
+import jakarta.servlet.AsyncContext;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+/**
+ * Spatial index (re)computation service.
+ *
+ * Supports two types of tasks that are executed concurrently. For a given data set, only a single
+ * task can be active at a given time. The status of the most recent task can be queried via the
+ * rest API. Task execution also broadcasts start/abort/termination events via server side events
+ * (SSE).
+ *
+ * <ul>
+ *   <li>Updating/replacing the graphs of a spatial index.</li>
+ *   <li>Removal of graphs from a spatial index that are absent in the corresponding data set.</li>
+ * </ul>
+ */
+public class SpatialIndexComputeService extends BaseActionREST {
+    private static final Logger logger = LoggerFactory.getLogger(SpatialIndexComputeService.class);
+
+    private static Gson gson = new Gson();
+
+    /** Registered clients listening to server side events for indexer status updates. */
+    private KeySetView<AsyncContext, ?> eventListeners = ConcurrentHashMap.newKeySet();
+
+    public SpatialIndexComputeService() {}
+
+    private static List<Node> extractGraphs(DatasetGraph dsg, HttpAction action) {
+        String uris = action.getRequest().getParameter(HttpNames.paramGraph);
+        Collection<String> strs;
+        if (uris == null || uris.isBlank()) {
+            strs = List.of(Quad.defaultGraphIRI.toString(), Quad.unionGraph.toString());
+        } else {
+            TypeToken<List<String>> typeToken = new TypeToken<List<String>>(){};
+            strs = gson.fromJson(uris, typeToken);
+        }
+        List<Node> rawGraphNodes = strs.stream().map(NodeFactory::createURI).distinct().toList();
+        // If the set of specified graphs is empty then index all.
+        if (rawGraphNodes.isEmpty()) {
+            rawGraphNodes = List.of(Quad.defaultGraphIRI, Quad.unionGraph);
+        }
+        List<Node> expandedGraphNodes = rawGraphNodes.stream()
+            .flatMap(node -> expandUnionGraphNode(dsg, node).stream()).distinct().toList();
+        return expandedGraphNodes;
+    }
+
+    private static boolean isReplaceMode(HttpAction action) {
+        String str = action.getRequest().getParameter("replaceMode");
+        boolean result = (str == null || str.isBlank()) ? false : Boolean.parseBoolean(str);
+        return result;
+    }
+
+    private static int getThreadCount(HttpAction action) {
+        String str = action.getRequest().getParameter("maxThreadCount");
+        int result = (str == null || str.isBlank()) ? 1 : Integer.parseInt(str);
+
+        if (result == 0) {
+            result = Runtime.getRuntime().availableProcessors();
+        }
+
+        return result;
+    }
+
+    private static List<Node> expandUnionGraphNode(DatasetGraph dsg, Node node) {
+        return Quad.isUnionGraph(node)
+            ? listGraphNodes(dsg)
+            : List.of(node);
+    }
+
+    private static List<Node> listGraphNodes(DatasetGraph dsg) {
+        try (Stream<Node> s = Iter.asStream(dsg.listGraphNodes())) {
+            return s.toList();
+        }
+    }
+
+    /**
+     * The GET command can serve: the website, the notification stream from task execution
+     * and the latest task execution status.
+     */
+    @Override
+    protected void doGet(HttpAction action) {
+        String rawCommand = action.getRequestParameter("command");
+        String command = Optional.ofNullable(rawCommand).orElse("website");
+        switch (command) {
+        case "website": serveWebSite(action); break;
+        case "events": serveEvents(action); break;
+        case "status": serveStatus(action); break;
+        default:
+            throw new UnsupportedOperationException("Unsupported operation: " + command);
+        }
+    }
+
+    protected void serveWebSite(HttpAction action) {
+        // Serves the minimal graphql ui
+        String resourceName = "spatial-indexer/index.html";
+        String str = null;
+        try (InputStream in = SpatialIndexComputeService.class.getClassLoader().getResourceAsStream(resourceName)) {
+            str = IOUtils.toString(in, StandardCharsets.UTF_8);
+        } catch (IOException e) {
+            throw new FusekiException(e);
+        }
+
+        if (str == null) {
+            action.setResponseStatus(HttpSC.INTERNAL_SERVER_ERROR_500);
+            action.setResponseContentType(WebContent.contentTypeTextPlain);
+            str = "Failed to load classpath resource " + resourceName;
+        } else {
+            action.setResponseStatus(HttpSC.OK_200);
+            action.setResponseContentType(WebContent.contentTypeHTML);
+        }
+        try (OutputStream out = action.getResponseOutputStream()) {
+            IOUtils.write(str, out, StandardCharsets.UTF_8);
+        } catch (IOException e) {
+            throw new FusekiException(e);
+        }
+    }
+
+    protected TaskControl<?> getActiveTask(HttpAction action) {
+        DatasetGraph dsg = action.getDataset();
+        Context cxt = dsg.getContext();
+        TaskControl<?> activeTask = cxt.get(SpatialIndexUtils.SPATIAL_INDEX_TASK_SYMBOL);
+        return activeTask;
+    }
+
+    /**
+     * Post request: Handle API call.
+     * Request is rejected if there is an already running task.
+     */
+    @Override
+    protected void doPost(HttpAction action) {
+        try {
+            String rawCommand = action.getRequestParameter("command");
+            String command = Optional.ofNullable(rawCommand).orElse("none");
+            switch (command) {
+            case "index": doIndex(action); break;
+            case "clean": doClean(action); break;
+            case "status": serveStatus(action); break;
+            case "cancel": doStop(action); break;
+            default: throw new UnsupportedOperationException("Unsupported operation: " + command);
+            }
+        } catch (Throwable t) {
+            String str = ExceptionUtils.getStackTrace(t);
+            action.log.error("An unexpected error occurred.", t);
+            // FusekiException
+            setResponse(action, HttpSC.INTERNAL_SERVER_ERROR_500, WebContent.contentTypeTextPlain, str);
+        }
+    }
+
+    protected void serveEvents(HttpAction action) {
+        HttpServletRequest request = action.getRequest();
+        HttpServletResponse response = action.getResponse();
+
+        response.setContentType("text/event-stream");
+        response.setCharacterEncoding("UTF-8");
+        response.setHeader("Cache-Control", "no-cache");
+
+        final AsyncContext asyncContext = request.startAsync();
+        asyncContext.setTimeout(0);
+        eventListeners.add(asyncContext);
+    }
+
+    /**
+     * Remove all graphs from the index for which there is no corresponding graph in the dataset.
+     */
+    protected void doClean(HttpAction action) throws Exception {
+        DatasetGraph dsg = action.getDataset();
+        Context cxt = dsg.getContext();
+
+        TaskControlOverAbortableThread<Thread> taskCtl = new TaskControlOverAbortableThread<>("Spatial Indexer Task");
+
+        cxt.compute(SpatialIndexUtils.SPATIAL_INDEX_TASK_SYMBOL, (key, priorTaskObj) -> {
+            TaskControl<?> priorTask = (TaskControl<?>)priorTaskObj;
+            if (priorTask != null && !priorTask.isComplete()) {
+                throw new RuntimeException("A spatial indexing task is already active for this dataset. Wait for completion or abort it.");
+            }
+
+            AbortableThread<?> thread = new AbortableThread<>() {
+                @Override
+                public void runActual() throws Exception {
+                    broadcastTaskStart(getStartTime(), null);
+
+                    SpatialIndex spatialIndexRaw = SpatialIndexUtils.getSpatialIndex(cxt);
+                    if (spatialIndexRaw == null) {
+                        throw new SpatialIndexException("No spatial index available on current dataset.");
+                    } else if (spatialIndexRaw instanceof SpatialIndexPerGraph spatialIndex) {
+                        STRtreePerGraph perGraphIndex = spatialIndex.getIndex();
+                        Map<Node, STRtree> treeMap = perGraphIndex.getTreeMap();
+                        Set<Node> graphNodes = new LinkedHashSet<>(Txn.calculateRead(dsg, () -> listGraphNodes(dsg)));
+                        // int initialGraphCount = treeMap.keySet().size();
+                        List<Node> indexGraphNodes = new ArrayList<>(treeMap.keySet());
+
+                        int cleanCount = 0;
+                        for (Node node : indexGraphNodes) {
+                            if (!(node == null || Quad.isDefaultGraph(node)) && !graphNodes.contains(node)) {
+                                perGraphIndex.removeTree(node);
+                                ++cleanCount;
+                                // System.out.println("Removed: [" + node + "] " + (node == null));
+                            }
+                        }
+
+                        int finalGraphCount = treeMap.keySet().size();
+                        Path targetFile = spatialIndex.getLocation();
+                        // SpatialIndexUtils.setSpatialIndex(cxt, newIndex);
+                        if (cleanCount > 0 && targetFile != null) {
+                            // newIndex.setLocation(targetFile);
+                            action.log.info("Writing spatial index of {} graphs (cleaned: {}) to disk at path {}", finalGraphCount, cleanCount, targetFile.toAbsolutePath());
+                            SpatialIndexIoKryo.save(targetFile, spatialIndex);
+                        }
+                        String statusMsg = String.format("Updated spatial index of %d graphs (cleaned: %d)", finalGraphCount, cleanCount);
+                        setStatusMessage(statusMsg);
+                        if (logger.isInfoEnabled()) {
+                            logger.info("Indexing of {} graphs completed successfully.", finalGraphCount);
+                        }
+                    } else {
+                        throw new SpatialIndexException("Unsupported spatial index type for cleaning.");
+                    }
+                }
+
+                @Override
+                protected void doAfterRun() {
+                    broadcastTaskEnd(getEndTime(), getThrowable(), getStatusMessage());
+                }
+            };
+
+            taskCtl.setThread(thread);
+            taskCtl.setSource(thread);
+            thread.start();
+            return taskCtl;
+        });
+    }
+
+    protected void doStop(HttpAction action) {
+        TaskControl<?> task = getActiveTask(action);
+        String state;
+        if (task != null) {
+            state = "true";
+            // Add a completion listener to return when done.
+            task.abort();
+            Thread thread = (Thread)task.getSource();
+            try {
+                thread.join();
+            } catch (InterruptedException e) {
+                // TODO Raise some HTTP error
+                throw new RuntimeException(e);
+            }
+        } else {
+            state = "false";
+        }
+
+        action.setResponseStatus(HttpSC.OK_200);
+        action.setResponseContentType(WebContent.contentTypeJSON);
+        try {
+            action.getResponseOutputStream().print(String.format("{ \"stopped\": %s }", state));
+        } catch (IOException e) {
+            throw new FusekiException(e);
+        }
+    }
+
+    /**
+     * Serves a JSON object that captures the current indexing status.
+     *
+     * <pre>
+     * {
+     *   isIndexing: Boolean, // Whether an index task is running.
+     *   isAborting: Boolean, // Iff isIndexing==true: Whether abort has been requested.
+     *   error: String,       // Iff isIndexing==false: An error message of the most recently terminated indexing task.
+     *   message: String      // Status message of the most recent indexing task (running or terminated).
+     *   time: long           // EpochMillis of the last status update.
+     * }
+     * </pre>
+     */
+    protected void serveStatus(HttpAction action) {
+        TaskControl<?> task = getActiveTask(action);
+
+        JsonObject status = new JsonObject();
+        Instant time;
+        if (task == null) {
+            status.addProperty("isIndexing", false);
+            time = Instant.ofEpochMilli(0);
+        } else {
+            status.addProperty("isIndexing", !task.isComplete());
+            if (!task.isComplete()) {
+                status.addProperty("isAborting", task.isAborting());
+                time = !task.isAborting() ? task.getStartTime() : task.getCancelTime();
+            } else {
+                time = task.getEndTime();
+            }
+            Throwable throwable = task.getThrowable();
+            if (throwable != null) {
+                String msg = ExceptionUtils.getStackTrace(throwable);
+                status.addProperty("error", msg);
+            }
+
+            String msg = task.getStatusMessage();
+            if (msg != null) {
+                status.addProperty("message", msg);
+            }
+        }
+        status.addProperty("time", time.toEpochMilli());
+
+        action.setResponseStatus(HttpSC.OK_200);
+        action.setResponseContentType(WebContent.contentTypeJSON);
+        try {
+            String str = gson.toJson(status);
+            action.getResponseOutputStream().println(str);
+        } catch (IOException e) {
+            throw new FusekiException(e);
+        }
+    }
+
+    protected TaskControl<?> scheduleTask(HttpAction action, SpatialIndexerComputation indexComputation, Path targetFile, boolean isReplaceTask) {
+        DatasetGraph dsg = action.getDataset();
+        Context cxt = dsg.getContext();
+
+        TaskControlOverAbortableThread<Thread> taskCtl = new TaskControlOverAbortableThread<>("Spatial Indexer Task");
+
+        cxt.compute(SpatialIndexUtils.SPATIAL_INDEX_TASK_SYMBOL, (key, priorTaskObj) -> {
+            TaskControl<?> priorTask = (TaskControl<?>)priorTaskObj;
+            if (priorTask != null && !priorTask.isComplete()) {
+                throw new RuntimeException("A spatial indexing task is already active for this dataset. Wait for completion or abort it.");
+            }
+
+            long graphCount = indexComputation.getGraphNodes().size();
+
+            AbortableThread<?> thread = new AbortableThread<Object>() {
+                @Override
+                public void runActual() throws Exception {
+                    broadcastTaskStart(getStartTime(), null);
+                    if (logger.isInfoEnabled()) {
+                        String replaceMsg = isReplaceTask ? "The resulting index will REPLACE a prior index." : "A prior index will be UPDATED with the newly indexed graphs.";
+                        logger.info("Indexing of {} graphs started. " + replaceMsg, graphCount);
+                    }
+
+                    // Uncomment to test artificial delays.
+                    // Thread.sleep(5000);
+
+                    SpatialIndexPerGraph newIndex = indexComputation.call();
+
+                    // If NOT in replace mode, add all graph-indexes from the previous index
+                    if (!isReplaceTask) {
+                        SpatialIndexPerGraph oldIndex = (SpatialIndexPerGraph)SpatialIndexUtils.getSpatialIndex(cxt);
+                        Map<Node, STRtree> treeMap = oldIndex.getIndex().getTreeMap();
+                        treeMap.forEach((name, tree) -> {
+                            if (!newIndex.getIndex().contains(name)) {
+                                newIndex.getIndex().setTree(name, tree);
+                            }
+                        });
+                    }
+
+                    SpatialIndexUtils.setSpatialIndex(cxt, newIndex);
+                    if (targetFile != null) {
+                        newIndex.setLocation(targetFile);
+                        action.log.info("Writing spatial index of {} graphs to disk at path {}", graphCount, targetFile.toAbsolutePath());
+                        SpatialIndexIoKryo.save(targetFile, newIndex);
+                    }
+                    String statusMsg = String.format("Updated spatial index with %d graphs.", graphCount);
+                    setStatusMessage(statusMsg);
+                    if (logger.isInfoEnabled()) {
+                        logger.info("Indexing of {} graphs completed successfully.", graphCount);
+                    }
+                }
+
+                @Override
+                public void requestCancel() {
+                    broadcastTaskAbort(getCancelTime(), null);
+                    indexComputation.abort();
+                    super.requestCancel(); // Interrupt
+                }
+
+                @Override
+                protected void doAfterRun() {
+                    try {
+                        indexComputation.close();
+                    } finally {
+                        try {
+                            Throwable throwable = getThrowable();
+                            Instant endTime = getEndTime();
+                            broadcastTaskEnd(endTime, throwable, getStatusMessage());
+                        } finally {
+                            if (logger.isInfoEnabled()) {
+                                logger.info("Indexing task of {} graphs terminated.", graphCount);
+                            }
+                        }
+                    }
+                }
+            };
+
+            taskCtl.setThread(thread);
+            taskCtl.setSource(thread);
+            thread.start();
+            return taskCtl;
+        });
+
+        return taskCtl;
+    }
+
+    protected void doIndex(HttpAction action) throws Exception {
+        DatasetGraph dsg = action.getDataset();
+        SpatialIndex index = SpatialIndexUtils.getSpatialIndex(dsg.getContext());
+
+        if (index == null) { // error: no spatial index has been configured
+            // XXX Could still allow for creating an in-memory-only index.
+            String msg = String.format("[%d] no spatial index has been configured for the dataset", action.id);
+            action.log.error(msg);
+            action.setResponseStatus(HttpSC.SERVICE_UNAVAILABLE_503);
+            action.setResponseContentType(WebContent.contentTypeTextPlain);
+            try {
+                action.getResponseWriter().println(msg);
+            } catch (IOException e) {
+                throw new FusekiException(e);
+            }
+            return;
+        } else {
+            boolean isReplaceMode = isReplaceMode(action);
+            int threadCount = getThreadCount(action);
+
+            // Only SpatialIndexPerGraph can be updated.
+            // Check if the index can be updated. If not then raise an exception
+            // that informs that only replace mode can be used in this situation.
+            if (!isReplaceMode && !(index instanceof SpatialIndexPerGraph)) {
+                throw new RuntimeException("Cannot update existing spatial index because its type is unsupported. Consider replacing the index.");
+            }
+
+            Path oldLocation = index.getLocation();
+            if (oldLocation == null) {
+                action.log.warn("Spatial index will not be persisted because no file location was configured.");
+            }
+
+            String srsURI = index.getSrsInfo().getSrsURI();
+
+            List<Node> graphNodes = Txn.calculateRead(dsg, () -> extractGraphs(dsg, action));
+            SpatialIndexerComputation task = new SpatialIndexerComputation(dsg, srsURI, graphNodes, threadCount);
+
+            action.log.info(String.format("[%d] spatial index: computation request accepted.", action.id));
+            scheduleTask(action, task, oldLocation, isReplaceMode);
+
+            setResponse(action, HttpSC.OK_200, WebContent.contentTypeTextPlain, "Spatial index computation task accepted at " + DateTimeUtils.nowAsXSDDateTimeString());
+        }
+    }
+
+    protected void setResponse(HttpAction action, int statusCode, String contentType, String payload) {
+        action.setResponseStatus(statusCode);
+        action.setResponseContentType(contentType);
+        try (OutputStream out = action.getResponseOutputStream()) {
+            IOUtils.write(payload, out, StandardCharsets.UTF_8);
+        } catch (IOException e) {
+            throw new FusekiException(e);
+        }
+    }
+
+    protected void broadcastTaskStart(Instant time, String msg) {
+        JsonObject json = new JsonObject();
+        json.addProperty("isIndexing", true);
+        json.addProperty("time", time.toEpochMilli());
+        if (msg != null) {
+            json.addProperty("message", msg);
+        }
+        broadcastJson(json);
+    }
+    protected void broadcastTaskAbort(Instant time, String msg) {
+        JsonObject json = new JsonObject();
+        json.addProperty("isIndexing", true);
+        json.addProperty("isAborting", true);
+        json.addProperty("time", time.toEpochMilli());
+        if (msg != null) {
+            json.addProperty("message", msg);
+        }
+        broadcastJson(json);
+    }
+
+    protected void broadcastTaskEnd(Instant time, Throwable throwable, String msg) {
+        JsonObject json = new JsonObject();
+        json.addProperty("isIndexing", false);
+        json.addProperty("time", time.toEpochMilli());
+        if (msg != null) {
+            json.addProperty("message", msg);
+        }
+        if (throwable != null) {
+            json.addProperty("error", ExceptionUtils.getStackTrace(throwable));
+        }
+        broadcastJson(json);
+    }
+
+    protected void broadcastJson(JsonElement jsonData) {
+        String str = gson.toJson(jsonData);
+        broadcastLine(str);
+    }
+
+    /**
+     * Broadcast a payload to all registered listeners.
+     * @param payload A string without newline characters.
+     */
+    protected void broadcastLine(String payload) {
+        Iterator<AsyncContext> it = eventListeners.getMap().keySet().iterator();
+        while (it.hasNext()) {
+            AsyncContext context = it.next();
+            try {
+                PrintWriter writer = context.getResponse().getWriter();
+                // Format demanded by server side events is: "data: <payload>\n\n".
+                writer.println("data: " + payload);
+                writer.println();
+                writer.flush();
+            } catch (IOException e) {
+                it.remove();
+                context.complete();
+            }
+        }
+    }
+}
diff --git a/jena-fuseki2/jena-fuseki-mod-geosparql/src/main/java/org/apache/jena/fuseki/mod/geosparql/TaskTracker.java b/jena-fuseki2/jena-fuseki-mod-geosparql/src/main/java/org/apache/jena/fuseki/mod/geosparql/TaskTracker.java
new file mode 100644
index 0000000000..93f15dff68
--- /dev/null
+++ b/jena-fuseki2/jena-fuseki-mod-geosparql/src/main/java/org/apache/jena/fuseki/mod/geosparql/TaskTracker.java
@@ -0,0 +1,128 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.jena.fuseki.mod.geosparql;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Iterator;
+import java.util.Map.Entry;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.ConcurrentSkipListMap;
+import java.util.concurrent.atomic.AtomicLong;
+
+import org.apache.jena.sparql.SystemARQ;
+import org.apache.jena.sparql.util.Context;
+import org.apache.jena.sparql.util.Symbol;
+
+/**
+ * A simple tracker for abortable tasks.
+ * Task registration accepts an arbitrary object and a runnable for aborting the task.
+ * The task itself is responsible for deregistration on its termination.
+ * A record with starting and termination time is maintained by this class.
+ */
+public class TaskTracker {
+    /** Record for when a task is submitted to the tracker. */
+    public record StartRecord(long requestId, Instant timestamp, Object requestObject, Runnable abortAction) {}
+
+    /** Record for when a task is completed. */
+    public record CompletionRecord(StartRecord start, Instant timestamp, Throwable throwable) {
+        public Duration duration() {
+            return Duration.between(start.timestamp, timestamp);
+        }
+
+        public boolean isSuccess() {
+            return throwable == null;
+        }
+    }
+
+    protected AtomicLong nextId = new AtomicLong();
+    protected ConcurrentMap<Long, StartRecord> idToStartRecord = new ConcurrentHashMap<>();
+    protected int maxHistorySize = 1000;
+    protected ConcurrentMap<Instant, CompletionRecord> history = new ConcurrentSkipListMap<>();
+
+    public ConcurrentMap<Instant, CompletionRecord> getHistory() {
+        return history;
+    }
+
+    public void setMaxHistorySize(int maxHistorySize) {
+        this.maxHistorySize = maxHistorySize;
+    }
+
+    public long put(Object requestObject, Runnable abortAction) {
+        long result = nextId.getAndIncrement();
+        StartRecord record = new StartRecord(result, Instant.now(), requestObject, abortAction);
+        idToStartRecord.put(result, record);
+        return result;
+    }
+
+    protected void trimHistory() {
+        if (history.size() >= maxHistorySize) {
+            Iterator<Entry<Instant, CompletionRecord>> it = history.entrySet().iterator();
+            while (history.size() >= maxHistorySize && it.hasNext()) {
+                it.next();
+                it.remove();
+            }
+        }
+    }
+
+    public CompletionRecord remove(long id, Throwable t) {
+        StartRecord startRecord = idToStartRecord.remove(id);
+        CompletionRecord result = null;
+        if (startRecord != null) {
+            trimHistory();
+            Instant now = Instant.now();
+            result = new CompletionRecord(startRecord, now, t);
+            history.put(now, result);
+        }
+        return result;
+    }
+
+    @Override
+    public String toString() {
+        return "Active: " + idToStartRecord.size() + ", History: " + history.size() + "/" + maxHistorySize;
+    }
+
+    public static final Symbol symTaskTracker = SystemARQ.allocSymbol("taskTracker");
+
+    public static TaskTracker getTracker(Context context) {
+        return context.get(symTaskTracker);
+    }
+
+    public static TaskTracker requireTracker(Context context) {
+        TaskTracker result = getTracker(context);
+        Objects.requireNonNull("No ExecTracker registered in context");
+        return result;
+    }
+
+    public static TaskTracker ensureTracker(Context context) {
+        TaskTracker result = context.get(symTaskTracker);
+        if (result == null) {
+            synchronized (context) {
+                result = context.get(symTaskTracker);
+                if (result == null) {
+                    result = new TaskTracker();
+                    context.set(symTaskTracker, result);
+                }
+            }
+        }
+        return result;
+    }
+}
diff --git a/jena-fuseki2/jena-fuseki-mod-geosparql/src/main/resources/META-INF/services/org.apache.jena.fuseki.main.sys.FusekiAutoModule b/jena-fuseki2/jena-fuseki-mod-geosparql/src/main/resources/META-INF/services/org.apache.jena.fuseki.main.sys.FusekiAutoModule
new file mode 100644
index 0000000000..9e6353c973
--- /dev/null
+++ b/jena-fuseki2/jena-fuseki-mod-geosparql/src/main/resources/META-INF/services/org.apache.jena.fuseki.main.sys.FusekiAutoModule
@@ -0,0 +1 @@
+org.apache.jena.fuseki.mod.geosparql.FMod_SpatialIndexer
diff --git a/jena-fuseki2/jena-fuseki-mod-geosparql/src/main/resources/spatial-indexer/index.html b/jena-fuseki2/jena-fuseki-mod-geosparql/src/main/resources/spatial-indexer/index.html
new file mode 100644
index 0000000000..117c0ea9f5
--- /dev/null
+++ b/jena-fuseki2/jena-fuseki-mod-geosparql/src/main/resources/spatial-indexer/index.html
@@ -0,0 +1,378 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Spatial Index Controls</title>
+    <style>
+        body {
+            font-family: Arial, sans-serif;
+            margin: 20px;
+        }
+        .controls {
+            display: flex;
+            box-sizing: border-box;
+            width: 100%;
+            gap: 10px;
+            margin-bottom: 20px;
+            align-items: center;
+            background: #f8f9fa;
+            padding: 10px;
+            border-radius: 5px;
+        }
+        table {
+            width: 100%;
+            border-collapse: collapse;
+            margin-top: 10px;
+        }
+        th, td {
+            border: 1px solid #ddd;
+            padding: 8px;
+            text-align: left;
+        }
+        th {
+            background-color: #f2f2f2;
+        }
+        tr:nth-child(even) {
+            background-color: #f9f9f9;
+        }
+
+        .log-info {
+            background-color: #cce5ff;
+            color: #004085;
+        }
+
+        .log-computing {
+            background-color: #ccc;
+            color: #333;
+        }
+
+        .log-error {
+            background-color: #f8d7da;
+            color: #721c24;
+        }
+
+        .log-success {
+            background-color: #d4edda;
+            color: #155724;
+        }
+    </style>
+</head>
+<body>
+    <h1>Rebuild Spatial Index for Selected Graphs</h1>
+
+    <div id="status-controls" class="controls" style="flex-direction: column;">
+        <div style="display: flex; width: 100%;">
+            <button id="toggle-status" style="display: none;">Show Stack Trace</button>
+        </div>
+        <div id="status-container" style="width: 100%;">
+            <pre id="status-message"></pre>
+        </div>
+    </div>
+
+    <div id="controls" class="controls">
+        <input type="text" id="filter" placeholder="Type to filter...">
+        <button id="deselect-all">Deselect all</button>
+        <button id="apply-action">Index all graphs</button>
+        <button id="stop-action">Stop running action</button>
+        <div title="If checked then the final index will contain ONLY the selected graphs. If not checked then the selected graphs will be updated in the index. When unsure leave this unchecked.">
+          <input id="replace-toggle" type="checkbox">
+          <label for="replace-toggle">Replace index with selected graphs</label>
+        </div>
+        <div title="Maximum thread pool size for indexing graphs. One thread can index one graph. If set to 0 then the maximum pool size becomes the number of available server threads.">
+          <input type="number" id="max-thread-count" min="0" max="10000" value="1">
+          <label for="max-thread-count">max threads</label>
+        </div>
+        <div style="display:flex; justify-content:flex-end; margin-left: auto;">
+            <button id="clean-action">Remove absent graphs from index</button>
+        </div>
+        <!-- <button id="stop-action">Stop running action</button> -->
+    </div>
+
+    <!--
+    <div class="controls" style="flex-direction: column;">
+        <div style="display: flex; justify-content: flex-end; width: 100%;">
+            <button id="toggle-status" style="display: none;">Show Stack Trace</button>
+        </div>
+        <div id="status-container" style="width: 100%;">
+            <pre id="status-message" style="margin-top: 10px; padding: 10px; border-radius: 5px; white-space: pre-wrap;"></pre>
+        </div>
+    </div>
+    -->
+
+    <table>
+        <thead>
+            <tr>
+                <th>Select</th>
+                <th>Graph Name</th>
+            </tr>
+        </thead>
+        <tbody id="graph-list">
+            <!-- Graph checkboxes will be populated here -->
+        </tbody>
+    </table>
+
+    <script>
+        const sparqlEndpoint = document.location.href + '/../';
+        const apiEndpoint = document.location.href;
+        const eventEndpoint = new EventSource(apiEndpoint + '?command=events');
+
+        function setStatusClass(el, statusClass) {
+            el.classList.remove('log-success', 'log-error', 'log-info', 'log-computing');
+            el.classList.add(statusClass);
+        }
+
+        let status = {};
+
+        eventEndpoint.onmessage = async e => {
+            // console.log('Got message:', e.data);
+            status = JSON.parse(e.data);
+
+            // For selenium unit testing store the latest message on the window!
+            window.lastEvent = status;
+            updateStatus();
+        };
+
+        eventEndpoint.onerror = async e => {
+            console.error('SSE error', e);
+        };
+
+        async function fetchIndexerStatus() {
+            const params = new URLSearchParams({ "command": "status" });
+            try {
+              const response = await fetch(apiEndpoint, { "method": "POST", "body": params })
+
+              if (response.status != 200) {
+                  const text = await response.text();
+                  throw new Error("HTTP Error: " + text);
+              }
+              const json = await response.json();
+              return json;
+            } catch (error) {
+                alert(`Error: ${error.message}`);
+            }
+        }
+
+        async function cancelIndexing() {
+            const params = new URLSearchParams({ "command": "cancel" });
+            try {
+                const response = await fetch(apiEndpoint, { "method": "POST", "body": params })
+                if (response.status != 200) {
+                    const text = await response.text();
+                    throw new Error("HTTP Error: " + text);
+                }
+            } catch (error) {
+                alert(`Error: ${error.message}`);
+            }
+        }
+
+        async function fetchGraphs() {
+            const query = `SELECT ?g { { BIND(<urn:x-arq:DefaultGraph> AS ?g) } UNION { BIND(<urn:x-arq:UnionGraph> AS ?g) } UNION { { SELECT * { GRAPH ?g { } } ORDER BY ?g } } }`;
+            const url = `${sparqlEndpoint}?query=${encodeURIComponent(query)}&format=application/json`;
+
+            try {
+                const response = await fetch(url);
+                const data = await response.json();
+                return data.results.bindings.map(binding => binding.g.value);
+            } catch (error) {
+                console.error('Error fetching graphs:', error);
+                return [];
+            }
+        }
+
+        function renderGraphs(graphs) {
+            const graphList = document.getElementById('graph-list');
+            graphList.innerHTML = '';
+
+            graphs.forEach(graph => {
+                const row = document.createElement('tr');
+
+                const checkboxCell = document.createElement('td');
+                const checkbox = document.createElement('input');
+                checkbox.type = 'checkbox';
+                checkbox.value = graph;
+                checkbox.addEventListener('change', updateApplyButtonLabel);
+                checkboxCell.appendChild(checkbox);
+
+                const nameCell = document.createElement('td');
+                nameCell.textContent = graph;
+
+                row.appendChild(checkboxCell);
+                row.appendChild(nameCell);
+                graphList.appendChild(row);
+            });
+        }
+
+        function updateApplyButtonLabel() {
+            const selectedGraphs = document.querySelectorAll('#graph-list input:checked');
+            applyBtn.textContent = selectedGraphs.length === 0 ? 'Index all graphs' : `Index ${selectedGraphs.length} graphs`;
+        }
+
+        document.getElementById('filter').addEventListener('input', function () {
+            const filterText = this.value.toLowerCase();
+            document.querySelectorAll('#graph-list tr').forEach(row => {
+                const label = row.children[1].textContent.toLowerCase();
+                row.style.display = label.includes(filterText) ? '' : 'none';
+            });
+        });
+
+        const abortController = new AbortController();
+        const signal = abortController.signal;
+
+        const deselectAllBtn = document.getElementById('deselect-all');
+        const applyBtn = document.getElementById('apply-action');
+        const cleanBtn = document.getElementById('clean-action');
+        const replaceCb = document.getElementById('replace-toggle');
+        const maxThreadCountInput = document.getElementById('max-thread-count');
+
+        async function reIndexGraphs(graphs) {
+            applyBtn.disabled = true;
+            const replaceMode = replaceCb.checked;
+            const maxThreadCountStr = maxThreadCountInput.value;
+            const maxThreadCount = maxThreadCountStr ? parseInt(maxThreadCountStr) : 1;
+
+            const params = new URLSearchParams({
+              "command": "index",
+              "graph": JSON.stringify(graphs),
+              "replaceMode": replaceMode,
+              "maxThreadCount": maxThreadCount
+            });
+
+            try {
+                const response = await fetch(apiEndpoint, { "method": "POST", "body": params, signal })
+
+                if (response.status != 200) {
+                    const text = await response.text();
+                    throw new Error("HTTP Error: " + text);
+                }
+
+            }   catch (error) {
+                alert(`Error: ${error.message}`);
+            }
+            applyBtn.disabled = false;
+        }
+
+        async function cleanGraphs() {
+            applyBtn.disabled = true;
+            const params = new URLSearchParams({ "command": "clean" });
+            try {
+                const response = await fetch(apiEndpoint, { "method": "POST", "body": params, signal })
+
+                if (response.status != 200) {
+                    const text = await response.text();
+                    throw new Error("HTTP Error: " + text);
+                }
+
+            }   catch (error) {
+                alert(`Error: ${error.message}`);
+            }
+            applyBtn.disabled = false;
+        }
+
+        applyBtn.addEventListener('click', function () {
+            const selectedGraphs = Array.from(document.querySelectorAll('#graph-list input:checked'))
+                .map(checkbox => checkbox.value);
+            reIndexGraphs(selectedGraphs);
+        });
+
+        cleanBtn.addEventListener('click', function () {
+            cleanGraphs();
+        });
+
+        deselectAllBtn.addEventListener('click', function () {
+            document.querySelectorAll('#graph-list input[type="checkbox"]').forEach(checkbox => checkbox.checked = false);
+            updateApplyButtonLabel();
+        });
+
+        function updateStatus() {
+            updateCancelButton();
+            updateStatusMessage();
+        }
+
+        function updateCancelButton() {
+            const elt = document.getElementById('stop-action');
+            elt.style.display = 'none';
+            elt.innerHTML = "Stop running indexer.";
+            elt.disabled = false;
+
+            if (status?.isIndexing) {
+                elt.style.display = '';
+                if (status?.isAborting) {
+                    elt.innerHTML = "Aborting...";
+                    elt.disabled = true;
+                }
+            }
+        }
+
+        // Update status message and style.
+        function updateStatusMessage() {
+            const statusEl = document.getElementById('status-controls');
+            const messageEl = document.getElementById('status-message');
+            const toggleBtn = document.getElementById('toggle-status');
+            const statusContainer = document.getElementById('status-container');
+            const time = status.time;
+
+            if (time !== undefined && time > 0) {
+                const dateStr = new Date(status.time).toLocaleString() + " ";
+                if (status.isIndexing) {
+                    messageEl.textContent = dateStr + "Computation is ongoing...";
+                    setStatusClass(statusEl, "log-computing");
+                    toggleBtn.style.display = 'none';
+                    statusContainer.style.display = 'block';
+                } else if (status.error) {
+                    toggleBtn.style.display = 'block';
+                    toggleBtn.textContent = 'Hide Stack Trace';
+                    statusContainer.style.display = 'block';
+
+                    toggleBtn.onclick = () => {
+                        const visible = statusContainer.style.display === 'block';
+                        statusContainer.style.display = visible ? 'none' : 'block';
+                        toggleBtn.textContent = visible ? 'Show Stack Trace' : 'Hide Stack Trace';
+                    };
+
+                    messageEl.textContent = dateStr + `An error occurred:\n${status.error}`;
+                    setStatusClass(statusEl, "log-error");
+                } else if (status.message) {
+                    messageEl.textContent = dateStr + status.message;
+                    setStatusClass(statusEl, "log-success");
+                    toggleBtn.style.display = 'none';
+                    statusContainer.style.display = 'block';
+                } else {
+                    const suffix = status.message ? ': ' + status.message : '.';
+                    messageEl.textContent = dateStr + "Indexer finished successfully" + suffix;
+                    setStatusClass(statusEl, "log-success");
+                    toggleBtn.style.display = 'none';
+                    statusContainer.style.display = 'block';
+                }
+            } else {
+                messageEl.textContent = "No indexer status information present.";
+                setStatusClass(statusEl, "log-info");
+                toggleBtn.style.display = 'none';
+                statusContainer.style.display = 'block';
+                return;
+            }
+        }
+
+        document.getElementById('stop-action').addEventListener('click', function() {
+            cancelIndexing();
+        });
+
+        cleanBtn.addEventListener('click', function() {
+            cancelIndexing();
+        });
+
+        async function initialize() {
+            status = await fetchIndexerStatus();
+            updateStatus();
+
+            const graphs = await fetchGraphs();
+            renderGraphs(graphs);
+            updateApplyButtonLabel();
+        }
+
+        updateStatus();
+        initialize();
+    </script>
+</body>
+</html>
+
diff --git a/jena-fuseki2/jena-fuseki-mod-geosparql/src/test/java/org/apache/jena/fuseki/mod/geosparql/TestFMod_SpatialIndexer.java b/jena-fuseki2/jena-fuseki-mod-geosparql/src/test/java/org/apache/jena/fuseki/mod/geosparql/TestFMod_SpatialIndexer.java
new file mode 100644
index 0000000000..391814a4db
--- /dev/null
+++ b/jena-fuseki2/jena-fuseki-mod-geosparql/src/test/java/org/apache/jena/fuseki/mod/geosparql/TestFMod_SpatialIndexer.java
@@ -0,0 +1,165 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.jena.fuseki.mod.geosparql;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import org.apache.jena.atlas.iterator.Iter;
+import org.apache.jena.fuseki.main.FusekiServer;
+import org.apache.jena.fuseki.main.cmds.FusekiMain;
+import org.apache.jena.geosparql.spatial.SpatialIndex;
+import org.apache.jena.geosparql.spatial.SpatialIndexException;
+import org.apache.jena.geosparql.spatial.index.v2.GeometryGenerator;
+import org.apache.jena.geosparql.spatial.index.v2.GeometryGenerator.GeometryType;
+import org.apache.jena.geosparql.spatial.index.v2.SpatialIndexPerGraph;
+import org.apache.jena.geosparql.spatial.index.v2.SpatialIndexUtils;
+import org.apache.jena.graph.Node;
+import org.apache.jena.graph.NodeFactory;
+import org.apache.jena.sparql.core.DatasetGraph;
+import org.apache.jena.sparql.core.DatasetGraphFactory;
+import org.apache.jena.sparql.core.Quad;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.locationtech.jts.geom.Envelope;
+import org.openqa.selenium.By;
+import org.openqa.selenium.JavascriptExecutor;
+import org.openqa.selenium.WebDriver;
+import org.openqa.selenium.WebElement;
+import org.openqa.selenium.chrome.ChromeDriver;
+import org.openqa.selenium.chrome.ChromeOptions;
+import org.openqa.selenium.support.ui.WebDriverWait;
+
+import io.github.bonigarcia.wdm.WebDriverManager;
+
+/**
+ * Test cases that interact with the spatial indexer web UI via Selenium.
+ *
+ * This class is currently set to "ignore" because it requires local browser.
+ * Although, a headless Chrome should be started automatically,
+ * this step turns out to not yet work reliable across all environments.
+ */
+@Ignore
+public class TestFMod_SpatialIndexer {
+    private WebDriver driver;
+    private JavascriptExecutor js;
+
+    private static Envelope queryEnvelope = new Envelope(-180, 180, -90, 90);
+    private DatasetGraph dsg;
+    private SpatialIndex spatialIndex;
+    private Node graphName1 = NodeFactory.createURI("http://www.example.org/graph1");
+
+    @Before
+    public void setUp() throws IOException, SpatialIndexException {
+        dsg = DatasetGraphFactory.create();
+        setupTestData(dsg);
+
+        spatialIndex = SpatialIndexUtils.buildSpatialIndex(dsg);
+
+        String[] argv = new String[] { "--empty" };
+        FusekiServer server = FusekiMain.builder(argv)
+            .add("test", dsg)
+            .build();
+        server.start();
+        int port = server.getPort();
+        String serverURL = "http://localhost:" + port + "/";
+        String siteUrl = serverURL + "test/update-spatial";
+
+        ChromeOptions options = new ChromeOptions();
+        options.addArguments("--headless=new"); // use "new" headless mode (better support)
+        options.addArguments("--no-sandbox");
+        options.addArguments("--disable-dev-shm-usage");
+
+        WebDriverManager.chromedriver().setup(); // Automatically downloads and sets path
+        driver = new ChromeDriver(options);
+        driver.get(siteUrl);
+        js = (JavascriptExecutor) driver;
+    }
+
+    private void setupTestData(DatasetGraph dsg) {
+        // Fill the graph with a few geometries; spatial index construction will derive the SRS from them.
+        Envelope envelope = new Envelope(-175, 175, -85, 85);
+
+        Map<GeometryType, Number> conf = new HashMap<>();
+        conf.put(GeometryType.POINT, 1);
+
+        // Generate geometries into the default graph and a named graph
+        GeometryGenerator.generateGraph(dsg.getDefaultGraph(), envelope, conf);
+
+        conf.put(GeometryType.POLYGON, 1);
+        GeometryGenerator.generateGraph(dsg.getGraph(graphName1), envelope, conf);
+    }
+
+    /**
+     * Test that first clicks the "apply" button on HTML page to update the spatial index.
+     * Then, the test removes a graph from the dataset and clicks the "clean" button which
+     * should remove all entries from the index for which there is no corresponding graph in the dataset.
+     */
+    @Test
+    public void testIndexAndCleanButtons() {
+        // Index the test data (default graph and 1 named graph).
+        WebElement button = driver.findElement(By.id("apply-action"));
+        button.click();
+        awaitEvent();
+        Assert.assertEquals(1, spatialIndex.query(queryEnvelope, Quad.defaultGraphIRI).size());
+        Assert.assertEquals(2, spatialIndex.query(queryEnvelope, graphName1).size());
+        clearLastEvent();
+
+        // Remove the named graph and update the index.
+        dsg.removeGraph(graphName1);
+        WebElement cleanButton = driver.findElement(By.id("clean-action"));
+        cleanButton.click();
+        awaitEvent();
+        SpatialIndexPerGraph newIndex = (SpatialIndexPerGraph)SpatialIndexUtils.getSpatialIndex(dsg.getContext());
+
+        // Check the number of graphs in the spatial index; the treeMap includes the default graph.
+        long numGraphsInIndex = newIndex.getIndex().getTreeMap().keySet().size();
+        long numGraphsInDataset;
+        try (Stream<?> s = Iter.asStream(dsg.listGraphNodes())) {
+            numGraphsInDataset = s.count() + 1; // Add one for the default graph.
+        }
+        Assert.assertEquals(numGraphsInIndex, numGraphsInDataset);
+        clearLastEvent();
+    }
+
+    private void awaitEvent() {
+        WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(5));
+        wait.until(d -> {
+            Object status = js.executeScript("return window.lastEvent;");
+            return status != null;
+        });
+    }
+
+    private void clearLastEvent() {
+        js.executeScript("window.lastEvent = null");
+    }
+
+    @After
+    public void tearDown() {
+        if (driver != null) {
+            driver.quit();
+        }
+    }
+}
diff --git a/jena-fuseki2/jena-fuseki-mod-geosparql/src/test/resources/log4j2-test.properties b/jena-fuseki2/jena-fuseki-mod-geosparql/src/test/resources/log4j2-test.properties
new file mode 100644
index 0000000000..fb3efdb6de
--- /dev/null
+++ b/jena-fuseki2/jena-fuseki-mod-geosparql/src/test/resources/log4j2-test.properties
@@ -0,0 +1,56 @@
+## Licensed under the terms of http://www.apache.org/licenses/LICENSE-2.0
+status = error
+name = PropertiesConfig
+filters = threshold
+
+filter.threshold.type = ThresholdFilter
+filter.threshold.level = ALL
+
+appender.console.type = Console
+appender.console.name = OUT
+appender.console.target = SYSTEM_OUT
+appender.console.layout.type = PatternLayout
+appender.console.layout.pattern = %d{HH:mm:ss} %-5p %-10c{1} :: %m%n
+#appender.console.layout.pattern = [%d{yyyy-MM-dd HH:mm:ss}] %-5p %-10c{1} :: %m%n
+
+rootLogger.level                  = INFO
+rootLogger.appenderRef.stdout.ref = OUT
+
+logger.jena.name  = org.apache.jena
+logger.jena.level = INFO
+
+logger.arq-exec.name  = org.apache.jena.arq.exec
+logger.arq-exec.level = INFO
+
+logger.fuseki.name  = org.apache.jena.fuseki
+logger.fuseki.level = WARN
+
+## Some tests correctly log warnings. TS_PrefixesService
+logger.fuseki-fuseki.name  = org.apache.jena.fuseki.Fuseki
+logger.fuseki-fuseki.level = ERROR
+ 
+logger.fuseki-autoload.name  = org.apache.jena.fuseki.main.sys.FusekiAutoModules
+logger.fuseki-autoload.level = ERROR
+
+logger.http.name  = org.apache.jena.http
+logger.http.level = INFO
+
+logger.riot.name  = org.apache.jena.riot
+logger.riot.level = INFO
+
+logger.riot.name  = org.apache.shiro
+logger.riot.level = WARN
+
+logger.jetty.name  = org.eclipse.jetty
+logger.jetty.level = WARN
+
+# This goes out in NCSA format
+appender.plain.type = Console
+appender.plain.name = PLAIN
+appender.plain.layout.type = PatternLayout
+appender.plain.layout.pattern = %m%n
+
+logger.request-log.name                   = org.apache.jena.fuseki.Request
+logger.request-log.additivity             = false
+logger.request-log.level                  = OFF
+logger.request-log.appenderRef.plain.ref  = PLAIN
diff --git a/jena-fuseki2/jena-fuseki-server/pom.xml b/jena-fuseki2/jena-fuseki-server/pom.xml
index 894c5f6d2b..222a7ef271 100644
--- a/jena-fuseki2/jena-fuseki-server/pom.xml
+++ b/jena-fuseki2/jena-fuseki-server/pom.xml
@@ -66,7 +66,13 @@
       <artifactId>jena-cmds</artifactId>
       <version>${project.version}</version>
     </dependency>
-    
+
+    <dependency>
+      <groupId>org.apache.jena</groupId>
+      <artifactId>jena-fuseki-mod-geosparql</artifactId>
+      <version>${project.version}</version>
+    </dependency>
+
     <dependency>
       <groupId>org.apache.logging.log4j</groupId>
       <artifactId>log4j-slf4j2-impl</artifactId>
@@ -95,7 +101,7 @@
         <configuration>
           <shadedArtifactAttached>false</shadedArtifactAttached>
 
-          <!-- 
+          <!--
             https://issues.apache.org/jira/browse/LOG4J2-954
             Multiple Log4j2Plugins.dat
             jena-fuseki-server only pick up one Log4j2Plugins.dat (from log4j-core)
@@ -122,7 +128,7 @@
             <filter>
               <artifact>*:*</artifact>
               <excludes>
-                <!-- Some jars are signed but shading breaks that. Don't 
+                <!-- Some jars are signed but shading breaks that. Don't
                   include signing files. -->
                 <exclude>META-INF/*.SF</exclude>
                 <exclude>META-INF/*.DSA</exclude>
diff --git a/jena-fuseki2/jena-fuseki-server/src/main/java/org/apache/jena/playground/MainPlaygroundFusekiModGeoSparql.java b/jena-fuseki2/jena-fuseki-server/src/main/java/org/apache/jena/playground/MainPlaygroundFusekiModGeoSparql.java
new file mode 100644
index 0000000000..2e2518ea09
--- /dev/null
+++ b/jena-fuseki2/jena-fuseki-server/src/main/java/org/apache/jena/playground/MainPlaygroundFusekiModGeoSparql.java
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.jena.playground;
+
+import org.apache.jena.fuseki.main.FusekiServer;
+import org.apache.jena.fuseki.main.sys.FusekiModules;
+import org.apache.jena.fuseki.mod.geosparql.FMod_SpatialIndexer;
+import org.apache.jena.fuseki.system.FusekiLogging;
+import org.apache.jena.query.DatasetFactory;
+
+public class MainPlaygroundFusekiModGeoSparql {
+    public static void main(String[] args) {
+        FusekiLogging.setLogging();
+
+        FMod_SpatialIndexer myModule = new FMod_SpatialIndexer();
+        myModule.start();
+        FusekiModules fmods = FusekiModules.create(myModule);
+        FusekiServer server = FusekiServer.create()
+            .fusekiModules(fmods)
+            .staticFileBase("/home/raven/Laboratory/webapp")
+            .add("test", DatasetFactory.create())
+            .port(3030)
+            .enablePing(true)
+            .enableStats(true)
+            .enableTasks(true)
+            .build();
+
+        server.start();
+    }
+}
diff --git a/jena-fuseki2/pom.xml b/jena-fuseki2/pom.xml
index 3dbe63b212..7adfec9458 100644
--- a/jena-fuseki2/pom.xml
+++ b/jena-fuseki2/pom.xml
@@ -57,6 +57,8 @@
     <!-- Fuseki main server -->
     <module>jena-fuseki-main</module>
 
+    <module>jena-fuseki-mod-geosparql</module>
+
     <!-- fuseki-server.jar -->
     <module>jena-fuseki-server</module>
     
diff --git a/jena-geosparql/pom.xml b/jena-geosparql/pom.xml
index 7c3c80b1e9..e5145a2e93 100644
--- a/jena-geosparql/pom.xml
+++ b/jena-geosparql/pom.xml
@@ -14,7 +14,9 @@
    limitations under the License.
 -->
 
-<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
   <modelVersion>4.0.0</modelVersion>
   <artifactId>jena-geosparql</artifactId>
   <packaging>jar</packaging>
@@ -33,7 +35,6 @@
   </properties>
 
   <dependencies>
-
     <dependency>
       <groupId>org.apache.jena</groupId>
       <artifactId>jena-arq</artifactId>
@@ -44,18 +45,6 @@
       <groupId>org.apache.sis.core</groupId>
       <artifactId>sis-referencing</artifactId>
     </dependency>
-    
-    <!-- Evntually this wil be:
-    <dependency>
-      <groupId>jakarta.xml.bind</groupId>
-      <artifactId>jakarta.xml.bind-api</artifactId>
-    </dependency>
-    -->
-    
-	<dependency>
-      <groupId>javax.xml.bind</groupId>
-      <artifactId>jaxb-api</artifactId>
-    </dependency>
 
     <dependency>
       <groupId>org.slf4j</groupId>
@@ -78,8 +67,8 @@
     </dependency>
 
     <dependency>
-      <groupId>org.apache.commons</groupId>
-      <artifactId>commons-collections4</artifactId>
+      <groupId>com.esotericsoftware</groupId>
+      <artifactId>kryo</artifactId>
     </dependency>
 
     <!-- Non-free; testing only -->
@@ -101,7 +90,6 @@
       <artifactId>log4j-slf4j2-impl</artifactId>
       <scope>test</scope>
     </dependency>
-
   </dependencies>
 
   <build>
@@ -133,11 +121,11 @@
       <plugin>
         <groupId>org.apache.maven.plugins</groupId>
         <artifactId>maven-source-plugin</artifactId>
-        <executions> 
+        <executions>
           <execution>
-            <id>attach-sources-test</id> 
+            <id>attach-sources-test</id>
             <goals>
-              <goal>test-jar-no-fork</goal> 
+              <goal>test-jar-no-fork</goal>
             </goals>
           </execution>
         </executions>
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/InitGeoSPARQL.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/InitGeoSPARQL.java
index a0ca84639c..e32482a1a6 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/InitGeoSPARQL.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/InitGeoSPARQL.java
@@ -42,7 +42,7 @@ public class InitGeoSPARQL implements JenaSubsystemLifecycle {
         if ( initialized )
             return ;
         synchronized (initLock) {
-            if ( initialized ) {
+            if ( initialized || System.getProperty("jena.geosparql.skip", "false").equalsIgnoreCase("true") ) {
                 JenaSystem.logLifecycle("InitGeoSPARQL - skip") ;
                 return ;
             }
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/assembler/GeoAssembler.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/assembler/GeoAssembler.java
index 5e54e6ad95..0f64669160 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/assembler/GeoAssembler.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/assembler/GeoAssembler.java
@@ -18,7 +18,15 @@
 
 package org.apache.jena.geosparql.assembler;
 
-import static org.apache.jena.geosparql.assembler.VocabGeoSPARQL.*;
+import static org.apache.jena.geosparql.assembler.VocabGeoSPARQL.pApplyDefaultGeometry;
+import static org.apache.jena.geosparql.assembler.VocabGeoSPARQL.pDataset;
+import static org.apache.jena.geosparql.assembler.VocabGeoSPARQL.pIndexEnabled;
+import static org.apache.jena.geosparql.assembler.VocabGeoSPARQL.pIndexExpiries;
+import static org.apache.jena.geosparql.assembler.VocabGeoSPARQL.pIndexSizes;
+import static org.apache.jena.geosparql.assembler.VocabGeoSPARQL.pInference;
+import static org.apache.jena.geosparql.assembler.VocabGeoSPARQL.pQueryRewrite;
+import static org.apache.jena.geosparql.assembler.VocabGeoSPARQL.pSpatialIndexFile;
+import static org.apache.jena.geosparql.assembler.VocabGeoSPARQL.pSrsUri;
 import static org.apache.jena.sparql.util.graph.GraphUtils.getBooleanValue;
 
 import java.io.IOException;
@@ -34,8 +42,7 @@ import org.apache.jena.geosparql.configuration.GeoSPARQLConfig;
 import org.apache.jena.geosparql.configuration.GeoSPARQLOperations;
 import org.apache.jena.geosparql.configuration.SrsException;
 import org.apache.jena.geosparql.spatial.SpatialIndexException;
-import org.apache.jena.graph.Graph;
-import org.apache.jena.graph.Node;
+import org.apache.jena.geosparql.spatial.index.v2.SpatialIndexUtils;
 import org.apache.jena.query.Dataset;
 import org.apache.jena.query.DatasetFactory;
 import org.apache.jena.rdf.model.Property;
@@ -56,8 +63,6 @@ public class GeoAssembler extends DatasetAssembler {
 
         // Base dataset.
         DatasetGraph base = super.createBaseDataset(root, pDataset);
-        Graph graph = root.getModel().getGraph();
-        Node subj = root.asNode();
 
         // GeoSPARQL RDFS inference. CLI: names = {"--inference", "-i"}
         boolean inference = true;
@@ -117,9 +122,15 @@ public class GeoAssembler extends DatasetAssembler {
         if (root.hasProperty(pSpatialIndexFile) )
             spatialIndexFilename = GraphUtils.getStringValue(root, pSpatialIndexFile);
 
-        // ---- Build
+        // SRS URI
+        String srsURI = null;
+        if (root.hasProperty(pSrsUri) )
+            srsURI = GraphUtils.getStringValue(root, pSrsUri);
+
 
+        // ---- Build
         Dataset dataset = DatasetFactory.wrap(base);
+        dataset.getContext().set(SpatialIndexUtils.symSrsUri, srsURI);
 
         // Conversion of data. Startup-only.
         // needed for w3c:geo/wgs84_pos#lat/log.
@@ -142,8 +153,8 @@ public class GeoAssembler extends DatasetAssembler {
         //Setup GeoSPARQL
         if (indexEnabled) {
             GeoSPARQLConfig.setupMemoryIndex(indexSizes.get(0), indexSizes.get(1), indexSizes.get(2),
-                                             (long)indexExpiries.get(0), (long)indexExpiries.get(1), (long)indexExpiries.get(2),
-                                             queryRewrite);
+                    (long)indexExpiries.get(0), (long)indexExpiries.get(1), (long)indexExpiries.get(2),
+                    queryRewrite);
         } else {
             GeoSPARQLConfig.setupNoIndex(queryRewrite);
         }
@@ -166,7 +177,7 @@ public class GeoAssembler extends DatasetAssembler {
     }
 
     private static void prepareSpatialExtension(Dataset dataset, String spatialIndex){
-        boolean isEmpty = dataset.calculateRead(()->dataset.isEmpty());
+        boolean isEmpty = dataset.calculateRead(dataset::isEmpty);
         if ( isEmpty && spatialIndex != null ) {
             LOG.warn("Dataset empty. Spatial Index not constructed. Server will require restarting after adding data and any updates to build Spatial Index.");
             return;
@@ -185,23 +196,23 @@ public class GeoAssembler extends DatasetAssembler {
             // file given but empty -> compute and serialize index
             Path spatialIndexPath = Path.of(spatialIndex);
             if ( ! Files.exists(spatialIndexPath) || Files.size(spatialIndexPath) == 0 ) {
-                GeoSPARQLConfig.setupSpatialIndex(dataset, spatialIndexPath.toFile());
+                GeoSPARQLConfig.setupSpatialIndex(dataset, spatialIndexPath);
                 return;
             }
 
             // load and setup the precomputed index
-            GeoSPARQLConfig.setupPrecomputedSpatialIndex(dataset, spatialIndexPath.toFile());
+            GeoSPARQLConfig.setupPrecomputedSpatialIndex(dataset, spatialIndexPath);
         }
         catch (SrsException ex) {
             // Data but no spatial data.
             if ( ! ex.getMessage().startsWith("No SRS found") )
                 throw ex;
-            LOG.warn(ex.getMessage());
+            LOG.warn(ex.getMessage(), ex);
         }
         catch (IOException ex) { IO.exception(ex); return; }
         catch (SpatialIndexException ex) {
             String msg = "Failed to create spatial index: "+ex.getMessage();
-            LOG.error(msg);
+            LOG.error(msg, ex);
             throw new JenaException(msg, ex);
         }
     }
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/assembler/VocabGeoSPARQL.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/assembler/VocabGeoSPARQL.java
index 49dafdd635..5156ea2c28 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/assembler/VocabGeoSPARQL.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/assembler/VocabGeoSPARQL.java
@@ -73,6 +73,10 @@ public class VocabGeoSPARQL {
     // "File to load or store the spatial index. Default to " + SPATIAL_INDEX_FILE + " in TDB folder if using TDB and not set. Otherwise spatial index is not stored.
     public static final Property pSpatialIndexFile = property("spatialIndexFile");
 
+    public static final Property pSpatialIndexPerGraph = property("spatialIndexPerGraph");
+
+    public static final Property pSrsUri = property("srsUri");
+
     // Dataset
     public static final Property pDataset = property("dataset");
 }
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/configuration/GeoSPARQLConfig.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/configuration/GeoSPARQLConfig.java
index 759e5f11f2..4f61fe1cd2 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/configuration/GeoSPARQLConfig.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/configuration/GeoSPARQLConfig.java
@@ -17,11 +17,17 @@
  */
 package org.apache.jena.geosparql.configuration;
 
-import java.io.File;
+import java.nio.file.Path;
 
 import org.apache.jena.geosparql.geof.topological.RelateFF;
 import org.apache.jena.geosparql.implementation.datatype.GeometryDatatype;
-import org.apache.jena.geosparql.implementation.function_registration.*;
+import org.apache.jena.geosparql.implementation.function_registration.Egenhofer;
+import org.apache.jena.geosparql.implementation.function_registration.GeometryProperty;
+import org.apache.jena.geosparql.implementation.function_registration.NonTopological;
+import org.apache.jena.geosparql.implementation.function_registration.RCC8;
+import org.apache.jena.geosparql.implementation.function_registration.Relate;
+import org.apache.jena.geosparql.implementation.function_registration.SimpleFeatures;
+import org.apache.jena.geosparql.implementation.function_registration.Spatial;
 import org.apache.jena.geosparql.implementation.index.GeometryLiteralIndex;
 import org.apache.jena.geosparql.implementation.index.GeometryTransformIndex;
 import org.apache.jena.geosparql.implementation.index.IndexConfiguration;
@@ -31,6 +37,9 @@ import org.apache.jena.geosparql.implementation.registry.SRSRegistry;
 import org.apache.jena.geosparql.implementation.vocabulary.Geo;
 import org.apache.jena.geosparql.spatial.SpatialIndex;
 import org.apache.jena.geosparql.spatial.SpatialIndexException;
+import org.apache.jena.geosparql.spatial.index.compat.SpatialIndexIo;
+import org.apache.jena.geosparql.spatial.index.v2.SpatialIndexIoKryo;
+import org.apache.jena.geosparql.spatial.index.v2.SpatialIndexUtils;
 import org.apache.jena.query.Dataset;
 import org.apache.jena.sparql.function.FunctionRegistry;
 import org.apache.jena.sparql.pfunction.PropertyFunctionRegistry;
@@ -249,7 +258,7 @@ public class GeoSPARQLConfig {
      * @throws SpatialIndexException
      */
     public static final void setupSpatialIndex(Dataset dataset) throws SpatialIndexException {
-        SpatialIndex.buildSpatialIndex(dataset);
+        SpatialIndexUtils.buildSpatialIndex(dataset.asDatasetGraph());
     }
 
     /**
@@ -260,9 +269,9 @@ public class GeoSPARQLConfig {
      * @param spatialIndexFile the file containing the serialized spatial index
      * @throws SpatialIndexException
      */
-    public static final void setupPrecomputedSpatialIndex(Dataset dataset, File spatialIndexFile) throws SpatialIndexException {
-        SpatialIndex si = SpatialIndex.load(spatialIndexFile);
-        SpatialIndex.setSpatialIndex(dataset, si);
+    public static final void setupPrecomputedSpatialIndex(Dataset dataset, Path spatialIndexFile) throws SpatialIndexException {
+        SpatialIndex si = SpatialIndexIo.load(spatialIndexFile);
+        SpatialIndexUtils.setSpatialIndex(dataset, si);
     }
 
     /**
@@ -274,10 +283,14 @@ public class GeoSPARQLConfig {
      * @param spatialIndexFile
      * @throws SpatialIndexException
      */
-    public static final void setupSpatialIndex(Dataset dataset, File spatialIndexFile) throws SpatialIndexException {
-        SpatialIndex.buildSpatialIndex(dataset, spatialIndexFile);
+    public static final void setupSpatialIndex(Dataset dataset, Path spatialIndexFile) throws SpatialIndexException {
+        SpatialIndexIoKryo.buildSpatialIndex(dataset, spatialIndexFile);
     }
 
+//    public static final void setupSpatialIndex(Dataset dataset, Path spatialIndexFile, boolean spatialIndexPerGraph) throws SpatialIndexException {
+//        SpatialIndexIoKryo.buildSpatialIndex(dataset, spatialIndexFile, spatialIndexPerGraph);
+//    }
+
     /**
      * Setup Spatial Index using Dataset using provided SRS URI.<br>
      * Spatial Index written to file once created.
@@ -287,8 +300,8 @@ public class GeoSPARQLConfig {
      * @param spatialIndexFile
      * @throws SpatialIndexException
      */
-    public static final void setupSpatialIndex(Dataset dataset, String srsURI, File spatialIndexFile) throws SpatialIndexException {
-        SpatialIndex.buildSpatialIndex(dataset, srsURI, spatialIndexFile);
+    public static final void setupSpatialIndex(Dataset dataset, String srsURI, Path spatialIndexFile) throws SpatialIndexException {
+        SpatialIndexIoKryo.buildSpatialIndex(dataset, srsURI, spatialIndexFile);
     }
 
     /**
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/configuration/GeoSPARQLOperations.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/configuration/GeoSPARQLOperations.java
index 5ecce51d42..6c28630f13 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/configuration/GeoSPARQLOperations.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/configuration/GeoSPARQLOperations.java
@@ -17,6 +17,8 @@
  */
 package org.apache.jena.geosparql.configuration;
 
+import static org.apache.jena.geosparql.configuration.GeoSPARQLConfig.DECIMAL_PLACES_PRECISION;
+
 import java.io.File;
 import java.io.FileInputStream;
 import java.io.FileOutputStream;
@@ -32,9 +34,9 @@ import java.util.List;
 import java.util.Set;
 import java.util.TreeSet;
 import java.util.UUID;
+
 import org.apache.jena.datatypes.DatatypeFormatException;
 import org.apache.jena.datatypes.RDFDatatype;
-import static org.apache.jena.geosparql.configuration.GeoSPARQLConfig.DECIMAL_PLACES_PRECISION;
 import org.apache.jena.geosparql.implementation.GeometryWrapper;
 import org.apache.jena.geosparql.implementation.datatype.GMLDatatype;
 import org.apache.jena.geosparql.implementation.datatype.GeometryDatatype;
@@ -44,6 +46,7 @@ import org.apache.jena.geosparql.implementation.vocabulary.Geo;
 import org.apache.jena.geosparql.implementation.vocabulary.GeoSPARQL_URI;
 import org.apache.jena.geosparql.implementation.vocabulary.SpatialExtension;
 import org.apache.jena.geosparql.spatial.ConvertLatLon;
+import org.apache.jena.geosparql.spatial.index.v2.SpatialIndexUtils;
 import org.apache.jena.query.Dataset;
 import org.apache.jena.query.DatasetFactory;
 import org.apache.jena.query.ReadWrite;
@@ -63,6 +66,8 @@ import org.apache.jena.reasoner.Reasoner;
 import org.apache.jena.reasoner.ReasonerRegistry;
 import org.apache.jena.riot.Lang;
 import org.apache.jena.riot.RDFDataMgr;
+import org.apache.jena.system.AutoTxn;
+import org.apache.jena.system.Txn;
 import org.apache.jena.util.iterator.ExtendedIterator;
 import org.apache.jena.vocabulary.RDFS;
 import org.opengis.geometry.MismatchedDimensionException;
@@ -501,23 +506,29 @@ public class GeoSPARQLOperations {
      * @return SRS URI
      */
     public static final String findModeSRS(Dataset dataset) throws SrsException {
+        // return SRS if set via assembler config
+        if (dataset.getContext().isDefined(SpatialIndexUtils.symSrsUri)) {
+            return dataset.getContext().getAsString(SpatialIndexUtils.symSrsUri);
+        }
+
         LOGGER.info("Find Mode SRS - Started");
         ModeSRS modeSRS = new ModeSRS();
         //Default Model
-        dataset.begin(ReadWrite.READ);
-        Model defaultModel = dataset.getDefaultModel();
-        modeSRS.search(defaultModel);
+        try (AutoTxn txn = Txn.autoTxn(dataset, ReadWrite.READ)) {
+            Model defaultModel = dataset.getDefaultModel();
+            modeSRS.search(defaultModel);
 
-        //Named Models
-        Iterator<String> graphNames = dataset.listNames();
-        while (graphNames.hasNext()) {
-            String graphName = graphNames.next();
-            Model namedModel = dataset.getNamedModel(graphName);
-            modeSRS.search(namedModel);
-        }
+            //Named Models
+            Iterator<String> graphNames = dataset.listNames();
+            while (graphNames.hasNext()) {
+                String graphName = graphNames.next();
+                Model namedModel = dataset.getNamedModel(graphName);
+                modeSRS.search(namedModel);
+            }
 
-        LOGGER.info("Find Mode SRS - Completed");
-        dataset.end();
+            txn.commit();
+            LOGGER.info("Find Mode SRS - Completed");
+        }
 
         return modeSRS.getModeURI();
     }
@@ -982,24 +993,24 @@ public class GeoSPARQLOperations {
 
     /**
      * Convert the input model to the most frequent coordinate reference system
-     * and default datatype.
+     * and default datatype using WKT geometry representation.
      *
      * @param inputModel
      * @return Output of conversion.
      */
     public static final Model convert(Model inputModel) {
-        return convertSRSDatatype(inputModel, null, null);
+        return convertSRSDatatype(inputModel, null, WKTDatatype.INSTANCE);
     }
 
     /**
-     * Convert the input model to the output coordinate reference system.
+     * Convert the input model to the output coordinate reference system using WKT geometry representation.
      *
      * @param inputModel
      * @param outputSrsURI
      * @return Output of conversion.
      */
     public static final Model convert(Model inputModel, String outputSrsURI) {
-        return convertSRSDatatype(inputModel, outputSrsURI, null);
+        return convertSRSDatatype(inputModel, outputSrsURI, WKTDatatype.INSTANCE);
     }
 
     /**
@@ -1041,24 +1052,25 @@ public class GeoSPARQLOperations {
 
     /**
      * Convert the input dataset to the most frequent coordinate reference
-     * system and default datatype.
+     * system and default datatype using WKT geometry representation.
      *
      * @param dataset
      * @return Converted dataset.
      */
     public static final Dataset convert(Dataset dataset) {
-        return convert(dataset, null, null);
+        return convert(dataset, null, WKTDatatype.INSTANCE);
     }
 
     /**
-     * Convert the input dataset to the output coordinate reference system.
+     * Convert the input dataset to the output coordinate reference system
+     * using WKT geometry representation.
      *
      * @param dataset
      * @param outputSrsURI
      * @return Converted dataset.
      */
     public static final Dataset convert(Dataset dataset, String outputSrsURI) {
-        return convert(dataset, outputSrsURI, null);
+        return convert(dataset, outputSrsURI, WKTDatatype.INSTANCE);
     }
 
     /**
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/configuration/SrsException.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/configuration/SrsException.java
index ebc7d5fb16..cc846afffe 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/configuration/SrsException.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/configuration/SrsException.java
@@ -22,6 +22,7 @@ package org.apache.jena.geosparql.configuration;
  *
  */
 public class SrsException extends RuntimeException {
+    private static final long serialVersionUID = 1L;
 
     public SrsException(String msg) {
         super(msg);
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/geo/topological/GenericPropertyFunction.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geo/topological/GenericPropertyFunction.java
index e389a681c6..4c0c827842 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/geo/topological/GenericPropertyFunction.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geo/topological/GenericPropertyFunction.java
@@ -18,7 +18,6 @@
 package org.apache.jena.geosparql.geo.topological;
 
 import java.util.Collection;
-import java.util.HashSet;
 import java.util.List;
 
 import org.apache.jena.atlas.iterator.Iter;
@@ -29,10 +28,10 @@ import org.apache.jena.geosparql.implementation.vocabulary.Geo;
 import org.apache.jena.geosparql.implementation.vocabulary.SpatialExtension;
 import org.apache.jena.geosparql.spatial.SpatialIndex;
 import org.apache.jena.geosparql.spatial.SpatialIndexException;
+import org.apache.jena.geosparql.spatial.index.v2.SpatialIndexUtils;
 import org.apache.jena.graph.Graph;
 import org.apache.jena.graph.Node;
 import org.apache.jena.graph.Triple;
-import org.apache.jena.rdf.model.Resource;
 import org.apache.jena.sparql.core.Var;
 import org.apache.jena.sparql.engine.ExecutionContext;
 import org.apache.jena.sparql.engine.QueryIterator;
@@ -155,7 +154,7 @@ public abstract class GenericPropertyFunction extends PFuncSimple {
             }
         }
 
-        boolean isSpatialIndex = SpatialIndex.isDefined(execCxt);
+        boolean isSpatialIndex = SpatialIndexUtils.isDefined(execCxt);
         QueryIterator result;
         if (!isSpatialIndex || filterFunction.isDisjoint() || filterFunction.isDisconnected()) {
             //Disjointed so retrieve all cases.
@@ -230,16 +229,18 @@ public abstract class GenericPropertyFunction extends PFuncSimple {
 
             Node geometryLiteral = boundGeometryLiteral.getGeometryLiteral();
 
-            //Perform the search of the Spatial Index of the Dataset.
-            SpatialIndex spatialIndex = SpatialIndex.retrieve(execCxt);
+            // Perform the search of the Spatial Index of the Dataset.
+            SpatialIndex spatialIndex = SpatialIndexUtils.retrieve(execCxt);
             GeometryWrapper geom = GeometryWrapper.extract(geometryLiteral);
             GeometryWrapper transformedGeom = geom.transform(spatialIndex.getSrsInfo());
+
             Envelope searchEnvelope = transformedGeom.getEnvelope();
-            HashSet<Resource> features = spatialIndex.query(searchEnvelope);
+            Node graphName = SpatialIndexUtils.unwrapGraphName(graph);
+            Collection<Node> features = spatialIndex.query(searchEnvelope, graphName);
 
             // Check each of the Features that match the search.
             QueryIterator featuresIter = QueryIterPlainWrapper.create(
-                    Iter.map(features.iterator(), feature -> BindingFactory.binding(binding, unboundVar, feature.asNode())),
+                    Iter.map(features.iterator(), feature -> BindingFactory.binding(binding, unboundVar, feature)),
                     execCxt);
 
             QueryIterator queryIterator = QueryIter.flatMap(featuresIter,
@@ -334,3 +335,4 @@ public abstract class GenericPropertyFunction extends PFuncSimple {
         return filterFunction.exec(subjectGeometryLiteral, objectGeometryLiteral);
     }
 }
+
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/geo/topological/SpatialObjectGeometryLiteral.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geo/topological/SpatialObjectGeometryLiteral.java
index fbb8dbdc2d..42484cec1e 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/geo/topological/SpatialObjectGeometryLiteral.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geo/topological/SpatialObjectGeometryLiteral.java
@@ -27,10 +27,8 @@ import org.apache.jena.geosparql.spatial.ConvertLatLon;
 import org.apache.jena.graph.Graph;
 import org.apache.jena.graph.Node;
 import org.apache.jena.graph.NodeFactory;
-import org.apache.jena.graph.Triple;
 import org.apache.jena.system.G;
 import org.apache.jena.system.RDFDataException;
-import org.apache.jena.util.iterator.ExtendedIterator;
 import org.apache.jena.vocabulary.RDF;
 
 /**
@@ -126,7 +124,6 @@ public class SpatialObjectGeometryLiteral {
 
         if (geometry != null) {
             //Find the Geometry Literal of the Geometry.
-            ExtendedIterator<Triple> iter = graph.find(geometry, Geo.HAS_SERIALIZATION_NODE, null);
             Node literalNode = G.getSP(graph, geometry, Geo.HAS_SERIALIZATION_NODE);
             // If hasSerialization not found then check asWKT.
             if (literalNode == null)
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/DimensionInfo.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/DimensionInfo.java
index 6850974af1..073c71a5be 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/DimensionInfo.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/DimensionInfo.java
@@ -17,11 +17,13 @@
  */
 package org.apache.jena.geosparql.implementation;
 
+import static org.apache.jena.geosparql.implementation.jts.CustomCoordinateSequence.findCoordinateSequenceDimensions;
+
 import java.io.Serializable;
 import java.util.List;
 import java.util.Objects;
+
 import org.apache.jena.geosparql.implementation.jts.CoordinateSequenceDimensions;
-import static org.apache.jena.geosparql.implementation.jts.CustomCoordinateSequence.findCoordinateSequenceDimensions;
 import org.locationtech.jts.geom.Coordinate;
 import org.locationtech.jts.geom.CoordinateXY;
 import org.locationtech.jts.geom.Geometry;
@@ -31,6 +33,7 @@ import org.locationtech.jts.geom.Geometry;
  *
  */
 public class DimensionInfo implements Serializable {
+    private static final long serialVersionUID = 1L;
 
     private final int coordinate;
     private final int spatial;
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/GeometryWrapper.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/GeometryWrapper.java
index d93109f20b..562ccdfaad 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/GeometryWrapper.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/GeometryWrapper.java
@@ -18,8 +18,8 @@
 package org.apache.jena.geosparql.implementation;
 
 import java.io.Serializable;
-import java.lang.invoke.MethodHandles;
 import java.util.Objects;
+
 import org.apache.jena.datatypes.DatatypeFormatException;
 import org.apache.jena.geosparql.configuration.GeoSPARQLConfig;
 import org.apache.jena.geosparql.implementation.datatype.GMLDatatype;
@@ -57,16 +57,13 @@ import org.opengis.referencing.crs.CoordinateReferenceSystem;
 import org.opengis.referencing.operation.MathTransform;
 import org.opengis.referencing.operation.TransformException;
 import org.opengis.util.FactoryException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 /**
  *
  *
  */
 public class GeometryWrapper implements Serializable {
-
-    private static final Logger LOGGER = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+    private static final long serialVersionUID = 1L;
 
     private final DimensionInfo dimensionInfo;
     private final SRSInfo srsInfo;
@@ -449,9 +446,9 @@ public class GeometryWrapper implements Serializable {
             //Convert to WGS84. Use WGS84 and not CRS84 as assuming WGS8 is more prevalent.
             CoordinateReferenceSystem wgs84CRS = SRSRegistry.getCRS(SRS_URI.WGS84_CRS);
             MathTransform transform = MathTransformRegistry.getMathTransform(srsInfo.getCrs(), wgs84CRS);
-            
+
             DirectPosition wgs84Point = transform.transform(point, null);
-            
+
             //Find the UTM zone.
             utmURI = SRSRegistry.findUTMZoneURIFromWGS84(wgs84Point.getOrdinate(0), wgs84Point.getOrdinate(1));
 
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/SRSInfoException.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/SRSInfoException.java
index 59fe658eb1..f3d21079d8 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/SRSInfoException.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/SRSInfoException.java
@@ -22,6 +22,7 @@ package org.apache.jena.geosparql.implementation;
  *
  */
 public class SRSInfoException extends RuntimeException {
+    private static final long serialVersionUID = 1L;
 
     public SRSInfoException(String msg) {
         super(msg);
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/UnitsConversionException.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/UnitsConversionException.java
index 90310bffc9..04f9594029 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/UnitsConversionException.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/UnitsConversionException.java
@@ -22,6 +22,7 @@ package org.apache.jena.geosparql.implementation;
  *
  */
 public class UnitsConversionException extends RuntimeException {
+    private static final long serialVersionUID = 1L;
 
     public UnitsConversionException(String msg) {
         super(msg);
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/UnitsOfMeasure.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/UnitsOfMeasure.java
index fab5643227..d492879592 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/UnitsOfMeasure.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/UnitsOfMeasure.java
@@ -17,12 +17,15 @@
  */
 package org.apache.jena.geosparql.implementation;
 
+import static org.apache.jena.geosparql.configuration.GeoSPARQLOperations.cleanUpPrecision;
+
 import java.io.Serializable;
 import java.util.Objects;
+
 import javax.measure.Quantity;
 import javax.measure.Unit;
 import javax.measure.quantity.Length;
-import static org.apache.jena.geosparql.configuration.GeoSPARQLOperations.cleanUpPrecision;
+
 import org.apache.jena.geosparql.implementation.registry.UnitsRegistry;
 import org.apache.jena.geosparql.implementation.vocabulary.Unit_URI;
 import org.apache.sis.measure.Quantities;
@@ -38,6 +41,7 @@ import org.opengis.referencing.crs.CoordinateReferenceSystem;
  *
  */
 public class UnitsOfMeasure implements Serializable {
+    private static final long serialVersionUID = 1L;
 
     private final Unit<Length> unit;
     private final String unitURI;
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/datatype/GMLDatatype.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/datatype/GMLDatatype.java
index 456eb2bc2f..bc4e9c5d76 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/datatype/GMLDatatype.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/datatype/GMLDatatype.java
@@ -18,6 +18,7 @@
 package org.apache.jena.geosparql.implementation.datatype;
 
 import java.io.IOException;
+
 import org.apache.jena.datatypes.DatatypeFormatException;
 import org.apache.jena.geosparql.implementation.DimensionInfo;
 import org.apache.jena.geosparql.implementation.GeometryWrapper;
@@ -26,8 +27,6 @@ import org.apache.jena.geosparql.implementation.parsers.gml.GMLWriter;
 import org.apache.jena.geosparql.implementation.vocabulary.Geo;
 import org.jdom2.JDOMException;
 import org.locationtech.jts.geom.Geometry;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 /**
  *
@@ -35,9 +34,6 @@ import org.slf4j.LoggerFactory;
  *
  */
 public class GMLDatatype extends GeometryDatatype {
-
-    private static final Logger LOGGER = LoggerFactory.getLogger(GMLDatatype.class);
-
     /**
      * The default GML type URI.
      */
@@ -96,5 +92,4 @@ public class GMLDatatype extends GeometryDatatype {
     public String toString() {
         return "GMLDatatype{" + URI + '}';
     }
-
 }
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/datatype/GeoJSONDatatype.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/datatype/GeoJSONDatatype.java
index 89206a4247..d1bb25d06a 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/datatype/GeoJSONDatatype.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/datatype/GeoJSONDatatype.java
@@ -19,16 +19,11 @@ package org.apache.jena.geosparql.implementation.datatype;
 
 import org.apache.jena.datatypes.BaseDatatype;
 import org.apache.jena.geosparql.implementation.vocabulary.Geo;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 /*
  * this GeoJSONDatatype does not yet do anything other than wrap a literal
  */
 public class GeoJSONDatatype extends BaseDatatype {
-
-    private static final Logger LOGGER = LoggerFactory.getLogger(GeoJSONDatatype.class);
-
     /**
      * The default GML type URI.
      */
@@ -45,5 +40,4 @@ public class GeoJSONDatatype extends BaseDatatype {
     private GeoJSONDatatype() {
         super(URI);
     }
-
 }
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/datatype/WKTDatatype.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/datatype/WKTDatatype.java
index 485ae00403..3f94b8a541 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/datatype/WKTDatatype.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/datatype/WKTDatatype.java
@@ -24,8 +24,6 @@ import org.apache.jena.geosparql.implementation.parsers.wkt.WKTReader;
 import org.apache.jena.geosparql.implementation.parsers.wkt.WKTWriter;
 import org.apache.jena.geosparql.implementation.vocabulary.Geo;
 import org.locationtech.jts.geom.Geometry;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 /**
  * WKTDatatype class allows the URI "geo:wktLiteral" to be used as a datatype
@@ -43,9 +41,6 @@ import org.slf4j.LoggerFactory;
  * specify an explicit spatial reference system URI.
  */
 public class WKTDatatype extends GeometryDatatype {
-
-    private static final Logger LOGGER = LoggerFactory.getLogger(WKTDatatype.class);
-
     /**
      * The default WKT type URI.
      */
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/jts/CustomCoordinateSequence.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/jts/CustomCoordinateSequence.java
index 143dfaedaa..5fb8aeee3e 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/jts/CustomCoordinateSequence.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/jts/CustomCoordinateSequence.java
@@ -17,10 +17,12 @@
  */
 package org.apache.jena.geosparql.implementation.jts;
 
+import static org.apache.jena.geosparql.implementation.WKTLiteralFactory.reducePrecision;
+
 import java.io.Serializable;
 import java.util.Arrays;
 import java.util.List;
-import static org.apache.jena.geosparql.implementation.WKTLiteralFactory.reducePrecision;
+
 import org.locationtech.jts.geom.Coordinate;
 import org.locationtech.jts.geom.CoordinateSequence;
 import org.locationtech.jts.geom.Envelope;
@@ -32,6 +34,7 @@ import org.locationtech.jts.geom.Envelope;
  *
  */
 public class CustomCoordinateSequence implements CoordinateSequence, Serializable {
+    private static final long serialVersionUID = 1L;
 
     private final double[] x;
     private final double[] y;
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/jts/CustomCoordinateSequenceFactory.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/jts/CustomCoordinateSequenceFactory.java
index 04cddc2d34..4a78718037 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/jts/CustomCoordinateSequenceFactory.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/jts/CustomCoordinateSequenceFactory.java
@@ -18,6 +18,7 @@
 package org.apache.jena.geosparql.implementation.jts;
 
 import java.io.Serializable;
+
 import org.locationtech.jts.geom.Coordinate;
 import org.locationtech.jts.geom.CoordinateSequence;
 import org.locationtech.jts.geom.CoordinateSequenceFactory;
@@ -27,6 +28,7 @@ import org.locationtech.jts.geom.CoordinateSequenceFactory;
  *
  */
 public class CustomCoordinateSequenceFactory implements CoordinateSequenceFactory, Serializable {
+    private static final long serialVersionUID = 1L;
 
     @Override
     public CoordinateSequence create(Coordinate[] coordinates) {
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/jts/CustomGeometryFactory.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/jts/CustomGeometryFactory.java
index 61e76dbb13..1c4a84e9fa 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/jts/CustomGeometryFactory.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/jts/CustomGeometryFactory.java
@@ -17,16 +17,19 @@
  */
 package org.apache.jena.geosparql.implementation.jts;
 
+import static org.apache.jena.geosparql.configuration.GeoSPARQLConfig.PRECISION_MODEL_SCALE_FACTOR;
+
 import java.io.Serializable;
+
 import org.locationtech.jts.geom.GeometryFactory;
 import org.locationtech.jts.geom.PrecisionModel;
-import static org.apache.jena.geosparql.configuration.GeoSPARQLConfig.PRECISION_MODEL_SCALE_FACTOR;
 
 /**
  *
  *
  */
 public class CustomGeometryFactory implements Serializable {
+    private static final long serialVersionUID = 1L;
 
     private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory(new PrecisionModel(PRECISION_MODEL_SCALE_FACTOR), 0, new CustomCoordinateSequenceFactory());
 
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/jts/GeometryTransformation.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/jts/GeometryTransformation.java
index c8fcfc758b..7d500734bc 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/jts/GeometryTransformation.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/jts/GeometryTransformation.java
@@ -17,10 +17,11 @@
  */
 package org.apache.jena.geosparql.implementation.jts;
 
-import java.lang.invoke.MethodHandles;
+import static org.apache.jena.geosparql.configuration.GeoSPARQLOperations.cleanUpPrecision;
+
 import java.util.ArrayList;
+
 import org.apache.jena.datatypes.DatatypeFormatException;
-import static org.apache.jena.geosparql.configuration.GeoSPARQLOperations.cleanUpPrecision;
 import org.locationtech.jts.geom.Coordinate;
 import org.locationtech.jts.geom.CoordinateSequence;
 import org.locationtech.jts.geom.Geometry;
@@ -35,8 +36,6 @@ import org.locationtech.jts.geom.Point;
 import org.locationtech.jts.geom.Polygon;
 import org.opengis.referencing.operation.MathTransform;
 import org.opengis.referencing.operation.TransformException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 /**
  *
@@ -44,8 +43,6 @@ import org.slf4j.LoggerFactory;
  */
 public class GeometryTransformation {
 
-    private static final Logger LOGGER = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
-
     public static final Geometry transform(Geometry sourceGeometry, MathTransform transform) throws TransformException {
 
         String geometryType = sourceGeometry.getGeometryType();
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/registry/MathTransformRegistry.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/registry/MathTransformRegistry.java
index c9a92e0be0..3d1498712f 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/registry/MathTransformRegistry.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/registry/MathTransformRegistry.java
@@ -17,7 +17,6 @@
  */
 package org.apache.jena.geosparql.implementation.registry;
 
-import java.lang.invoke.MethodHandles;
 import org.apache.commons.collections4.keyvalue.MultiKey;
 import org.apache.commons.collections4.map.HashedMap;
 import org.apache.commons.collections4.map.MultiKeyMap;
@@ -27,8 +26,6 @@ import org.opengis.referencing.crs.CoordinateReferenceSystem;
 import org.opengis.referencing.operation.MathTransform;
 import org.opengis.referencing.operation.TransformException;
 import org.opengis.util.FactoryException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 /**
  *
@@ -36,7 +33,6 @@ import org.slf4j.LoggerFactory;
  */
 public class MathTransformRegistry {
 
-    private static final Logger LOGGER = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
     private static final MultiKeyMap<CoordinateReferenceSystem, MathTransform> MATH_TRANSFORM_REGISTRY = MultiKeyMap.multiKeyMap(new HashedMap<>());
 
     public synchronized static final MathTransform getMathTransform(CoordinateReferenceSystem sourceCRS, CoordinateReferenceSystem targetCRS) throws FactoryException, MismatchedDimensionException, TransformException {
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/registry/SRSRegistry.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/registry/SRSRegistry.java
index 837290f3ae..746b294deb 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/registry/SRSRegistry.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/registry/SRSRegistry.java
@@ -17,17 +17,19 @@
  */
 package org.apache.jena.geosparql.implementation.registry;
 
+import static org.apache.jena.geosparql.implementation.vocabulary.SRS_URI.EPSG_BASE_SRS_URI;
+
 import java.io.Serializable;
 import java.lang.invoke.MethodHandles;
 import java.text.DecimalFormat;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
+
 import org.apache.jena.geosparql.implementation.SRSInfo;
 import org.apache.jena.geosparql.implementation.SRSInfoException;
 import org.apache.jena.geosparql.implementation.UnitsOfMeasure;
 import org.apache.jena.geosparql.implementation.vocabulary.SRS_URI;
-import static org.apache.jena.geosparql.implementation.vocabulary.SRS_URI.EPSG_BASE_SRS_URI;
 import org.opengis.referencing.crs.CoordinateReferenceSystem;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -37,6 +39,7 @@ import org.slf4j.LoggerFactory;
  *
  */
 public class SRSRegistry implements Serializable {
+    private static final long serialVersionUID = 1L;
 
     private static final Logger LOGGER = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
 
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/registry/UnitsURIException.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/registry/UnitsURIException.java
index 8bd057e85d..d49c807258 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/registry/UnitsURIException.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/registry/UnitsURIException.java
@@ -22,6 +22,7 @@ package org.apache.jena.geosparql.implementation.registry;
  *
  */
 public class UnitsURIException extends RuntimeException {
+    private static final long serialVersionUID = 1L;
 
     public UnitsURIException(String msg) {
         super(msg);
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/kryo/EnvelopeSerializer.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/kryo/EnvelopeSerializer.java
new file mode 100644
index 0000000000..eaeec35e14
--- /dev/null
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/kryo/EnvelopeSerializer.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.jena.geosparql.kryo;
+
+import org.locationtech.jts.geom.Envelope;
+
+import com.esotericsoftware.kryo.Kryo;
+import com.esotericsoftware.kryo.Serializer;
+import com.esotericsoftware.kryo.io.Input;
+import com.esotericsoftware.kryo.io.Output;
+
+public class EnvelopeSerializer extends Serializer<Envelope> {
+    @Override
+    public void write(Kryo kryo, Output output, Envelope envelope) {
+        output.writeDouble(envelope.getMinX());
+        output.writeDouble(envelope.getMaxX());
+        output.writeDouble(envelope.getMinY());
+        output.writeDouble(envelope.getMaxY());
+    }
+
+    @Override
+    public Envelope read(Kryo kryo, Input input, Class<Envelope> type) {
+        double xMin = input.readDouble();
+        double xMax = input.readDouble();
+        double yMin = input.readDouble();
+        double yMax = input.readDouble();
+        if (xMin <= xMax) {
+            return new Envelope(xMin, xMax, yMin, yMax);
+        } else {
+            // Null envelope cannot be constructed using Envelope(xMin, xMax, yMin, yMax)
+            return new Envelope();
+        }
+    }
+}
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/kryo/GeometrySerializerJtsWkb.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/kryo/GeometrySerializerJtsWkb.java
new file mode 100644
index 0000000000..00d66ae7ba
--- /dev/null
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/kryo/GeometrySerializerJtsWkb.java
@@ -0,0 +1,72 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jena.geosparql.kryo;
+
+import org.locationtech.jts.geom.Geometry;
+import org.locationtech.jts.geom.GeometryFactory;
+import org.locationtech.jts.io.ParseException;
+import org.locationtech.jts.io.WKBReader;
+import org.locationtech.jts.io.WKBWriter;
+
+import com.esotericsoftware.kryo.Kryo;
+import com.esotericsoftware.kryo.Serializer;
+import com.esotericsoftware.kryo.io.Input;
+import com.esotericsoftware.kryo.io.Output;
+
+/** Geometry de-/serialization via the WKB facilities of JTS. */
+public class GeometrySerializerJtsWkb
+    extends Serializer<Geometry>
+{
+    private transient WKBReader reader;
+    private transient WKBWriter writer;
+
+    public GeometrySerializerJtsWkb() {
+        this(new GeometryFactory());
+    }
+
+    public GeometrySerializerJtsWkb(GeometryFactory geometryFactory) {
+        this(new WKBReader(geometryFactory), new WKBWriter());
+    }
+
+    public GeometrySerializerJtsWkb(WKBReader reader, WKBWriter writer) {
+        super();
+        this.reader = reader;
+        this.writer = writer;
+    }
+
+    @Override
+    public void write(Kryo kryo, Output output, Geometry geometry) {
+        byte[] data = writer.write(geometry);
+        output.write(data, 0, data.length);
+        kryo.writeClassAndObject(output, geometry.getUserData());
+    }
+
+    @Override
+    public Geometry read(Kryo kryo, Input input, Class<Geometry> type) {
+        byte[] bytes = kryo.readObject(input, byte[].class);
+        Geometry geometry;
+        try {
+            geometry = reader.read(bytes);
+        } catch (ParseException e) {
+            throw new RuntimeException(e);
+        }
+        Object userData = kryo.readClassAndObject(input);
+        geometry.setUserData(userData);
+        return geometry;
+    }
+}
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/kryo/NodeSerializer.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/kryo/NodeSerializer.java
new file mode 100644
index 0000000000..74f950d73e
--- /dev/null
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/kryo/NodeSerializer.java
@@ -0,0 +1,346 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jena.geosparql.kryo;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.stream.Collectors;
+
+import org.apache.jena.datatypes.RDFDatatype;
+import org.apache.jena.datatypes.TypeMapper;
+import org.apache.jena.graph.Graph;
+import org.apache.jena.graph.Node;
+import org.apache.jena.graph.NodeFactory;
+import org.apache.jena.graph.Node_ANY;
+import org.apache.jena.graph.Node_Blank;
+import org.apache.jena.graph.Node_Graph;
+import org.apache.jena.graph.Node_Literal;
+import org.apache.jena.graph.Node_Triple;
+import org.apache.jena.graph.Node_URI;
+import org.apache.jena.graph.Node_Variable;
+import org.apache.jena.graph.TextDirection;
+import org.apache.jena.graph.Triple;
+import org.apache.jena.sparql.core.Quad;
+import org.apache.jena.sparql.core.Var;
+import org.apache.jena.vocabulary.OWL;
+import org.apache.jena.vocabulary.RDF;
+import org.apache.jena.vocabulary.RDFS;
+import org.apache.jena.vocabulary.XSD;
+
+import com.esotericsoftware.kryo.Kryo;
+import com.esotericsoftware.kryo.Serializer;
+import com.esotericsoftware.kryo.io.Input;
+import com.esotericsoftware.kryo.io.Output;
+
+/**
+ * An RDF 1.2 node serializer for kryo.
+ */
+public class NodeSerializer
+    extends Serializer<Node>
+{
+    public static void register(Kryo kryo) {
+        register(kryo, new NodeSerializer());
+    }
+
+    /**
+     * Registers serializers for all Jena Node types - except for Node_Ext and any subclasses.
+     *
+     * For Node_Triple and Node_Graph to function, additional serializers for
+     * Triple and Graph need to be registered.
+     */
+    public static void register(Kryo kryo, Serializer<Node> nodeSerializer) {
+        // Concrete nodes
+        kryo.register(Node.class, nodeSerializer);
+        kryo.register(Node_Blank.class, nodeSerializer);
+        kryo.register(Node_URI.class, nodeSerializer);
+        kryo.register(Node_Literal.class, nodeSerializer);
+        kryo.register(Node_Triple.class, nodeSerializer);
+
+        // Variable nodes
+        kryo.register(Node_Variable.class, nodeSerializer);
+        kryo.register(Var.class, nodeSerializer);
+        kryo.register(Node_ANY.class, nodeSerializer);
+
+        // Extensions
+        kryo.register(Node_Graph.class, nodeSerializer);
+    }
+
+    protected static final byte TYPE_MASK         = (byte)0xf0; // 1111 0000
+    protected static final byte TYPE_IRI          =       0x10; // 0001 0000
+    protected static final byte TYPE_BNODE        =       0x20; // 0010 0000
+    protected static final byte TYPE_LITERAL      =       0x30; // 0011 0000
+    protected static final byte TYPE_TRIPLE       =       0x40; // 0100 0000
+    protected static final byte TYPE_GRAPH        =       0x50; // 0101 0000 - Delegates back to kryo
+
+    protected static final byte TYPE_VAR          = (byte)0x80; // 1000 0000 - NoteFactory.createVariable
+    protected static final byte TYPE_SVAR         = (byte)0x90; // 1001 0000 - Var.alloc
+    protected static final byte TYPE_ANY          = (byte)0xa0; // 1010 0000
+    protected static final byte TYPE_EXT          = (byte)0xb0; // 1011 0000 - Delegates back to kryo
+
+    protected static final byte LITERAL_MASK      =       0x03; // 0000 0011
+    protected static final byte LITERAL_STRING    =       0x00; // 0000 0000
+    protected static final byte LITERAL_LANG      =       0x01; // 0000 0001
+    protected static final byte LITERAL_DTYPE     =       0x02; // 0000 0010
+
+    // Iff (TYPE_LITERAL and LITERAL_LANG) the following applies:
+    protected static final byte LITERAL_LANG_MASK =       0x0c; // 0000 1100
+    protected static final byte LITERAL_LANG_LTR  =       0x04; // 0000 0100
+    protected static final byte LITERAL_LANG_RTL  =       0x08; // 0000 1000
+
+    // Iff (TYPE_IRI) or (TYPE_LITERAL & LITERAL_DYTE): Whether the value field is abbreviated.
+    protected static final byte ABBREV_IRI        =       0x08; // 0000 1000
+
+    private static Map<String, String> globalPrefixToIri = new HashMap<>();
+
+    private Map<String, String> prefixToIri = new HashMap<>();
+    private Map<String, String> iriToPrefix = new HashMap<>();
+
+    static {
+        globalPrefixToIri.put("a", RDF.type.getURI());
+        globalPrefixToIri.put("d", Quad.defaultGraphIRI.getURI());
+        globalPrefixToIri.put("g", Quad.defaultGraphNodeGenerated.getURI());
+        globalPrefixToIri.put("x", XSD.NS);
+        globalPrefixToIri.put("r", RDF.uri);
+        globalPrefixToIri.put("s", RDFS.uri);
+        globalPrefixToIri.put("o", OWL.NS);
+    }
+
+    /**
+     * Takes a guess for the namespace URI string to use in abbreviation.
+     * Finds the part of the IRI string before the last '#', '/', ':' or '.'.
+     *
+     * @param iriString String string
+     * @return String or null
+     */
+    // XXX Should use a trie instead.
+    private static String getCandidateNamespaceIri(String iriString) {
+        int n = iriString.length();
+        int i;
+        loop: for (i = n - 1; i >= 0; --i) {
+            char c = iriString.charAt(i);
+            switch(c) {
+            case '#':
+            case '/':
+            case ':':
+            case '.':
+                break loop;
+            default:
+                // continue
+            }
+        }
+        String result = i >= 0 ? iriString.substring(0, i + 1) : null;
+        return result;
+    }
+
+    private static String encode(Map<String, String> iriToPrefix, String iri) {
+        String result = iriToPrefix.get(iri);
+        if (result == null) {
+            String nsIri = getCandidateNamespaceIri(iri);
+            if (nsIri != null) {
+                String prefix = iriToPrefix.get(nsIri);
+                if (prefix != null) {
+                    result = prefix + ":" + iri.substring(nsIri.length());
+                }
+            }
+        }
+        return result;
+    }
+
+    private static String decode(Map<String, String> prefixToIri, String curie) {
+        String result;
+        int idx = curie.indexOf(':');
+        if (idx < 0) {
+            result = prefixToIri.get(curie);
+        } else {
+            String prefix = curie.substring(0, idx);
+            String iri = prefixToIri.get(prefix);
+            result = iri + curie.substring(idx + 1);
+        }
+        return result;
+    }
+
+    protected TypeMapper typeMapper;
+
+    public NodeSerializer() {
+        this(TypeMapper.getInstance());
+    }
+
+    public NodeSerializer(TypeMapper typeMapper) {
+        this(typeMapper, globalPrefixToIri);
+    }
+
+    public NodeSerializer(TypeMapper typeMapper, Map<String, String> prefixToIri) {
+        super();
+        this.typeMapper = typeMapper;
+        this.prefixToIri = new HashMap<>(prefixToIri);
+
+        this.iriToPrefix = prefixToIri.entrySet().stream()
+                .collect(Collectors.toMap(Entry::getValue, Entry::getKey));
+    }
+
+    @Override
+    public void write(Kryo kryo, Output output, Node node) {
+        if (node.isURI()) {
+            String uri = node.getURI();
+            String curie = encode(iriToPrefix, uri);
+            if (curie != null) {
+                output.writeByte(TYPE_IRI | ABBREV_IRI);
+                output.writeString(curie);
+            } else {
+                output.writeByte(TYPE_IRI);
+                output.writeString(uri);
+            }
+        } else if (node.isLiteral()) {
+            String lex = node.getLiteralLexicalForm();
+            String lang = node.getLiteralLanguage();
+            String dt = node.getLiteralDatatypeURI();
+            TextDirection dir = node.getLiteralBaseDirection();
+
+            if (lang != null && !lang.isEmpty()) {
+                byte langDirBits = dir == null ? (byte)0 : switch(dir) {
+                    case LTR -> LITERAL_LANG_LTR;
+                    case RTL -> LITERAL_LANG_RTL;
+                };
+                output.writeByte(TYPE_LITERAL | LITERAL_LANG | langDirBits);
+                output.writeString(lex);
+                output.writeString(lang);
+            } else if (dt != null && !dt.isEmpty() && !dt.equals(XSD.xstring.getURI())) {
+                String dtCurie = encode(iriToPrefix, dt);
+                if (dtCurie != null) {
+                    output.writeByte(TYPE_LITERAL | LITERAL_DTYPE | ABBREV_IRI);
+                    output.writeString(lex);
+                    output.writeString(dtCurie);
+                } else {
+                    output.writeByte(TYPE_LITERAL | LITERAL_DTYPE);
+                    output.writeString(lex);
+                    output.writeString(dt);
+                }
+            } else {
+                output.writeByte(TYPE_LITERAL);
+                output.writeString(lex);
+            }
+        } else if (node.isBlank()) {
+            output.writeByte(TYPE_BNODE);
+            output.writeString(node.getBlankNodeLabel());
+        } else if (Node.ANY.equals(node)) {
+            output.writeByte(TYPE_ANY);
+        } else if (node.isVariable()) {
+            if (node instanceof Var) {
+                output.writeByte(TYPE_SVAR);
+            } else {
+                output.writeByte(TYPE_VAR);
+            }
+            output.writeString(node.getName());
+        } else if (node.isTripleTerm()) {
+            output.writeByte(TYPE_TRIPLE);
+            kryo.writeObject(output, node.getTriple());
+        } else if (node.isNodeGraph()) {
+            output.writeByte(TYPE_GRAPH);
+            kryo.writeObject(output, node.getGraph());
+        } else if (node.isExt()) {
+            output.writeByte(TYPE_EXT);
+            kryo.writeClassAndObject(output, node);
+        } else {
+            throw new RuntimeException("Unknown node type: " + node);
+        }
+    }
+
+    @Override
+    public Node read(Kryo kryo, Input input, Class<Node> cls) {
+        Node result;
+        String v1, v2;
+        Triple t;
+
+        byte type = input.readByte();
+
+        int typeVal = type & TYPE_MASK;
+        switch (typeVal) {
+            case TYPE_IRI:
+                v1 = input.readString();
+                if ((type & ABBREV_IRI) != 0) {
+                    v1 = decode(prefixToIri, v1);
+                }
+                result = NodeFactory.createURI(v1);
+                break;
+            case TYPE_LITERAL:
+                int subTypeVal = type & LITERAL_MASK;
+                switch (subTypeVal) {
+                    case LITERAL_STRING:
+                        v1 = input.readString();
+                        result = NodeFactory.createLiteralString(v1);
+                        break;
+                    case LITERAL_LANG:
+                        int langDirBits = type & LITERAL_LANG_MASK;
+
+                        TextDirection textDir = switch(langDirBits) {
+                            case LITERAL_LANG_LTR -> TextDirection.LTR;
+                            case LITERAL_LANG_RTL -> TextDirection.RTL;
+                            default -> Node.noTextDirection;
+                        };
+
+                        v1 = input.readString();
+                        v2 = input.readString();
+                        result = NodeFactory.createLiteralDirLang(v1, v2, textDir);
+                        break;
+                    case LITERAL_DTYPE:
+                        v1 = input.readString();
+                        v2 = input.readString();
+                        if ((type & ABBREV_IRI) != 0) {
+                            v2 = decode(prefixToIri, v2);
+                        }
+                        RDFDatatype dtype = typeMapper.getSafeTypeByName(v2);
+                        result = NodeFactory.createLiteralDT(v1, dtype);
+                        break;
+                    default:
+                        throw new RuntimeException("Unknown literal sub-type: " + subTypeVal);
+                }
+                break;
+            case TYPE_BNODE:
+                v1 = input.readString();
+                result = NodeFactory.createBlankNode(v1);
+                break;
+            case TYPE_TRIPLE:
+                t = kryo.readObject(input, Triple.class);
+                result = NodeFactory.createTripleTerm(t);
+                break;
+            case TYPE_GRAPH:
+                Graph graph = kryo.readObject(input, Graph.class);
+                result = new Node_Graph(graph);
+                break;
+            case TYPE_ANY:
+                result = Node.ANY;
+                break;
+            case TYPE_SVAR:
+                v1 = input.readString();
+                result = Var.alloc(v1);
+                break;
+            case TYPE_VAR:
+                v1 = input.readString();
+                result = NodeFactory.createVariable(v1);
+                break;
+            case TYPE_EXT:
+                Object o = kryo.readClassAndObject(input);
+                result = (Node)o;
+                break;
+            default:
+                throw new RuntimeException("Unknown node type: " + typeVal);
+        }
+        return result;
+    }
+}
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/kryo/TripleSerializer.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/kryo/TripleSerializer.java
new file mode 100644
index 0000000000..d4582211d8
--- /dev/null
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/kryo/TripleSerializer.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jena.geosparql.kryo;
+
+import org.apache.jena.graph.Node;
+import org.apache.jena.graph.Triple;
+
+import com.esotericsoftware.kryo.Kryo;
+import com.esotericsoftware.kryo.Serializer;
+import com.esotericsoftware.kryo.io.Input;
+import com.esotericsoftware.kryo.io.Output;
+
+/** Kryo serializer for {@link Triple}. Depends on registered {@link Node} serializers. */
+public class TripleSerializer extends Serializer<Triple> {
+    @Override
+    public void write(Kryo kryo, Output output, Triple obj) {
+        kryo.writeClassAndObject(output, obj.getSubject());
+        kryo.writeClassAndObject(output, obj.getPredicate());
+        kryo.writeClassAndObject(output, obj.getObject());
+    }
+
+    @Override
+    public Triple read(Kryo kryo, Input input, Class<Triple> objClass) {
+        Node s = (Node)kryo.readClassAndObject(input);
+        Node p = (Node)kryo.readClassAndObject(input);
+        Node o = (Node)kryo.readClassAndObject(input);
+        Triple result = Triple.create(s, p, o);
+        return result;
+    }
+}
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SearchEnvelope.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SearchEnvelope.java
index 7ab0ea9879..561a48ebbc 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SearchEnvelope.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SearchEnvelope.java
@@ -18,14 +18,16 @@
 package org.apache.jena.geosparql.spatial;
 
 import java.lang.invoke.MethodHandles;
-import java.util.HashSet;
+import java.util.Collection;
 import java.util.Objects;
+
 import org.apache.jena.geosparql.implementation.GeometryWrapper;
 import org.apache.jena.geosparql.implementation.SRSInfo;
 import org.apache.jena.geosparql.implementation.UnitsOfMeasure;
 import org.apache.jena.geosparql.implementation.great_circle.GreatCirclePointDistance;
 import org.apache.jena.geosparql.implementation.great_circle.LatLonPoint;
-import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.graph.Node;
+import org.apache.jena.sparql.core.Quad;
 import org.apache.jena.sparql.expr.ExprEvalException;
 import org.locationtech.jts.geom.Envelope;
 import org.opengis.geometry.MismatchedDimensionException;
@@ -107,11 +109,28 @@ public class SearchEnvelope {
         return srsInfo;
     }
 
-    public HashSet<Resource> check(SpatialIndex spatialIndex) {
-        HashSet<Resource> features = spatialIndex.query(mainEnvelope);
+
+//    public Collection<Node> check(SpatialIndex spatialIndex) {
+//        Collection<Node> features = spatialIndex.query(mainEnvelope);
+//
+//        if (wrapEnvelope != null) {
+//            Collection<Node> wrapFeatures = spatialIndex.query(wrapEnvelope);
+//            features.addAll(wrapFeatures);
+//        }
+//        return features;
+//    }
+
+    // Check default graph only
+    public Collection<Node> check(SpatialIndex spatialIndex) {
+        return check(spatialIndex, Quad.defaultGraphIRI);
+    }
+
+    // Check within a single graph; null for default graph.
+    public Collection<Node> check(SpatialIndex spatialIndex, Node graph) {
+        Collection<Node> features = spatialIndex.query(mainEnvelope, graph);
 
         if (wrapEnvelope != null) {
-            HashSet<Resource> wrapFeatures = spatialIndex.query(wrapEnvelope);
+            Collection<Node> wrapFeatures = spatialIndex.query(wrapEnvelope, graph);
             features.addAll(wrapFeatures);
         }
         return features;
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SpatialIndex.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SpatialIndex.java
index 63f48f244f..3fb8eb6a1e 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SpatialIndex.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SpatialIndex.java
@@ -17,530 +17,45 @@
  */
 package org.apache.jena.geosparql.spatial;
 
-import java.io.*;
-import java.lang.invoke.MethodHandles;
-import java.nio.file.Files;
 import java.nio.file.Path;
-import java.util.*;
+import java.util.Collection;
 
-import org.apache.jena.atlas.RuntimeIOException;
-import org.apache.jena.atlas.io.IOX;
-import org.apache.jena.geosparql.configuration.GeoSPARQLOperations;
-import org.apache.jena.geosparql.implementation.GeometryWrapper;
 import org.apache.jena.geosparql.implementation.SRSInfo;
-import org.apache.jena.geosparql.implementation.registry.SRSRegistry;
-import org.apache.jena.geosparql.implementation.vocabulary.Geo;
-import org.apache.jena.geosparql.implementation.vocabulary.SRS_URI;
-import org.apache.jena.geosparql.implementation.vocabulary.SpatialExtension;
-import org.apache.jena.query.Dataset;
-import org.apache.jena.query.DatasetFactory;
-import org.apache.jena.query.ReadWrite;
-import org.apache.jena.rdf.model.*;
-import org.apache.jena.sparql.engine.ExecutionContext;
-import org.apache.jena.sparql.util.Context;
-import org.apache.jena.sparql.util.Symbol;
-import org.apache.jena.util.iterator.ExtendedIterator;
+import org.apache.jena.graph.Node;
+import org.apache.jena.sparql.core.Quad;
 import org.locationtech.jts.geom.Envelope;
-import org.locationtech.jts.index.strtree.STRtree;
-import org.opengis.geometry.MismatchedDimensionException;
-import org.opengis.referencing.operation.TransformException;
-import org.opengis.util.FactoryException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
-/**
- * SpatialIndex for testing bounding box collisions between geometries within a
- * Dataset.<br>
- * Queries must be performed using the same SRS URI as the SpatialIndex.<br>
- * The SpatialIndex is added to the Dataset Context when it is built.<br>
- * QueryRewriteIndex is also stored in the SpatialIndex as its content is
- * Dataset specific.
- *
- */
-public class SpatialIndex {
-
-    private static final Logger LOGGER = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
-
-    public static final Symbol SPATIAL_INDEX_SYMBOL = Symbol.create("http://jena.apache.org/spatial#index");
-
-    private transient final SRSInfo srsInfo;
-    private boolean isBuilt;
-    private final STRtree strTree;
-    private static final int MINIMUM_CAPACITY = 2;
-
-    private SpatialIndex() {
-        this.strTree = new STRtree(MINIMUM_CAPACITY);
-        this.isBuilt = true;
-        this.strTree.build();
-        this.srsInfo = SRSRegistry.getSRSInfo(SRS_URI.DEFAULT_WKT_CRS84);
-    }
-
-    /**
-     * Unbuilt Spatial Index with provided capacity.
-     *
-     * @param capacity
-     * @param srsURI
-     */
-    public SpatialIndex(int capacity, String srsURI) {
-        int indexCapacity = capacity < MINIMUM_CAPACITY ? MINIMUM_CAPACITY : capacity;
-        this.strTree = new STRtree(indexCapacity);
-        this.isBuilt = false;
-        this.srsInfo = SRSRegistry.getSRSInfo(srsURI);
-    }
-
-    /**
-     * Built Spatial Index with provided capacity.
-     *
-     * @param spatialIndexItems
-     * @param srsURI
-     * @throws SpatialIndexException
-     */
-    public SpatialIndex(Collection<SpatialIndexItem> spatialIndexItems, String srsURI) throws SpatialIndexException {
-        int indexCapacity = spatialIndexItems.size() < MINIMUM_CAPACITY ? MINIMUM_CAPACITY : spatialIndexItems.size();
-        this.strTree = new STRtree(indexCapacity);
-        insertItems(spatialIndexItems);
-        this.strTree.build();
-        this.isBuilt = true;
-        this.srsInfo = SRSRegistry.getSRSInfo(srsURI);
-    }
+public interface SpatialIndex {
 
     /**
      *
      * @return Information about the SRS used by the SpatialIndex.
      */
-    public SRSInfo getSrsInfo() {
-        return srsInfo;
-    }
+    SRSInfo getSrsInfo();
 
     /**
      *
      * @return True if the SpatialIndex is empty.
      */
-    public boolean isEmpty() {
-        return strTree.isEmpty();
-    }
-
-    /**
-     *
-     * @return True if the SpatialIndex has been built.
-     */
-    public boolean isBuilt() {
-        return isBuilt;
-    }
-
-    /**
-     * Build the Spatial Index. No more items can be added.
-     */
-    public void build() {
-        if (!isBuilt) {
-            strTree.build();
-            isBuilt = true;
-        }
-    }
+    boolean isEmpty();
 
     /**
-     * Items to add to an unbuilt Spatial Index.
-     *
-     * @param indexItems
-     * @throws SpatialIndexException
+     * Returns the number of items in the index.
      */
-    public final void insertItems(Collection<SpatialIndexItem> indexItems) throws SpatialIndexException {
-
-        for (SpatialIndexItem indexItem : indexItems) {
-            insertItem(indexItem.getEnvelope(), indexItem.getItem());
-        }
-    }
+    long getSize();
 
     /**
-     * Item to add to an unbuilt Spatial Index.
+     * Query the index for all matching items in the given graph.
      *
-     * @param envelope
-     * @param item
-     * @throws SpatialIndexException
-     */
-    public final void insertItem(Envelope envelope, Resource item) throws SpatialIndexException {
-        if (!isBuilt) {
-            strTree.insert(envelope, item);
-        } else {
-            throw new SpatialIndexException("SpatialIndex has been built and cannot have additional items.");
-        }
-    }
-
-    @SuppressWarnings("unchecked")
-    public HashSet<Resource> query(Envelope searchEnvelope) {
-        if (!strTree.isEmpty()) {
-            return new HashSet<>(strTree.query(searchEnvelope));
-        } else {
-            return new HashSet<>();
-        }
-    }
-
-    @Override
-    public String toString() {
-        return "SpatialIndex{" + "srsInfo=" + srsInfo + ", isBuilt=" + isBuilt + ", strTree=" + strTree + '}';
-    }
-
-    /**
-     * Retrieve the SpatialIndex from the Context.
-     *
-     * @param execCxt
-     * @return SpatialIndex contained in the Context.
-     * @throws SpatialIndexException
-     */
-    public static final SpatialIndex retrieve(ExecutionContext execCxt) throws SpatialIndexException {
-
-        Context context = execCxt.getContext();
-        SpatialIndex spatialIndex = (SpatialIndex) context.get(SPATIAL_INDEX_SYMBOL, null);
-
-        if (spatialIndex == null) {
-            throw new SpatialIndexException("Dataset Context does not contain SpatialIndex.");
-        }
-
-        return spatialIndex;
-    }
-
-    /**
-     *
-     * @param execCxt
-     * @return True if a SpatialIndex is defined in the ExecutionContext.
-     */
-    public static final boolean isDefined(ExecutionContext execCxt) {
-        Context context = execCxt.getContext();
-        return context.isDefined(SPATIAL_INDEX_SYMBOL);
-    }
-
-    /**
-     * Set the SpatialIndex into the Context of the Dataset for later retrieval
-     * and use in spatial functions.
-     *
-     * @param dataset
-     * @param spatialIndex
-     */
-    public static final void setSpatialIndex(Dataset dataset, SpatialIndex spatialIndex) {
-        Context context = dataset.getContext();
-        context.set(SPATIAL_INDEX_SYMBOL, spatialIndex);
-    }
-
-    /**
-     * Build Spatial Index from all graphs in Dataset.<br>
-     * Dataset contains SpatialIndex in Context.<br>
-     * Spatial Index written to file.
-     *
-     * @param dataset
-     * @param srsURI
-     * @param spatialIndexFile
-     * @return SpatialIndex constructed.
-     * @throws SpatialIndexException
-     */
-    public static SpatialIndex buildSpatialIndex(Dataset dataset, String srsURI, File spatialIndexFile) throws SpatialIndexException {
-
-        SpatialIndex spatialIndex = load(spatialIndexFile);
-
-        if (spatialIndex.isEmpty()) {
-            Collection<SpatialIndexItem> spatialIndexItems = findSpatialIndexItems(dataset, srsURI);
-            save(spatialIndexFile, spatialIndexItems, srsURI);
-            spatialIndex = new SpatialIndex(spatialIndexItems, srsURI);
-            spatialIndex.build();
-        }
-
-        setSpatialIndex(dataset, spatialIndex);
-        return spatialIndex;
-    }
-
-    /**
-     * Build Spatial Index from all graphs in Dataset.<br>
-     * Dataset contains SpatialIndex in Context.<br>
-     * SRS URI based on most frequent found in Dataset.<br>
-     * Spatial Index written to file.
-     *
-     * @param dataset
-     * @param spatialIndexFile
-     * @return SpatialIndex constructed.
-     * @throws SpatialIndexException
-     */
-    public static SpatialIndex buildSpatialIndex(Dataset dataset, File spatialIndexFile) throws SpatialIndexException {
-        String srsURI = GeoSPARQLOperations.findModeSRS(dataset);
-        SpatialIndex spatialIndex = buildSpatialIndex(dataset, srsURI, spatialIndexFile);
-        return spatialIndex;
-    }
-
-    /**
-     * Build Spatial Index from all graphs in Dataset.<br>
-     * Dataset contains SpatialIndex in Context.
-     *
-     * @param dataset
-     * @param srsURI
-     * @return SpatialIndex constructed.
-     * @throws SpatialIndexException
-     */
-    public static SpatialIndex buildSpatialIndex(Dataset dataset, String srsURI) throws SpatialIndexException {
-        LOGGER.info("Building Spatial Index - Started");
-
-        Collection<SpatialIndexItem> items = findSpatialIndexItems(dataset, srsURI);
-        SpatialIndex spatialIndex = new SpatialIndex(items, srsURI);
-        spatialIndex.build();
-        setSpatialIndex(dataset, spatialIndex);
-        LOGGER.info("Building Spatial Index - Completed");
-        return spatialIndex;
-    }
-
-    /**
-     * Find Spatial Index Items from all graphs in Dataset.<br>
-     *
-     * @param dataset
-     * @param srsURI
-     * @return SpatialIndexItems found.
-     * @throws SpatialIndexException
-     */
-    public static Collection<SpatialIndexItem> findSpatialIndexItems(Dataset dataset, String srsURI) throws SpatialIndexException {
-        //Default Model
-        dataset.begin(ReadWrite.READ);
-        Model defaultModel = dataset.getDefaultModel();
-        Collection<SpatialIndexItem> items = getSpatialIndexItems(defaultModel, srsURI);
-
-        //Named Models
-        Iterator<String> graphNames = dataset.listNames();
-        while (graphNames.hasNext()) {
-            String graphName = graphNames.next();
-            Model namedModel = dataset.getNamedModel(graphName);
-            Collection<SpatialIndexItem> graphItems = getSpatialIndexItems(namedModel, srsURI);
-            items.addAll(graphItems);
-        }
-
-        dataset.end();
-
-        return items;
-    }
-
-    /**
-     * Build Spatial Index from all graphs in Dataset.<br>
-     * Dataset contains SpatialIndex in Context.<br>
-     * SRS URI based on most frequent found in Dataset.
-     *
-     * @param dataset
-     * @return SpatialIndex constructed.
-     * @throws SpatialIndexException
-     */
-    public static SpatialIndex buildSpatialIndex(Dataset dataset) throws SpatialIndexException {
-        String srsURI = GeoSPARQLOperations.findModeSRS(dataset);
-        SpatialIndex spatialIndex = buildSpatialIndex(dataset, srsURI);
-        return spatialIndex;
-    }
-
-    /**
-     * Wrap Model in a Dataset and build SpatialIndex.
-     *
-     * @param model
-     * @param srsURI
-     * @return Dataset with default Model and SpatialIndex in Context.
-     * @throws SpatialIndexException
-     */
-    public static final Dataset wrapModel(Model model, String srsURI) throws SpatialIndexException {
-
-        Dataset dataset = DatasetFactory.createTxnMem();
-        dataset.setDefaultModel(model);
-        buildSpatialIndex(dataset, srsURI);
-
-        return dataset;
-    }
-
-    /**
-     * Wrap Model in a Dataset and build SpatialIndex.
-     *
-     * @param model
-     * @return Dataset with default Model and SpatialIndex in Context.
-     * @throws SpatialIndexException
-     */
-    public static final Dataset wrapModel(Model model) throws SpatialIndexException {
-        Dataset dataset = DatasetFactory.createTxnMem();
-        dataset.setDefaultModel(model);
-        String srsURI = GeoSPARQLOperations.findModeSRS(dataset);
-        buildSpatialIndex(dataset, srsURI);
-
-        return dataset;
-    }
-
-    /**
-     * Find items from the Model transformed to the SRS URI.
-     *
-     * @param model
-     * @param srsURI
-     * @return Items found in the Model in the SRS URI.
-     * @throws SpatialIndexException
-     */
-    public static final Collection<SpatialIndexItem> getSpatialIndexItems(Model model, String srsURI) throws SpatialIndexException {
-
-        List<SpatialIndexItem> items = new ArrayList<>();
-
-        //Only add one set of statements as a converted dataset will duplicate the same info.
-        if (model.contains(null, Geo.HAS_GEOMETRY_PROP, (Resource) null)) {
-            LOGGER.info("Feature-hasGeometry-Geometry statements found.");
-            if (model.contains(null, SpatialExtension.GEO_LAT_PROP, (Literal) null)) {
-                LOGGER.warn("Lat/Lon Geo predicates also found but will not be added to index.");
-            }
-            Collection<SpatialIndexItem> geometryLiteralItems = getGeometryLiteralIndexItems(model, srsURI);
-            items.addAll(geometryLiteralItems);
-        } else if (model.contains(null, SpatialExtension.GEO_LAT_PROP, (Literal) null)) {
-            LOGGER.info("Geo predicate statements found.");
-            Collection<SpatialIndexItem> geoPredicateItems = getGeoPredicateIndexItems(model, srsURI);
-            items.addAll(geoPredicateItems);
-        }
-
-        return items;
-    }
-
-    /**
-     *
-     * @param model
-     * @param srsURI
-     * @return GeometryLiteral items prepared for adding to SpatialIndex.
-     * @throws SpatialIndexException
-     */
-    private static Collection<SpatialIndexItem> getGeometryLiteralIndexItems(Model model, String srsURI) throws SpatialIndexException {
-        List<SpatialIndexItem> items = new ArrayList<>();
-        StmtIterator stmtIt = model.listStatements(null, Geo.HAS_GEOMETRY_PROP, (Resource) null);
-        while (stmtIt.hasNext()) {
-            Statement stmt = stmtIt.nextStatement();
-
-            Resource feature = stmt.getSubject();
-            Resource geometry = stmt.getResource();
-
-            ExtendedIterator<RDFNode> nodeIter = model.listObjectsOfProperty(geometry, Geo.HAS_SERIALIZATION_PROP);
-            if (!nodeIter.hasNext()) {
-                NodeIterator wktNodeIter = model.listObjectsOfProperty(geometry, Geo.AS_WKT_PROP);
-                NodeIterator gmlNodeIter = model.listObjectsOfProperty(geometry, Geo.AS_GML_PROP);
-                nodeIter = wktNodeIter.andThen(gmlNodeIter);
-            }
-
-            while (nodeIter.hasNext()) {
-                Literal geometryLiteral = nodeIter.next().asLiteral();
-                GeometryWrapper geometryWrapper = GeometryWrapper.extract(geometryLiteral);
-
-                try {
-                    //Ensure all entries in the target SRS URI.
-                    GeometryWrapper transformedGeometryWrapper = geometryWrapper.convertSRS(srsURI);
-
-                    Envelope envelope = transformedGeometryWrapper.getEnvelope();
-                    SpatialIndexItem item = new SpatialIndexItem(envelope, feature);
-                    items.add(item);
-                } catch (FactoryException | MismatchedDimensionException | TransformException ex) {
-                    throw new SpatialIndexException("Transformation Exception: " + geometryLiteral + ". " + ex.getMessage());
-                }
-
-            }
-        }
-        return items;
-    }
-
-    /**
-     *
-     * @param model
-     * @param srsURI
-     * @return Geo predicate objects prepared for adding to SpatialIndex.
-     */
-    private static Collection<SpatialIndexItem> getGeoPredicateIndexItems(Model model, String srsURI) throws SpatialIndexException {
-        List<SpatialIndexItem> items = new ArrayList<>();
-        ResIterator resIt = model.listResourcesWithProperty(SpatialExtension.GEO_LAT_PROP);
-
-        while (resIt.hasNext()) {
-            Resource feature = resIt.nextResource();
-
-            Literal lat = feature.getRequiredProperty(SpatialExtension.GEO_LAT_PROP).getLiteral();
-            Literal lon = feature.getProperty(SpatialExtension.GEO_LON_PROP).getLiteral();
-            if (lon == null) {
-                LOGGER.warn("Geo predicates: latitude found but not longitude. " + feature);
-                continue;
-            }
-
-            Literal latLonPoint = ConvertLatLon.toLiteral(lat.getFloat(), lon.getFloat());
-            GeometryWrapper geometryWrapper = GeometryWrapper.extract(latLonPoint);
-
-            try {
-                //Ensure all entries in the target SRS URI.
-                GeometryWrapper transformedGeometryWrapper = geometryWrapper.convertSRS(srsURI);
-
-                Envelope envelope = transformedGeometryWrapper.getEnvelope();
-                SpatialIndexItem item = new SpatialIndexItem(envelope, feature);
-                items.add(item);
-            } catch (FactoryException | MismatchedDimensionException | TransformException ex) {
-                throw new SpatialIndexException("Transformation Exception: " + geometryWrapper.getLexicalForm() + ". " + ex.getMessage());
-            }
-        }
-        return items;
-    }
-
-    /**
-     * Load a SpatialIndex from file.<br>
-     * Index will be built and empty if file does not exist or is null.
-     *
-     * @param spatialIndexFile
-     * @return Built Spatial Index.
-     * @throws SpatialIndexException
-     */
-    public static final SpatialIndex load(File spatialIndexFile) throws SpatialIndexException {
-
-        if (spatialIndexFile != null && spatialIndexFile.exists()) {
-            LOGGER.info("Loading Spatial Index - Started: {}", spatialIndexFile.getAbsolutePath());
-            //Cannot directly store the SpatialIndex due to Resources not being serializable, use SpatialIndexStorage class.
-            try (ObjectInputStream in = new ObjectInputStream(new FileInputStream(spatialIndexFile))) {
-                SpatialIndexStorage storage = (SpatialIndexStorage) in.readObject();
-
-                SpatialIndex spatialIndex = storage.getSpatialIndex();
-                LOGGER.info("Loading Spatial Index - Completed: {}", spatialIndexFile.getAbsolutePath());
-                return spatialIndex;
-            } catch (ClassNotFoundException | IOException ex) {
-                throw new SpatialIndexException("Loading Exception: " + ex.getMessage(), ex);
-            }
-        } else {
-            return new SpatialIndex();
-        }
-    }
-
-    /**
-     * Save SpatialIndex contents to file.
-     *
-     * @param spatialIndexFileURI
-     * @param spatialIndexItems
-     * @param srsURI
-     * @throws SpatialIndexException
-     */
-    public static final void save(String spatialIndexFileURI, Collection<SpatialIndexItem> spatialIndexItems, String srsURI) throws SpatialIndexException {
-        save(new File(spatialIndexFileURI), spatialIndexItems, srsURI);
-    }
-
-    /**
-     * Save SpatialIndex contents to file.
+     * The default graph can be referenced with null or any value for which {@link Quad#isDefaultGraph()} returns true.
+     * The union graph (= all named graphs but not the default graph) can be referenced with {@link Quad#unionGraph}.
      *
-     * @param spatialIndexFile
-     * @param spatialIndexItems
-     * @param srsURI
-     * @throws SpatialIndexException
+     * @param searchEnvelope The bounding box for which to find overlapping items.
+     * @param graph The graph to which to restrict the query.
+     * @return The collection of nodes whose envelopes overlap with the search envelope.
      */
-    public static final void save(File spatialIndexFile, Collection<SpatialIndexItem> spatialIndexItems, String srsURI) throws SpatialIndexException {
+    Collection<Node> query(Envelope searchEnvelope, Node graph);
 
-        //Cannot directly store the SpatialIndex due to Resources not being serializable, use SpatialIndexStorage class.
-        if (spatialIndexFile != null) {
-            LOGGER.info("Saving Spatial Index - Started: {}", spatialIndexFile.getAbsolutePath());
-            SpatialIndexStorage storage = new SpatialIndexStorage(spatialIndexItems, srsURI);
-            String filename = spatialIndexFile.getAbsolutePath();
-            Path file = Path.of(filename);
-            Path tmpFile = IOX.uniqueDerivedPath(file, null);
-            try {
-                Files.deleteIfExists(file);
-            } catch (IOException ex) {
-                throw new SpatialIndexException("Failed to delete file: " + ex.getMessage());
-            }
-            try {
-                IOX.safeWriteOrCopy(file, tmpFile,
-                                    out->{
-                                        ObjectOutputStream oos = new ObjectOutputStream(out);
-                                        oos.writeObject(storage);
-                                    });
-            } catch (RuntimeIOException ex) {
-                throw new SpatialIndexException("Save Exception: " + ex.getMessage());
-            } finally {
-                LOGGER.info("Saving Spatial Index - Completed: {}", spatialIndexFile.getAbsolutePath());
-            }
-        }
-    }
+    Path getLocation();
+    void setLocation(Path location);
 }
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SpatialIndexException.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SpatialIndexException.java
index a50bd949fb..6928e3a099 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SpatialIndexException.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SpatialIndexException.java
@@ -22,6 +22,7 @@ package org.apache.jena.geosparql.spatial;
  *
  */
 public class SpatialIndexException extends Exception {
+    private static final long serialVersionUID = 1L;
 
     public SpatialIndexException(String msg) {
         super(msg);
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SpatialIndexFindUtils.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SpatialIndexFindUtils.java
new file mode 100644
index 0000000000..09120eba77
--- /dev/null
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SpatialIndexFindUtils.java
@@ -0,0 +1,185 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jena.geosparql.spatial;
+
+import java.lang.invoke.MethodHandles;
+import java.util.Iterator;
+
+import org.apache.jena.atlas.iterator.Iter;
+import org.apache.jena.atlas.iterator.IteratorCloseable;
+import org.apache.jena.geosparql.implementation.GeometryWrapper;
+import org.apache.jena.geosparql.implementation.vocabulary.Geo;
+import org.apache.jena.geosparql.implementation.vocabulary.SpatialExtension;
+import org.apache.jena.graph.Graph;
+import org.apache.jena.graph.Node;
+import org.apache.jena.graph.Triple;
+import org.apache.jena.sparql.core.DatasetGraph;
+import org.apache.jena.system.G;
+import org.locationtech.jts.geom.Envelope;
+import org.opengis.geometry.MismatchedDimensionException;
+import org.opengis.referencing.operation.TransformException;
+import org.opengis.util.FactoryException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class SpatialIndexFindUtils {
+    private static final Logger LOGGER = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+    /**
+     * Find Spatial Index Items from all graphs in Dataset.<br>
+     *
+     * @param datasetGraph
+     * @param srsURI
+     * @return SpatialIndexItems found.
+     */
+    public static IteratorCloseable<SpatialIndexItem> findSpatialIndexItems(DatasetGraph datasetGraph, String srsURI) {
+        Graph defaultGraph = datasetGraph.getDefaultGraph();
+        IteratorCloseable<SpatialIndexItem> itemsIter = findSpatialIndexItems(defaultGraph, srsURI);
+        try {
+            //Named Models
+            Iterator<Node> graphNodeIt = datasetGraph.listGraphNodes();
+            Iterator<SpatialIndexItem> namedGraphItemsIt = Iter.iter(graphNodeIt).flatMap(graphNode -> {
+                Graph namedGraph = datasetGraph.getGraph(graphNode);
+                IteratorCloseable<SpatialIndexItem> graphItems = findSpatialIndexItems(namedGraph, srsURI);
+                return graphItems;
+            });
+            itemsIter = Iter.iter(itemsIter).append(namedGraphItemsIt);
+        } catch(Throwable t) {
+            t.addSuppressed(new RuntimeException("Failure during findSpatialIndexItems.", t));
+            Iter.close(itemsIter);
+            throw t;
+        }
+        return itemsIter;
+    }
+
+    /**
+     * Find items from the Model transformed to the SRS URI.
+     *
+     * @param graph
+     * @param srsURI
+     * @return Items found in the Model in the SRS URI.
+     */
+    public static final IteratorCloseable<SpatialIndexItem> findSpatialIndexItems(Graph graph, String srsURI) {
+        IteratorCloseable<SpatialIndexItem> result;
+        // Only add one set of statements as a converted dataset will duplicate the same info.
+        if (graph.contains(null, Geo.HAS_GEOMETRY_NODE, null)) {
+            LOGGER.info("Feature-hasGeometry-Geometry statements found.");
+            if (graph.contains(null, SpatialExtension.GEO_LAT_NODE, null)) {
+                LOGGER.warn("Lat/Lon Geo predicates also found but will not be added to index.");
+            }
+            result = findGeometryIndexItems(graph, srsURI);
+        } else if (graph.contains(null, SpatialExtension.GEO_LAT_NODE, null)) {
+            LOGGER.info("Geo predicate statements found.");
+            result = findGeoPredicateIndexItems(graph, srsURI);
+        } else {
+            result = Iter.empty();
+        }
+        return result;
+    }
+
+    /**
+     *
+     * @param graph
+     * @param srsURI
+     * @return SpatialIndexItem items prepared for adding to SpatialIndex.
+     */
+    public static IteratorCloseable<SpatialIndexItem> findGeometryIndexItems(Graph graph, String srsURI) {
+        Iterator<Triple> stmtIter = graph.find(null, Geo.HAS_GEOMETRY_NODE, null);
+        IteratorCloseable<SpatialIndexItem> result = Iter.iter(stmtIter).flatMap(stmt -> {
+            Node feature = stmt.getSubject();
+            Node geometry = stmt.getObject();
+
+            Iterator<Node> nodeIter = G.iterSP(graph, geometry, Geo.HAS_SERIALIZATION_NODE);
+
+            // XXX If there is a super-property then the concrete serializations are not tried.
+            try {
+                if (!nodeIter.hasNext()) {
+                    Iter.close(nodeIter);
+
+                    Iterator<Node> wktNodeIter = G.iterSP(graph, geometry, Geo.AS_WKT_NODE);
+                    nodeIter = wktNodeIter;
+
+                    Iterator<Node> gmlNodeIter = G.iterSP(graph, geometry, Geo.AS_GML_NODE);
+                    nodeIter = Iter.append(wktNodeIter, gmlNodeIter);
+                }
+            } catch (Throwable t) {
+                t.addSuppressed(new RuntimeException("Error encountered.", t));
+                Iter.close(nodeIter);
+                throw t;
+            }
+
+            Iterator<SpatialIndexItem> itemIter = Iter.map(nodeIter, geometryNode -> {
+                GeometryWrapper geometryWrapper = GeometryWrapper.extract(geometryNode);
+                SpatialIndexItem item = makeSpatialIndexItem(feature, geometryWrapper, srsURI);
+                return item;
+            });
+            return itemIter;
+        });
+        return result;
+    }
+
+    /**
+     * 
+     * @param graph
+     * @param srsURI
+     * @return Geo predicate objects prepared for adding to SpatialIndex.
+     */
+    public static IteratorCloseable<SpatialIndexItem> findGeoPredicateIndexItems(Graph graph, String srsURI) {
+        Iterator<Triple> latIt = graph.find(Node.ANY, SpatialExtension.GEO_LAT_NODE, Node.ANY);
+        IteratorCloseable<SpatialIndexItem> result = Iter.iter(latIt).flatMap(triple -> {
+            Node feature = triple.getSubject();
+            Node lat = triple.getObject();
+
+            // Silently create the cross-product between lats and lons.
+            Iterator<Node> lons = G.iterSP(graph, feature, SpatialExtension.GEO_LON_NODE);
+            // LOGGER.warn("Geo predicates: also found but will not be added to index.");
+
+
+            // int[] lonCounter = {0};
+            Iterator<SpatialIndexItem> r = Iter.iter(lons).map(lon -> {
+//            	if (lonCounter[0] == 1) {
+//            		LOGGER.warn("Geo predicates: multiple longitudes on feature " + feature);
+//            	}
+//            	++lonCounter[0];
+                GeometryWrapper geometryWrapper = ConvertLatLon.toGeometryWrapper(lat, lon);
+                SpatialIndexItem item = makeSpatialIndexItem(feature, geometryWrapper, srsURI);
+                return item;
+            });
+            return r;
+        });
+        return result;
+    }
+
+    public static SpatialIndexItem makeSpatialIndexItem(Node feature, GeometryWrapper geometryWrapper, String srsURI) {
+        //Ensure all entries in the target SRS URI.
+        GeometryWrapper transformedGeometryWrapper = unsafeConvert(geometryWrapper, srsURI);
+        Envelope envelope = transformedGeometryWrapper.getEnvelope();
+        SpatialIndexItem item = new SpatialIndexItem(envelope, feature);
+        return item;
+    }
+
+    public static GeometryWrapper unsafeConvert(GeometryWrapper geometryWrapper, String srsURI) {
+        GeometryWrapper result;
+        try {
+            result = geometryWrapper.convertSRS(srsURI);
+        } catch (MismatchedDimensionException | FactoryException | TransformException e) {
+            throw new RuntimeException(e);
+        }
+        return result;
+    }
+}
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SpatialIndexItem.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SpatialIndexItem.java
index 8e6e7a0386..1abff73da3 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SpatialIndexItem.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SpatialIndexItem.java
@@ -17,7 +17,8 @@
  */
 package org.apache.jena.geosparql.spatial;
 
-import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.graph.Node;
+import org.apache.jena.rdf.model.RDFNode;
 import org.locationtech.jts.geom.Envelope;
 
 /**
@@ -27,9 +28,23 @@ import org.locationtech.jts.geom.Envelope;
 public class SpatialIndexItem {
 
     private final Envelope envelope;
-    private final Resource item;
+    private final Node item;
 
-    public SpatialIndexItem(Envelope envelope, Resource item) {
+    @Deprecated // (forRemoval = true)
+    public static SpatialIndexItem of(Envelope envelope, RDFNode item) {
+        return new SpatialIndexItem(envelope, item);
+    }
+
+    public static SpatialIndexItem of(Envelope envelope, Node node) {
+        return new SpatialIndexItem(envelope, node);
+    }
+
+    @Deprecated // (forRemoval = true)
+    public SpatialIndexItem(Envelope envelope, RDFNode item) {
+        this(envelope, item.asNode());
+    }
+
+    public SpatialIndexItem(Envelope envelope, Node item) {
         this.envelope = envelope;
         this.item = item;
     }
@@ -38,7 +53,7 @@ public class SpatialIndexItem {
         return envelope;
     }
 
-    public Resource getItem() {
+    public Node getItem() {
         return item;
     }
 
@@ -46,5 +61,4 @@ public class SpatialIndexItem {
     public String toString() {
         return "SpatialIndexItem{" + "envelope=" + envelope + ", item=" + item + '}';
     }
-
 }
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SpatialIndexStorage.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SpatialIndexStorage.java
index 410e8b4b34..dacaba1915 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SpatialIndexStorage.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SpatialIndexStorage.java
@@ -21,6 +21,8 @@ import java.io.Serializable;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
+
+import org.apache.jena.geosparql.spatial.index.v1.SpatialIndexV1;
 import org.apache.jena.rdf.model.Resource;
 import org.apache.jena.rdf.model.ResourceFactory;
 import org.locationtech.jts.geom.Envelope;
@@ -29,6 +31,7 @@ import org.locationtech.jts.geom.Envelope;
  * Spatial Index Items in a Serializable form for file reading or writing.
  *
  */
+@Deprecated /** Serializable java class of spatial index v1. Moving this class would break Java serialization. */
 public class SpatialIndexStorage implements Serializable {
 
     private final String srsURI;
@@ -41,7 +44,7 @@ public class SpatialIndexStorage implements Serializable {
         this.storageItems = new ArrayList<>(spatialIndexItems.size());
 
         for (SpatialIndexItem spatialIndexItem : spatialIndexItems) {
-            StorageItem storageItem = new StorageItem(spatialIndexItem.getEnvelope(), spatialIndexItem.getItem());
+            StorageItem storageItem = new StorageItem(spatialIndexItem.getEnvelope(), spatialIndexItem.getItem().getURI());
             storageItems.add(storageItem);
         }
     }
@@ -62,8 +65,8 @@ public class SpatialIndexStorage implements Serializable {
         return indexItems;
     }
 
-    public SpatialIndex getSpatialIndex() throws SpatialIndexException {
-        return new SpatialIndex(getIndexItems(), srsURI);
+    public SpatialIndexV1 getSpatialIndex() throws SpatialIndexException {
+        return new SpatialIndexV1(getIndexItems(), srsURI);
     }
 
     private class StorageItem implements Serializable {
@@ -76,6 +79,11 @@ public class SpatialIndexStorage implements Serializable {
             this.uri = item.getURI();
         }
 
+        public StorageItem(Envelope envelope, String uri) {
+            this.envelope = envelope;
+            this.uri = uri;
+        }
+
         public Envelope getEnvelope() {
             return envelope;
         }
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/compat/SpatialIndexIo.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/compat/SpatialIndexIo.java
new file mode 100644
index 0000000000..8bd7f0b70f
--- /dev/null
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/compat/SpatialIndexIo.java
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jena.geosparql.spatial.index.compat;
+
+import java.lang.invoke.MethodHandles;
+import java.nio.file.Path;
+
+import org.apache.jena.geosparql.spatial.SpatialIndex;
+import org.apache.jena.geosparql.spatial.SpatialIndexException;
+import org.apache.jena.geosparql.spatial.index.v1.SpatialIndexAdapterV1;
+import org.apache.jena.geosparql.spatial.index.v2.SpatialIndexIoKryo;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@SuppressWarnings("removal")
+public class SpatialIndexIo {
+    private static final Logger LOGGER = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+    /** Attempt to load a spatial index from file using all supported formats. */
+    public static final SpatialIndex load(Path spatialIndexFile) throws SpatialIndexException {
+        return load(spatialIndexFile, false);
+    }
+
+    /**
+     * Attempt to load a spatial index from file using all supported formats.
+     * This method should only be used for testing as it allows suppressing warnings when loading legacy index formats.
+     */
+    public static final SpatialIndex load(Path spatialIndexFile, boolean suppressLegacyWarnings) throws SpatialIndexException {
+        SpatialIndex result;
+        try {
+            result = SpatialIndexIoKryo.load(spatialIndexFile);
+        } catch (Throwable t1) {
+            if (!suppressLegacyWarnings) {
+                LOGGER.warn("Failed to load spatial index with latest format. Trying legacy formats...", t1);
+            }
+            try {
+                org.apache.jena.geosparql.spatial.index.v1.SpatialIndexV1 v1 = org.apache.jena.geosparql.spatial.index.v1.SpatialIndexV1.load(spatialIndexFile.toFile());
+                result = new SpatialIndexAdapterV1(v1);
+
+                if (!suppressLegacyWarnings) {
+                    LOGGER.warn("Successfully loaded spatial index with legacy format v1. Upgrade advised.");
+                }
+            } catch (Throwable t2) {
+                LOGGER.warn("Failed to load spatial index legacy format.", t2);
+                t1.addSuppressed(new RuntimeException("Failed to load spatial index with any format.", t2));
+                throw t1;
+            }
+        }
+        return result;
+    }
+}
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v1/SpatialIndexAdapterV1.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v1/SpatialIndexAdapterV1.java
new file mode 100644
index 0000000000..9f33eeae15
--- /dev/null
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v1/SpatialIndexAdapterV1.java
@@ -0,0 +1,84 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jena.geosparql.spatial.index.v1;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+import org.apache.jena.geosparql.implementation.SRSInfo;
+import org.apache.jena.geosparql.spatial.SpatialIndex;
+import org.apache.jena.graph.Node;
+import org.apache.jena.rdf.model.RDFNode;
+import org.apache.jena.rdf.model.Resource;
+import org.locationtech.jts.geom.Envelope;
+
+/** Adapter class for spatial index v1. */
+@Deprecated(forRemoval=true)
+public class SpatialIndexAdapterV1
+    implements SpatialIndex
+{
+    protected org.apache.jena.geosparql.spatial.index.v1.SpatialIndexV1 v1;
+
+    public SpatialIndexAdapterV1(org.apache.jena.geosparql.spatial.index.v1.SpatialIndexV1 v1) {
+        super();
+        this.v1 = v1;
+    }
+
+    @Override
+    public SRSInfo getSrsInfo() {
+        return v1.getSrsInfo();
+    }
+
+    @Override
+    public boolean isEmpty() {
+        return v1.isEmpty();
+    }
+
+    @Override
+    public long getSize() {
+        return -1;
+    }
+
+    protected Collection<Node> adapt(Collection<? extends RDFNode> resources) {
+        Collection<Node> result = resources.stream()
+            .map(RDFNode::asNode)
+            .collect(Collectors.toCollection(HashSet::new));
+        return result;
+    }
+
+    @Override
+    public Collection<Node> query(Envelope searchEnvelope, Node graphName) {
+        HashSet<Resource> resources = v1.query(searchEnvelope);
+        return adapt(resources);
+    }
+
+    @Override
+    public Path getLocation() {
+        Path result = Optional.ofNullable(v1.getLocation()).map(File::toPath).orElse(null);
+        return result;
+    }
+
+    @Override
+    public void setLocation(Path location) {
+        v1.setLocation(location == null ? null : location.toFile());
+    }
+}
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SpatialIndex.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v1/SpatialIndexV1.java
similarity index 84%
copy from jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SpatialIndex.java
copy to jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v1/SpatialIndexV1.java
index 63f48f244f..cc89c70fea 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SpatialIndex.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v1/SpatialIndexV1.java
@@ -15,13 +15,21 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.jena.geosparql.spatial;
+package org.apache.jena.geosparql.spatial.index.v1;
 
-import java.io.*;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
 import java.lang.invoke.MethodHandles;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.util.*;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
 
 import org.apache.jena.atlas.RuntimeIOException;
 import org.apache.jena.atlas.io.IOX;
@@ -32,13 +40,26 @@ import org.apache.jena.geosparql.implementation.registry.SRSRegistry;
 import org.apache.jena.geosparql.implementation.vocabulary.Geo;
 import org.apache.jena.geosparql.implementation.vocabulary.SRS_URI;
 import org.apache.jena.geosparql.implementation.vocabulary.SpatialExtension;
+import org.apache.jena.geosparql.spatial.ConvertLatLon;
+import org.apache.jena.geosparql.spatial.SpatialIndex;
+import org.apache.jena.geosparql.spatial.SpatialIndexException;
+import org.apache.jena.geosparql.spatial.SpatialIndexItem;
+import org.apache.jena.geosparql.spatial.SpatialIndexStorage;
+import org.apache.jena.geosparql.spatial.index.v2.SpatialIndexUtils;
 import org.apache.jena.query.Dataset;
 import org.apache.jena.query.DatasetFactory;
 import org.apache.jena.query.ReadWrite;
-import org.apache.jena.rdf.model.*;
+import org.apache.jena.rdf.model.Literal;
+import org.apache.jena.rdf.model.Model;
+import org.apache.jena.rdf.model.NodeIterator;
+import org.apache.jena.rdf.model.RDFNode;
+import org.apache.jena.rdf.model.ResIterator;
+import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.rdf.model.Statement;
+import org.apache.jena.rdf.model.StmtIterator;
 import org.apache.jena.sparql.engine.ExecutionContext;
 import org.apache.jena.sparql.util.Context;
-import org.apache.jena.sparql.util.Symbol;
+import org.apache.jena.sparql.util.ModelUtils;
 import org.apache.jena.util.iterator.ExtendedIterator;
 import org.locationtech.jts.geom.Envelope;
 import org.locationtech.jts.index.strtree.STRtree;
@@ -55,20 +76,19 @@ import org.slf4j.LoggerFactory;
  * The SpatialIndex is added to the Dataset Context when it is built.<br>
  * QueryRewriteIndex is also stored in the SpatialIndex as its content is
  * Dataset specific.
- *
  */
-public class SpatialIndex {
+@Deprecated(forRemoval=true) /** Superseded by {@link SpatialIndex} and {@link org.apache.jena.geosparql.spatial.index.v2.SpatialIndexPerGraph}. */
+public class SpatialIndexV1 {
 
     private static final Logger LOGGER = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
 
-    public static final Symbol SPATIAL_INDEX_SYMBOL = Symbol.create("http://jena.apache.org/spatial#index");
-
     private transient final SRSInfo srsInfo;
     private boolean isBuilt;
     private final STRtree strTree;
     private static final int MINIMUM_CAPACITY = 2;
+    private File location;
 
-    private SpatialIndex() {
+    private SpatialIndexV1() {
         this.strTree = new STRtree(MINIMUM_CAPACITY);
         this.isBuilt = true;
         this.strTree.build();
@@ -81,7 +101,7 @@ public class SpatialIndex {
      * @param capacity
      * @param srsURI
      */
-    public SpatialIndex(int capacity, String srsURI) {
+    public SpatialIndexV1(int capacity, String srsURI) {
         int indexCapacity = capacity < MINIMUM_CAPACITY ? MINIMUM_CAPACITY : capacity;
         this.strTree = new STRtree(indexCapacity);
         this.isBuilt = false;
@@ -95,7 +115,7 @@ public class SpatialIndex {
      * @param srsURI
      * @throws SpatialIndexException
      */
-    public SpatialIndex(Collection<SpatialIndexItem> spatialIndexItems, String srsURI) throws SpatialIndexException {
+    public SpatialIndexV1(Collection<SpatialIndexItem> spatialIndexItems, String srsURI) throws SpatialIndexException {
         int indexCapacity = spatialIndexItems.size() < MINIMUM_CAPACITY ? MINIMUM_CAPACITY : spatialIndexItems.size();
         this.strTree = new STRtree(indexCapacity);
         insertItems(spatialIndexItems);
@@ -104,6 +124,14 @@ public class SpatialIndex {
         this.srsInfo = SRSRegistry.getSRSInfo(srsURI);
     }
 
+    public File getLocation() {
+        return location;
+    }
+
+    public void setLocation(File location) {
+        this.location = location;
+    }
+
     /**
      *
      * @return Information about the SRS used by the SpatialIndex.
@@ -147,7 +175,7 @@ public class SpatialIndex {
     public final void insertItems(Collection<SpatialIndexItem> indexItems) throws SpatialIndexException {
 
         for (SpatialIndexItem indexItem : indexItems) {
-            insertItem(indexItem.getEnvelope(), indexItem.getItem());
+            insertItem(indexItem.getEnvelope(), ModelUtils.convertGraphNodeToRDFNode(indexItem.getItem()).asResource());
         }
     }
 
@@ -187,10 +215,10 @@ public class SpatialIndex {
      * @return SpatialIndex contained in the Context.
      * @throws SpatialIndexException
      */
-    public static final SpatialIndex retrieve(ExecutionContext execCxt) throws SpatialIndexException {
+    public static final SpatialIndexV1 retrieve(ExecutionContext execCxt) throws SpatialIndexException {
 
         Context context = execCxt.getContext();
-        SpatialIndex spatialIndex = (SpatialIndex) context.get(SPATIAL_INDEX_SYMBOL, null);
+        SpatialIndexV1 spatialIndex = (SpatialIndexV1) context.get(SpatialIndexUtils.SPATIAL_INDEX_SYMBOL, null);
 
         if (spatialIndex == null) {
             throw new SpatialIndexException("Dataset Context does not contain SpatialIndex.");
@@ -206,7 +234,7 @@ public class SpatialIndex {
      */
     public static final boolean isDefined(ExecutionContext execCxt) {
         Context context = execCxt.getContext();
-        return context.isDefined(SPATIAL_INDEX_SYMBOL);
+        return context.isDefined(SpatialIndexUtils.SPATIAL_INDEX_SYMBOL);
     }
 
     /**
@@ -216,9 +244,10 @@ public class SpatialIndex {
      * @param dataset
      * @param spatialIndex
      */
-    public static final void setSpatialIndex(Dataset dataset, SpatialIndex spatialIndex) {
+    public static final void setSpatialIndex(Dataset dataset, SpatialIndexV1 spatialIndex) {
         Context context = dataset.getContext();
-        context.set(SPATIAL_INDEX_SYMBOL, spatialIndex);
+        SpatialIndex wrapper = new SpatialIndexAdapterV1(spatialIndex);
+        SpatialIndexUtils.setSpatialIndex(context, wrapper);
     }
 
     /**
@@ -232,14 +261,15 @@ public class SpatialIndex {
      * @return SpatialIndex constructed.
      * @throws SpatialIndexException
      */
-    public static SpatialIndex buildSpatialIndex(Dataset dataset, String srsURI, File spatialIndexFile) throws SpatialIndexException {
+    public static SpatialIndexV1 buildSpatialIndex(Dataset dataset, String srsURI, File spatialIndexFile) throws SpatialIndexException {
 
-        SpatialIndex spatialIndex = load(spatialIndexFile);
+        SpatialIndexV1 spatialIndex = load(spatialIndexFile);
+        spatialIndex.setLocation(spatialIndexFile);
 
         if (spatialIndex.isEmpty()) {
             Collection<SpatialIndexItem> spatialIndexItems = findSpatialIndexItems(dataset, srsURI);
             save(spatialIndexFile, spatialIndexItems, srsURI);
-            spatialIndex = new SpatialIndex(spatialIndexItems, srsURI);
+            spatialIndex = new SpatialIndexV1(spatialIndexItems, srsURI);
             spatialIndex.build();
         }
 
@@ -258,9 +288,9 @@ public class SpatialIndex {
      * @return SpatialIndex constructed.
      * @throws SpatialIndexException
      */
-    public static SpatialIndex buildSpatialIndex(Dataset dataset, File spatialIndexFile) throws SpatialIndexException {
+    public static SpatialIndexV1 buildSpatialIndex(Dataset dataset, File spatialIndexFile) throws SpatialIndexException {
         String srsURI = GeoSPARQLOperations.findModeSRS(dataset);
-        SpatialIndex spatialIndex = buildSpatialIndex(dataset, srsURI, spatialIndexFile);
+        SpatialIndexV1 spatialIndex = buildSpatialIndex(dataset, srsURI, spatialIndexFile);
         return spatialIndex;
     }
 
@@ -273,11 +303,11 @@ public class SpatialIndex {
      * @return SpatialIndex constructed.
      * @throws SpatialIndexException
      */
-    public static SpatialIndex buildSpatialIndex(Dataset dataset, String srsURI) throws SpatialIndexException {
+    public static SpatialIndexV1 buildSpatialIndex(Dataset dataset, String srsURI) throws SpatialIndexException {
         LOGGER.info("Building Spatial Index - Started");
 
         Collection<SpatialIndexItem> items = findSpatialIndexItems(dataset, srsURI);
-        SpatialIndex spatialIndex = new SpatialIndex(items, srsURI);
+        SpatialIndexV1 spatialIndex = new SpatialIndexV1(items, srsURI);
         spatialIndex.build();
         setSpatialIndex(dataset, spatialIndex);
         LOGGER.info("Building Spatial Index - Completed");
@@ -321,9 +351,9 @@ public class SpatialIndex {
      * @return SpatialIndex constructed.
      * @throws SpatialIndexException
      */
-    public static SpatialIndex buildSpatialIndex(Dataset dataset) throws SpatialIndexException {
+    public static SpatialIndexV1 buildSpatialIndex(Dataset dataset) throws SpatialIndexException {
         String srsURI = GeoSPARQLOperations.findModeSRS(dataset);
-        SpatialIndex spatialIndex = buildSpatialIndex(dataset, srsURI);
+        SpatialIndexV1 spatialIndex = buildSpatialIndex(dataset, srsURI);
         return spatialIndex;
     }
 
@@ -477,7 +507,7 @@ public class SpatialIndex {
      * @return Built Spatial Index.
      * @throws SpatialIndexException
      */
-    public static final SpatialIndex load(File spatialIndexFile) throws SpatialIndexException {
+    public static final SpatialIndexV1 load(File spatialIndexFile) throws SpatialIndexException {
 
         if (spatialIndexFile != null && spatialIndexFile.exists()) {
             LOGGER.info("Loading Spatial Index - Started: {}", spatialIndexFile.getAbsolutePath());
@@ -485,14 +515,14 @@ public class SpatialIndex {
             try (ObjectInputStream in = new ObjectInputStream(new FileInputStream(spatialIndexFile))) {
                 SpatialIndexStorage storage = (SpatialIndexStorage) in.readObject();
 
-                SpatialIndex spatialIndex = storage.getSpatialIndex();
+                SpatialIndexV1 spatialIndex = storage.getSpatialIndex();
                 LOGGER.info("Loading Spatial Index - Completed: {}", spatialIndexFile.getAbsolutePath());
                 return spatialIndex;
             } catch (ClassNotFoundException | IOException ex) {
                 throw new SpatialIndexException("Loading Exception: " + ex.getMessage(), ex);
             }
         } else {
-            return new SpatialIndex();
+            return new SpatialIndexV1();
         }
     }
 
@@ -535,6 +565,7 @@ public class SpatialIndex {
                                     out->{
                                         ObjectOutputStream oos = new ObjectOutputStream(out);
                                         oos.writeObject(storage);
+                                        oos.flush();
                                     });
             } catch (RuntimeIOException ex) {
                 throw new SpatialIndexException("Save Exception: " + ex.getMessage());
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v2/GeometryGenerator.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v2/GeometryGenerator.java
new file mode 100644
index 0000000000..f07e801815
--- /dev/null
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v2/GeometryGenerator.java
@@ -0,0 +1,175 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.jena.geosparql.spatial.index.v2;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Random;
+
+import org.apache.jena.geosparql.implementation.GeometryWrapper;
+import org.apache.jena.geosparql.implementation.datatype.WKTDatatype;
+import org.apache.jena.geosparql.implementation.jts.CustomGeometryFactory;
+import org.apache.jena.geosparql.implementation.vocabulary.Geo;
+import org.apache.jena.graph.Graph;
+import org.apache.jena.graph.Node;
+import org.apache.jena.graph.NodeFactory;
+import org.locationtech.jts.geom.Coordinate;
+import org.locationtech.jts.geom.Envelope;
+import org.locationtech.jts.geom.Geometry;
+import org.locationtech.jts.geom.GeometryFactory;
+import org.locationtech.jts.geom.LineString;
+import org.locationtech.jts.geom.Polygon;
+
+/**
+ * Class to generate various geometry types for testing. The generated coordinate sequences are based on circles.
+ * This class is also used in jena-benchmarks.
+ */
+public class GeometryGenerator {
+
+    public enum GeometryType {
+        POINT,
+        LINESTRING,
+        LINEARRING,
+        POLYGON,
+        MULTIPOINT,
+        MULTILINESTRING,
+        MULTIPOLYGON,
+        GEOMETRYCOLLECTION;
+    }
+
+    public static Coordinate[] createCircle(GeometryFactory geometryFactory, Coordinate center, double radius, int numPoints, boolean closed) {
+        Coordinate[] coords = new Coordinate[numPoints + (closed ? 1 : 0)]; // +1 to close the circle
+        for (int i = 0; i < numPoints; i++) {
+            double angle = 2 * Math.PI * i / numPoints;
+            double x = center.x + radius * Math.cos(angle);
+            double y = center.y + radius * Math.sin(angle);
+            coords[i] = new Coordinate(x, y);
+        }
+        if (closed) {
+            coords[numPoints] = coords[0];
+        }
+        return coords;
+    }
+
+    public static Geometry createGeom(GeometryType type, GeometryFactory f, Coordinate center, double radius, int numPoints) {
+        Geometry result;
+        switch(type) {
+        case POINT:
+            // Only return the point - (a circle of radius 0)
+            result = f.createPoint(center); break;
+        default:
+            // Non-multi geometries.
+            Coordinate[] c1 = createCircle(f,center, radius, numPoints, true);
+
+            switch(type) {
+            case MULTIPOINT: result = f.createMultiPointFromCoords(c1); break;
+            case LINESTRING: result = f.createLineString(c1); break;
+            case LINEARRING: result = f.createLinearRing(c1); break;
+            case POLYGON: result = f.createPolygon(c1); break;
+            default:
+                // Multi geometries.
+                Coordinate[] c2 = createCircle(f,center, radius * 0.5, (int)Math.max(3, numPoints * 0.8), true);
+
+                switch(type) {
+                case MULTILINESTRING: result = f.createMultiLineString(new LineString[]{f.createLineString(c1), f.createLineString(c2)}); break;
+                case MULTIPOLYGON: result = f.createMultiPolygon(new Polygon[]{f.createPolygon(c1), f.createPolygon(c2)}); break;
+                case GEOMETRYCOLLECTION: result = f.createGeometryCollection(new Geometry[] {f.createPoint(center), f.createLinearRing(c2)}); break;
+                default:
+                    throw new RuntimeException("Unsupported geometry type: " + type);
+                }
+            }
+        }
+        return result;
+    }
+
+    /**
+     * Create a map that maps each geometry type to the specified amount.
+     * For use with {@link #generateGraph(Graph, Envelope, Map)}.
+     */
+    public static Map<GeometryType, Number> createConfig(long amount) {
+        // How many geometries to generate of each type.
+        // Note that the index only stores their envelopes.
+        Map<GeometryType, Number> config = new LinkedHashMap<>();
+        config.put(GeometryType.POINT, amount);
+        config.put(GeometryType.LINESTRING, amount);
+        config.put(GeometryType.LINEARRING, amount);
+        config.put(GeometryType.POLYGON, amount);
+        config.put(GeometryType.MULTIPOINT, amount);
+        config.put(GeometryType.MULTILINESTRING, amount);
+        config.put(GeometryType.MULTIPOLYGON, amount);
+        config.put(GeometryType.GEOMETRYCOLLECTION, amount);
+        return config;
+    }
+
+    /**
+     * Generate GeoSPARQL data with various geometry types in the given graph.
+     *
+     * @param graph The target graph.
+     * @param envelope The allowed area for the positions of generated geometries (the shape may overlap).
+     * @param config Map of geometry type to the number of geometries to generate.
+     *
+     * @return The number of generated geometries added to the graph.
+     */
+    public static long generateGraph(Graph graph, Envelope envelope, Map<GeometryType, Number> config) {
+        GeometryFactory geometryFactory = CustomGeometryFactory.theInstance();
+
+        float maxRadius = 1;
+        int minNumPoints = 3;
+        int maxNumPoints = 30;
+
+        Random rand = new Random(0);
+
+        float minX = (float)envelope.getMinX();
+        float maxX = (float)envelope.getMaxX();
+        float minY = (float)envelope.getMinY();
+        float maxY = (float)envelope.getMaxY();
+
+        float dX = maxX - minX;
+        float dY = maxY - minY;
+        int dNumPoints = maxNumPoints - minNumPoints;
+
+        long generatedItemCount = 0;
+        long nextFeatureId = 0;
+        for (Entry<GeometryType, Number> e : config.entrySet()) {
+            GeometryType geometryType = e.getKey();
+            long count = e.getValue().longValue();
+            for (long i = 0; i < count; ++i) {
+
+                float x = minX + rand.nextFloat(dX);
+                float y = minY + rand.nextFloat(dY);
+                int numPoints = minNumPoints + (int)(rand.nextFloat(dNumPoints));
+
+                Coordinate c = new Coordinate(x, y);
+                Geometry g = GeometryGenerator.createGeom(geometryType, geometryFactory, c, maxRadius, numPoints);
+
+                long featureId = nextFeatureId++;
+                Node feature = NodeFactory.createURI("http://www.example.org/feature" + featureId);
+                Node geometry = NodeFactory.createURI("http://www.example.org/geometry" + featureId);
+                Node geom = new GeometryWrapper(g, WKTDatatype.URI).asNode();
+
+                graph.add(feature, Geo.HAS_GEOMETRY_NODE, geometry);
+                graph.add(geometry, Geo.AS_WKT_NODE, geom);
+
+                ++generatedItemCount;
+            }
+        }
+        return generatedItemCount;
+    }
+}
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v2/KryoRegistratorSpatialIndexV2.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v2/KryoRegistratorSpatialIndexV2.java
new file mode 100644
index 0000000000..8b86e36375
--- /dev/null
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v2/KryoRegistratorSpatialIndexV2.java
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jena.geosparql.spatial.index.v2;
+
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.apache.jena.geosparql.kryo.EnvelopeSerializer;
+import org.apache.jena.geosparql.kryo.NodeSerializer;
+import org.apache.jena.geosparql.kryo.TripleSerializer;
+import org.apache.jena.graph.Triple;
+import org.locationtech.jts.geom.Envelope;
+import org.locationtech.jts.geom.Geometry;
+import org.locationtech.jts.geom.GeometryCollection;
+import org.locationtech.jts.geom.LineString;
+import org.locationtech.jts.geom.LinearRing;
+import org.locationtech.jts.geom.MultiLineString;
+import org.locationtech.jts.geom.MultiPoint;
+import org.locationtech.jts.geom.MultiPolygon;
+import org.locationtech.jts.geom.Point;
+import org.locationtech.jts.geom.Polygon;
+import org.locationtech.jts.index.strtree.STRtree;
+import org.locationtech.jts.index.strtree.STRtreeSerializer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.esotericsoftware.kryo.Kryo;
+import com.esotericsoftware.kryo.Serializer;
+import com.esotericsoftware.kryo.serializers.MapSerializer;
+
+/**
+ * The class is used to configure the kryo serialization
+ * of the spatial index. Changes to the kyro configuration may
+ * break loading of existing index files - so changes must be made
+ * with care.
+ */
+public class KryoRegistratorSpatialIndexV2 {
+
+    private final static Logger LOGGER = LoggerFactory.getLogger(KryoRegistratorSpatialIndexV2.class);
+
+    public static void registerClasses(Kryo kryo, Serializer<Geometry> geometrySerializer) {
+        LOGGER.debug("Registering custom serializers for geometry types");
+
+        // Java
+        Serializer<?> mapSerializer = new MapSerializer();
+        kryo.register(Map.class, mapSerializer);
+        kryo.register(HashMap.class, mapSerializer);
+        kryo.register(LinkedHashMap.class, mapSerializer);
+
+        // Jena
+        NodeSerializer.register(kryo);
+        registerTripleSerializer(kryo); // Needed for RDFstar Nodes.
+
+        // JTS
+        kryo.register(STRtree.class, new STRtreeSerializer());
+        kryo.register(Envelope.class, new EnvelopeSerializer());
+
+        // Jena + JTS
+        kryo.register(STRtreePerGraph.class, new STRtreePerGraphSerializer());
+
+        // The index only stores envelopes and jena nodes.
+        // Therefore geometry serializers are not needed.
+        // registerGeometrySerializers(kryo, geometrySerializer);
+    }
+
+    /**
+     * The default serializer for {@link Triple}.
+     * Must be registered in addition to node serializers for RDF-Star (triples-in-nodes).
+     */
+    public static void registerTripleSerializer(Kryo kryo) {
+        kryo.register(Triple.class, new TripleSerializer());
+        // kryo.register(Triple[].class); // Array variants need to be registered though no extra serializer needed.
+    }
+
+    public static void registerGeometrySerializers(Kryo kryo, Serializer<Geometry> geometrySerializer) {
+        kryo.register(Point.class, geometrySerializer);
+        kryo.register(LinearRing.class, geometrySerializer);
+        kryo.register(LineString.class, geometrySerializer);
+        kryo.register(Polygon.class, geometrySerializer);
+        kryo.register(MultiPoint.class, geometrySerializer);
+        kryo.register(MultiLineString.class, geometrySerializer);
+        kryo.register(MultiPolygon.class, geometrySerializer);
+        kryo.register(GeometryCollection.class, geometrySerializer);
+    }
+}
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v2/STRtreePerGraph.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v2/STRtreePerGraph.java
new file mode 100644
index 0000000000..d6f65c6024
--- /dev/null
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v2/STRtreePerGraph.java
@@ -0,0 +1,193 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jena.geosparql.spatial.index.v2;
+
+import java.lang.invoke.MethodHandles;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.apache.jena.graph.Node;
+import org.apache.jena.sparql.core.Quad;
+import org.locationtech.jts.geom.Envelope;
+import org.locationtech.jts.index.strtree.STRtree;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class STRtreePerGraph {
+    private static final Logger LOGGER = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+    /**
+     * @implNote
+     *   The STRtree that corresponds to the default graph (referred to as 'default tree')
+     *   uses the name {@link Quad#defaultGraphIRI}.
+     *   The default tree is maintained as part of the treeMap (rather than as a separate field)
+     *   to simplify merging of partial spatial indexes.
+     */
+    private Map<Node, STRtree> treeMap;
+
+    // Unmodifiable view
+    private Map<Node, STRtree> treeMapView;
+
+    // XXX Make srsInfo part of this class?
+    // this.srsInfo = SRSRegistry.getSRSInfo(SRS_URI.DEFAULT_WKT_CRS84);
+
+    private boolean isBuilt = false;
+
+    public STRtreePerGraph() {
+        this(new ConcurrentHashMap<>());
+    }
+
+    public STRtreePerGraph(STRtree defaultTree) {
+        this(new ConcurrentHashMap<>());
+        Objects.requireNonNull(defaultTree);
+        setDefaultTree(defaultTree);
+    }
+
+    protected STRtreePerGraph(Map<Node, STRtree> treeMap) {
+        super();
+        this.treeMap = Objects.requireNonNull(treeMap);
+        this.treeMapView = Collections.unmodifiableMap(this.treeMap);
+    }
+
+    public STRtree getDefaultTree() {
+        return treeMap.get(Quad.defaultGraphIRI);
+    }
+
+    /** Returns an unmodifiable view of the tree map. */
+    public Map<Node, STRtree> getTreeMap() {
+        return treeMapView;
+    }
+
+    /** For serialization. */
+    Map<Node, STRtree> getInternalTreeMap() {
+        return treeMap;
+    }
+
+    /** Whether a tree with the given name exists. Handles default tree names. */
+    public boolean contains(Node name) {
+        return getTree(name) != null;
+    }
+
+    /** Whether a tree with the given name exists. Handles default tree names. */
+    public STRtree getTree(Node name) {
+        STRtree result = (name == null || Quad.isDefaultGraph(name))
+            ? treeMap.get(Quad.defaultGraphIRI)
+            : treeMap.get(name);
+        return result;
+    }
+
+    /** Whether a tree with the given name exists. Handles default tree names. */
+    public STRtree setTree(Node name, STRtree tree) {
+        STRtree result;
+        if (name == null || Quad.isDefaultGraph(name)) {
+            result = setDefaultTree(tree);
+        } else {
+            result = setNamedTree(name, tree);
+        }
+        return result;
+    }
+
+    /** Returns the prior default tree. */
+    public STRtree setDefaultTree(STRtree tree) {
+        return setNamedTree(Quad.defaultGraphIRI, tree);
+    }
+
+    /** Returns the prior tree of graphNode. */
+    protected STRtree setNamedTree(Node graphNode, STRtree tree) {
+        Objects.requireNonNull(graphNode); // Default graph must name must be Quad.defaultGraphIRI.
+        return treeMap.put(graphNode, tree);
+    }
+
+    /** Add all data of 'other' to this. Builds the added trees if {@link #isBuilt()} is true. */
+    public void setTrees(Map<Node, STRtree> treeMap) {
+        // If the index is already built then build all trees being added.
+        if (isBuilt()) {
+            treeMap.values().forEach(STRtree::build);
+        }
+        this.treeMap.putAll(treeMap);
+    }
+
+    public boolean removeTree(Node node) {
+        if (node == null) {
+            node = Quad.defaultGraphIRI;
+        }
+        return treeMap.remove(node) != null;
+    }
+
+    @SuppressWarnings("unchecked")
+    public Collection<Node> queryOneGraph(Envelope searchEnvelope, Node graph) {
+        Collection<Node> result;
+        if (graph == null || Quad.isDefaultGraph(graph)) {
+            // Handle default graph.
+            STRtree defaultTree = getDefaultTree();
+            result = defaultTree == null
+                ? Set.of()
+                : new LinkedHashSet<>(defaultTree.query(searchEnvelope));
+        } else if (Quad.isUnionGraph(graph)) {
+            // Handle union graph (avoid).
+            LOGGER.warn("spatial index lookup on union graph");
+            result = treeMap.entrySet().stream()
+                .filter(e -> !Quad.isDefaultGraph(e.getKey())) // Exclude default graph.
+                .map(Entry::getValue)
+                .map(tree -> tree.query(searchEnvelope))
+                .collect(LinkedHashSet::new,
+                    Set::addAll,
+                    Set::addAll);
+        } else {
+            // Handle specific named graph.
+            STRtree tree = treeMap.get(graph);
+            if (tree == null) {
+                LOGGER.warn("graph not indexed: " + graph);
+            }
+            if (tree != null && !tree.isEmpty()) {
+                result = new LinkedHashSet<>(tree.query(searchEnvelope));
+            } else {
+                result = new HashSet<>();
+            }
+        }
+        return result;
+    }
+
+    public boolean isEmpty() {
+        boolean result = treeMap.values().stream().allMatch(STRtree::isEmpty);
+        return result;
+    }
+
+    public void build() {
+        if (!isBuilt) {
+            treeMap.values().forEach(STRtree::build);
+            isBuilt = true;
+        }
+    }
+
+    public boolean isBuilt() {
+        return isBuilt;
+    }
+
+    public long size() {
+        long result = treeMap.values().stream().mapToLong(STRtree::size).sum();
+        return result;
+    }
+}
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v2/STRtreePerGraphSerializer.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v2/STRtreePerGraphSerializer.java
new file mode 100644
index 0000000000..93e7008bd4
--- /dev/null
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v2/STRtreePerGraphSerializer.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jena.geosparql.spatial.index.v2;
+
+import java.util.Map;
+
+import org.apache.jena.graph.Node;
+import org.locationtech.jts.index.strtree.STRtree;
+
+import com.esotericsoftware.kryo.Kryo;
+import com.esotericsoftware.kryo.Serializer;
+import com.esotericsoftware.kryo.io.Input;
+import com.esotericsoftware.kryo.io.Output;
+
+public class STRtreePerGraphSerializer
+    extends Serializer<STRtreePerGraph>
+{
+    @Override
+    public void write(Kryo kryo, Output output, STRtreePerGraph index) {
+        kryo.writeClassAndObject(output, index.getInternalTreeMap());
+        output.writeBoolean(index.isBuilt());
+    }
+
+    @Override
+    public STRtreePerGraph read(Kryo kryo, Input input, Class<STRtreePerGraph> type) {
+        @SuppressWarnings("unchecked")
+        Map<Node, STRtree> treeMap = (Map<Node, STRtree>)kryo.readClassAndObject(input);
+        boolean isBuilt = input.readBoolean();
+        STRtreePerGraph result = new STRtreePerGraph(treeMap);
+        if (isBuilt) {
+            result.build();
+        }
+        return result;
+    }
+}
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v2/STRtreeUtils.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v2/STRtreeUtils.java
new file mode 100644
index 0000000000..0d17dd90bb
--- /dev/null
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v2/STRtreeUtils.java
@@ -0,0 +1,117 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jena.geosparql.spatial.index.v2;
+
+import java.lang.invoke.MethodHandles;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.jena.atlas.iterator.Iter;
+import org.apache.jena.atlas.iterator.IteratorCloseable;
+import org.apache.jena.geosparql.spatial.SpatialIndexException;
+import org.apache.jena.geosparql.spatial.SpatialIndexFindUtils;
+import org.apache.jena.geosparql.spatial.SpatialIndexItem;
+import org.apache.jena.graph.Graph;
+import org.apache.jena.graph.Node;
+import org.apache.jena.graph.compose.Union;
+import org.apache.jena.sparql.core.DatasetGraph;
+import org.apache.jena.sparql.core.Quad;
+import org.locationtech.jts.index.strtree.STRtree;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class STRtreeUtils {
+    private static final Logger LOGGER = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+    static final int MINIMUM_CAPACITY = 2;
+
+    public static STRtree buildSpatialIndexTree(Graph graph, String srsURI) throws SpatialIndexException {
+        try {
+            STRtree tree;
+            IteratorCloseable<SpatialIndexItem> it = SpatialIndexFindUtils.findSpatialIndexItems(graph, srsURI);
+            try {
+                tree = buildSpatialIndexTree(it);
+            } finally {
+                it.close();
+            }
+            return tree;
+        } catch (Throwable e) {
+            throw new SpatialIndexException("Spatial index construction failed.", e);
+        }
+    }
+
+    public static STRtree buildSpatialIndexTreeUnion(DatasetGraph datasetGraph, String srsURI) throws SpatialIndexException {
+        // create the union view of default graph and all named graphs
+        Union unionAllGraph = new Union(datasetGraph.getDefaultGraph(), datasetGraph.getUnionGraph());
+        STRtree tree = buildSpatialIndexTree(unionAllGraph, srsURI);
+        return tree;
+    }
+
+    public static STRtreePerGraph buildSpatialIndexTree(DatasetGraph datasetGraph, String srsURI) throws SpatialIndexException {
+        Map<Node, STRtree> treeMap = new LinkedHashMap<>();
+
+        LOGGER.info("building spatial index for default graph ...");
+        Graph defaultGraph = datasetGraph.getDefaultGraph();
+        STRtree defaultGraphTree = buildSpatialIndexTree(defaultGraph, srsURI);
+        treeMap.put(Quad.defaultGraphIRI, defaultGraphTree);
+
+        // Named graphs
+        Iterator<Node> graphIter = datasetGraph.listGraphNodes();
+        try {
+            while (graphIter.hasNext()) {
+                Node graphNode = graphIter.next();
+                LOGGER.info("building spatial index for graph {} ...", graphNode);
+                Graph namedGraph = datasetGraph.getGraph(graphNode);
+                treeMap.put(graphNode, buildSpatialIndexTree(namedGraph, srsURI));
+            }
+        } finally {
+            Iter.close(graphIter);
+        }
+
+        return new STRtreePerGraph(treeMap);
+    }
+
+    /**
+     * Create an STRtree from the elements of the given iterator.
+     * It's the caller's responsibility to close the iterator if needed.
+     */
+    public static STRtree buildSpatialIndexTree(Iterator<SpatialIndexItem> it) throws SpatialIndexException {
+        // Collecting items into a list in order to assist tree construction with a known size.
+        List<SpatialIndexItem> items = Iter.toList(it);
+        STRtree tree = buildSpatialIndexTree(items);
+        return tree;
+    }
+
+    public static STRtree buildSpatialIndexTree(Collection<SpatialIndexItem> items) throws SpatialIndexException {
+        STRtree tree = new STRtree(Math.max(MINIMUM_CAPACITY, items.size()));
+        addToTree(tree, items.iterator());
+        tree.build();
+        return tree;
+    }
+
+    /**
+     * Accumulate the elements of the given iterator into an existing (unbuilt) STRtree.
+     * It's the caller's responsibility to close the iterator if needed.
+     */
+    public static void addToTree(STRtree treeAcc, Iterator<SpatialIndexItem> it) throws SpatialIndexException {
+        // List<SpatialIndexItem> items = Iter.toList(it);
+        it.forEachRemaining(item -> treeAcc.insert(item.getEnvelope(), item.getItem()));
+    }
+}
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v2/SpatialIndexHeader.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v2/SpatialIndexHeader.java
new file mode 100644
index 0000000000..3b3b1b1f82
--- /dev/null
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v2/SpatialIndexHeader.java
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.jena.geosparql.spatial.index.v2;
+
+import java.util.Objects;
+
+import com.google.gson.JsonObject;
+
+/**
+ * The header for a spatial index.
+ * For extensibility, this is merely a view over a JSON object.
+ */
+public class SpatialIndexHeader {
+    public static final String TYPE_KEY = "type";
+    public static final String VERSION_KEY = "version";
+    public static final String SRS_KEY = "srs";
+    public static final String GEOMETRY_SERIALIZER_KEY = "geometrySerializer";
+
+    public static final String TYPE_VALUE = "jena-spatial-index";
+
+    protected JsonObject json;
+
+    public SpatialIndexHeader() {
+        this(new JsonObject());
+    }
+
+    public SpatialIndexHeader(JsonObject json) {
+        super();
+        this.json = Objects.requireNonNull(json);
+    }
+
+    public JsonObject getJson() {
+        return json;
+    }
+
+    public void setType(String type) {
+        getJson().addProperty(TYPE_KEY, type);
+    }
+
+    public String getType() {
+        return getJson().get(TYPE_KEY).getAsString();
+    }
+
+    public void setVersion(String version) {
+        getJson().addProperty(VERSION_KEY, version);
+    }
+
+    public String getVersion() {
+        return getJson().get(VERSION_KEY).getAsString();
+    }
+
+    public void setSrsUri(String type) {
+        getJson().addProperty(SRS_KEY, type);
+    }
+
+    public String getSrsUri() {
+        return getJson().get(SRS_KEY).getAsString();
+    }
+
+    public void setGeometrySerializerClass(String className) {
+        getJson().addProperty(GEOMETRY_SERIALIZER_KEY, className);
+    }
+
+    public String getGeometrySerializerClass() {
+        return getJson().get(GEOMETRY_SERIALIZER_KEY).getAsString();
+    }
+}
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v2/SpatialIndexIoKryo.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v2/SpatialIndexIoKryo.java
new file mode 100644
index 0000000000..3f92547842
--- /dev/null
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v2/SpatialIndexIoKryo.java
@@ -0,0 +1,207 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jena.geosparql.spatial.index.v2;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.InvocationTargetException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Objects;
+
+import org.apache.jena.atlas.RuntimeIOException;
+import org.apache.jena.atlas.io.IOX;
+import org.apache.jena.geosparql.configuration.GeoSPARQLOperations;
+import org.apache.jena.geosparql.implementation.SRSInfo;
+import org.apache.jena.geosparql.implementation.registry.SRSRegistry;
+import org.apache.jena.geosparql.implementation.vocabulary.SRS_URI;
+import org.apache.jena.geosparql.kryo.GeometrySerializerJtsWkb;
+import org.apache.jena.geosparql.spatial.SpatialIndex;
+import org.apache.jena.geosparql.spatial.SpatialIndexException;
+import org.apache.jena.query.Dataset;
+import org.locationtech.jts.geom.Geometry;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.esotericsoftware.kryo.Kryo;
+import com.esotericsoftware.kryo.Serializer;
+import com.esotericsoftware.kryo.io.Input;
+import com.esotericsoftware.kryo.io.Output;
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
+
+public class SpatialIndexIoKryo {
+    private static final Logger LOGGER = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+    public static SpatialIndex buildSpatialIndex(Dataset dataset,
+                                                 String srsURI,
+                                                 Path spatialIndexFile) throws SpatialIndexException {
+
+        SpatialIndexPerGraph spatialIndex = load(spatialIndexFile);
+
+        if (spatialIndex.isEmpty()) {
+            spatialIndex = SpatialIndexUtils.buildSpatialIndex(dataset.asDatasetGraph(), srsURI);
+            save(spatialIndexFile, spatialIndex);
+        }
+        spatialIndex.setLocation(spatialIndexFile);
+
+        SpatialIndexUtils.setSpatialIndex(dataset, spatialIndex);
+        return spatialIndex;
+    }
+
+    public static SpatialIndex buildSpatialIndex(Dataset dataset, Path spatialIndexFile) throws SpatialIndexException {
+        String srsURI = GeoSPARQLOperations.findModeSRS(dataset);
+        SpatialIndex spatialIndex = buildSpatialIndex(dataset, srsURI, spatialIndexFile);
+        return spatialIndex;
+    }
+
+    /**
+     * Save SpatialIndex to file.
+     *
+     * @param spatialIndexFile the file being saved to
+     * @param index the spatial index
+     * @throws SpatialIndexException
+     */
+    public static final void save(Path spatialIndexFile, SpatialIndexPerGraph index) throws SpatialIndexException {
+        Path absPath = spatialIndexFile.toAbsolutePath();
+        if (spatialIndexFile != null) {
+            LOGGER.info("Saving Spatial Index - Started: {}", absPath);
+
+            String filename = absPath.toString();
+            Path file = Path.of(filename);
+            Path tmpFile = IOX.uniqueDerivedPath(file, null);
+            try {
+                Files.deleteIfExists(file);
+            } catch (IOException ex) {
+                throw new SpatialIndexException("Failed to delete file: " + ex.getMessage());
+            }
+            try {
+                IOX.safeWriteOrCopy(file, tmpFile, out -> writeToOutputStream(out, index));
+            } catch (RuntimeIOException ex) {
+                throw new SpatialIndexException("Save Exception: " + ex.getMessage());
+            } finally {
+                LOGGER.info("Saving Spatial Index - Completed: {}", absPath);
+            }
+
+        }
+    }
+
+    /**
+     * Write spatial index as Kryo serialization to given OutputStream.
+     * @param os output stream
+     * @param index spatial index
+     */
+    public static void writeToOutputStream(OutputStream os, SpatialIndexPerGraph index) {
+        GeometrySerializerJtsWkb geometrySerde = new GeometrySerializerJtsWkb();
+
+        SpatialIndexHeader header = new SpatialIndexHeader();
+        header.setType(SpatialIndexHeader.TYPE_VALUE);
+        header.setVersion("2.0.0");
+        header.setSrsUri(index.getSrsInfo().getSrsURI());
+        header.setGeometrySerializerClass(geometrySerde.getClass().getName());
+
+        Kryo kryo = new Kryo();
+        KryoRegistratorSpatialIndexV2.registerClasses(kryo, geometrySerde);
+        try (Output output = new Output(os)) {
+            writeHeader(output, header);
+            STRtreePerGraph trees = index.getIndex();
+            kryo.writeObject(output, trees);
+            output.flush();
+        }
+    }
+
+    public static void writeHeader(Output output, SpatialIndexHeader header) {
+        Gson gson = new Gson();
+        String headerStr = gson.toJson(header.getJson());
+        output.writeString(headerStr);
+    }
+
+    /** Read a string from input and return it as JSON. */
+    public static SpatialIndexHeader readHeader(Input input) {
+        String headerStr = input.readString();
+        Gson gson = new Gson();
+        JsonObject obj = gson.fromJson(headerStr, JsonObject.class);
+        return new SpatialIndexHeader(obj);
+    }
+
+    /**
+     * Load a SpatialIndex from file.<br>
+     * Index will be built and empty if file does not exist or is null.
+     *
+     * @param spatialIndexFile
+     * @return Built Spatial Index.
+     * @throws SpatialIndexException
+     */
+    @SuppressWarnings("unchecked")
+    public static final SpatialIndexPerGraph load(Path spatialIndexFile) throws SpatialIndexException {
+        String srsUri;
+        STRtreePerGraph index;
+
+        if (spatialIndexFile != null && Files.exists(spatialIndexFile)) {
+            spatialIndexFile = spatialIndexFile.toAbsolutePath();
+            LOGGER.info("Loading Spatial Index - Started: {}", spatialIndexFile);
+
+            try (Input input = new Input(Files.newInputStream(spatialIndexFile))) {
+                SpatialIndexHeader header = readHeader(input);
+
+                String type = header.getType();
+                if (!"jena-spatial-index".equals(type)) {
+                    throw new RuntimeException("Type does not indicate a spatial index file.");
+                }
+
+                String version = header.getVersion();
+                if (!"2.0.0".equals(version)) {
+                    throw new SpatialIndexException("The version of the spatial index does not match the version of this loader class.");
+                }
+
+                srsUri = header.getSrsUri();
+
+                // Get the geometrySerializer attribute
+                String geometrySerdeName = header.getGeometrySerializerClass();
+                Objects.requireNonNull(geometrySerdeName, "Field 'geometrySerde' not set.");
+
+                Serializer<Geometry> geometrySerializer;
+                try {
+                    Class<?> geometrySerdeClass = Class.forName(geometrySerdeName);
+                    geometrySerializer = (Serializer<Geometry>)geometrySerdeClass.getConstructor().newInstance();
+                } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | IllegalArgumentException
+                        | InvocationTargetException | NoSuchMethodException | ClassCastException | SecurityException e) {
+                    throw new SpatialIndexException("Failed to load index", e);
+                }
+
+                Kryo kryo = new Kryo();
+                KryoRegistratorSpatialIndexV2.registerClasses(kryo, geometrySerializer);
+
+                index = kryo.readObject(input, STRtreePerGraph.class);
+                LOGGER.info("Loading Spatial Index - Completed: {}", spatialIndexFile);
+            } catch (IOException ex) {
+                throw new SpatialIndexException("Loading Exception: " + ex.getMessage(), ex);
+            }
+        } else {
+            LOGGER.info("File {} does not exist. Creating empty Spatial Index.", (spatialIndexFile != null ? spatialIndexFile.toAbsolutePath() : "null"));
+            srsUri = SRS_URI.DEFAULT_WKT_CRS84;
+            index = new STRtreePerGraph();
+        }
+
+        SRSInfo srsInfo = SRSRegistry.getSRSInfo(srsUri);
+        SpatialIndexPerGraph spatialIndex = new SpatialIndexPerGraph(srsInfo, index, spatialIndexFile);
+        spatialIndex.setLocation(spatialIndexFile);
+        return spatialIndex;
+    }
+}
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v2/SpatialIndexPerGraph.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v2/SpatialIndexPerGraph.java
new file mode 100644
index 0000000000..b3f0fbb0b9
--- /dev/null
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v2/SpatialIndexPerGraph.java
@@ -0,0 +1,160 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jena.geosparql.spatial.index.v2;
+
+import java.lang.invoke.MethodHandles;
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.Objects;
+import java.util.Set;
+
+import org.apache.jena.geosparql.implementation.SRSInfo;
+import org.apache.jena.geosparql.implementation.registry.SRSRegistry;
+import org.apache.jena.geosparql.implementation.vocabulary.SRS_URI;
+import org.apache.jena.geosparql.spatial.SpatialIndex;
+import org.apache.jena.geosparql.spatial.SpatialIndexException;
+import org.apache.jena.graph.Graph;
+import org.apache.jena.graph.Node;
+import org.apache.jena.graph.NodeFactory;
+import org.apache.jena.query.TxnType;
+import org.apache.jena.sparql.core.DatasetGraph;
+import org.apache.jena.system.AutoTxn;
+import org.apache.jena.system.Txn;
+import org.locationtech.jts.geom.Envelope;
+import org.locationtech.jts.index.strtree.STRtree;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * SpatialIndex for testing bounding box collisions between geometries within a
+ * Dataset.<br>
+ * Queries must be performed using the same SRS URI as the SpatialIndex.<br>
+ * The SpatialIndex is added to the Dataset Context when it is built.<br>
+ * QueryRewriteIndex is also stored in the SpatialIndex as its content is
+ * Dataset specific.
+ *
+ */
+public class SpatialIndexPerGraph implements SpatialIndex {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+    private transient final SRSInfo srsInfo;
+    private STRtreePerGraph index;
+    private Path location;
+
+    public SpatialIndexPerGraph(STRtreePerGraph index) {
+        this(SRS_URI.DEFAULT_WKT_CRS84, index, null);
+    }
+
+    public SpatialIndexPerGraph(String srsUri, STRtreePerGraph index, Path location) {
+        this(
+            SRSRegistry.getSRSInfo(srsUri),
+            index,
+            null);
+    }
+
+    public SpatialIndexPerGraph(SRSInfo srsInfo, STRtreePerGraph index, Path location) {
+        super();
+        this.srsInfo = Objects.requireNonNull(srsInfo);
+        this.index = Objects.requireNonNull(index);
+        this.location = location;
+    }
+
+    public STRtreePerGraph getIndex() {
+        return index;
+    }
+
+    /**
+     *
+     * @return Information about the SRS used by the SpatialIndex.
+     */
+    @Override
+    public SRSInfo getSrsInfo() {
+        return srsInfo;
+    }
+
+    /**
+     *
+     * @return True if the SpatialIndex is empty.
+     */
+    @Override
+    public boolean isEmpty() {
+        return index.isEmpty();
+    }
+
+    /**
+     * Returns the number of items in the index.
+     */
+    @Override
+    public long getSize() {
+        return index.size();
+    }
+
+    @Override
+    public Collection<Node> query(Envelope searchEnvelope, Node graph) {
+        return index.queryOneGraph(searchEnvelope, graph);
+    }
+
+    @Override
+    public Path getLocation() {
+        return location;
+    }
+
+    @Override
+    public void setLocation(Path location) {
+        this.location = location;
+    }
+
+    @Override
+    public String toString() {
+        return "SpatialIndex{" + "srsInfo=" + srsInfo + ", index=" + index + ", file=" + location + '}';
+    }
+
+    /**
+     * Recompute and replace the spatial index trees for the given named graphs.
+     *
+     * @param index   the spatial index to modify
+     * @param datasetGraph the dataset containing the named graphs
+     * @param graphNames  the named graphs
+     * @throws SpatialIndexException
+     */
+    public static void recomputeIndexForGraphs(SpatialIndexPerGraph index,
+                                                       DatasetGraph datasetGraph,
+                                                       Set<String> graphNames) throws SpatialIndexException {
+        STRtreePerGraph trees = index.getIndex();
+        try (AutoTxn txn = Txn.autoTxn(datasetGraph, TxnType.READ)) {
+            for (String graphName : graphNames) {
+                Node g = graphName == null ? null : NodeFactory.createURI(graphName);
+                if (trees.contains(g)) {
+                    LOGGER.info("recomputing spatial index for graph: {}", graphName);
+                } else {
+                    LOGGER.info("computing spatial index for graph: {}", graphName);
+                }
+                Graph namedGraph = datasetGraph.getGraph(g);
+                STRtree indexTree = STRtreeUtils.buildSpatialIndexTree(namedGraph, index.getSrsInfo().getSrsURI());
+                STRtree oldIndexTree = trees.setTree(g, indexTree);
+                if (oldIndexTree != null) {
+                    LOGGER.info("replaced spatial index for graph: {}", graphName);
+                } else {
+                    LOGGER.info("added spatial index for graph: {}", graphName);
+                }
+            }
+            txn.commit();
+        }
+    }
+}
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v2/SpatialIndexUtils.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v2/SpatialIndexUtils.java
new file mode 100644
index 0000000000..1d055a4e09
--- /dev/null
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v2/SpatialIndexUtils.java
@@ -0,0 +1,215 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jena.geosparql.spatial.index.v2;
+
+import java.lang.invoke.MethodHandles;
+
+import org.apache.jena.geosparql.configuration.GeoSPARQLOperations;
+import org.apache.jena.geosparql.implementation.SRSInfo;
+import org.apache.jena.geosparql.implementation.registry.SRSRegistry;
+import org.apache.jena.geosparql.spatial.SpatialIndex;
+import org.apache.jena.geosparql.spatial.SpatialIndexException;
+import org.apache.jena.graph.Graph;
+import org.apache.jena.graph.Node;
+import org.apache.jena.query.Dataset;
+import org.apache.jena.query.DatasetFactory;
+import org.apache.jena.query.TxnType;
+import org.apache.jena.rdf.model.Model;
+import org.apache.jena.sparql.core.DatasetGraph;
+import org.apache.jena.sparql.core.NamedGraph;
+import org.apache.jena.sparql.engine.ExecutionContext;
+import org.apache.jena.sparql.util.Context;
+import org.apache.jena.sparql.util.Symbol;
+import org.apache.jena.system.AutoTxn;
+import org.apache.jena.system.Txn;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** ARQ-level utils for Dataset and Context. */
+public class SpatialIndexUtils {
+    private static final Logger LOGGER = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+    public static final Symbol SPATIAL_INDEX_SYMBOL = Symbol.create("http://jena.apache.org/spatial#index");
+
+    /** Symbol for a running task in a dataset's context. */
+    public static final Symbol SPATIAL_INDEX_TASK_SYMBOL = Symbol.create("http://jena.apache.org/spatial#indexTask");
+    public static final Symbol symSrsUri = Symbol.create("http://jena.apache.org/spatial#srsURI");
+
+    /**
+     * Set the SpatialIndex into the Context of the Dataset for later retrieval
+     * and use in spatial functions.
+     *
+     * @param dataset
+     * @param spatialIndex
+     */
+    public static final void setSpatialIndex(Dataset dataset, SpatialIndex spatialIndex) {
+        Context cxt = dataset.getContext();
+        setSpatialIndex(cxt, spatialIndex);
+    }
+
+    public static final void setSpatialIndex(DatasetGraph datasetGraph, SpatialIndex spatialIndex) {
+        Context cxt = datasetGraph.getContext();
+        setSpatialIndex(cxt, spatialIndex);
+    }
+
+    public static final void setSpatialIndex(Context context, SpatialIndex spatialIndex) {
+        context.set(SPATIAL_INDEX_SYMBOL, spatialIndex);
+    }
+
+    public static final SpatialIndex getSpatialIndex(Context cxt) {
+        return cxt.get(SPATIAL_INDEX_SYMBOL);
+    }
+
+    /**
+     *
+     * @param execCxt
+     * @return True if a SpatialIndex is defined in the ExecutionContext.
+     */
+    public static final boolean isDefined(ExecutionContext execCxt) {
+        Context context = execCxt.getContext();
+        return context.isDefined(SPATIAL_INDEX_SYMBOL);
+    }
+
+    /**
+     * Retrieve the SpatialIndex from the Context.
+     *
+     * @param execCxt
+     * @return SpatialIndex contained in the Context.
+     * @throws SpatialIndexException
+     */
+    public static final SpatialIndex retrieve(ExecutionContext execCxt) throws SpatialIndexException {
+        Context context = execCxt.getContext();
+        SpatialIndex spatialIndex = (SpatialIndex) context.get(SPATIAL_INDEX_SYMBOL, null);
+        if (spatialIndex == null) {
+            throw new SpatialIndexException("Dataset Context does not contain SpatialIndex.");
+        }
+        return spatialIndex;
+    }
+
+    /**
+     * Wrap Model in a Dataset and build SpatialIndex.
+     *
+     * @param model
+     * @param srsURI
+     * @return Dataset with default Model and SpatialIndex in Context.
+     * @throws SpatialIndexException
+     */
+    public static final Dataset wrapModel(Model model, String srsURI) throws SpatialIndexException {
+        Dataset dataset = DatasetFactory.createTxnMem();
+        dataset.setDefaultModel(model);
+        buildSpatialIndex(dataset.asDatasetGraph(), srsURI);
+        return dataset;
+    }
+
+    /**
+     * Wrap Model in a Dataset and build SpatialIndex.
+     *
+     * @param model
+     * @return Dataset with default Model and SpatialIndex in Context.
+     * @throws SpatialIndexException
+     */
+    public static final Dataset wrapModel(Model model) throws SpatialIndexException {
+        Dataset dataset = DatasetFactory.createTxnMem();
+        dataset.setDefaultModel(model);
+        String srsURI = GeoSPARQLOperations.findModeSRS(dataset);
+        buildSpatialIndex(dataset.asDatasetGraph(), srsURI);
+        return dataset;
+    }
+
+    /**
+     * Build Spatial Index from all graphs in Dataset.<br>
+     * Dataset contains SpatialIndex in Context.<br>
+     * SRS URI based on most frequent found in Dataset.
+     *
+     * @param datasetGraph
+     * @return SpatialIndex constructed.
+     * @throws SpatialIndexException
+     */
+    public static SpatialIndex buildSpatialIndex(DatasetGraph datasetGraph) throws SpatialIndexException {
+        // XXX Dataset wrapping due to legacy code
+        Dataset dataset = DatasetFactory.wrap(datasetGraph);
+        String srsURI = GeoSPARQLOperations.findModeSRS(dataset);
+        SpatialIndex spatialIndex = buildSpatialIndex(datasetGraph, srsURI);
+        return spatialIndex;
+    }
+
+    /**
+     * Build Spatial Index from all graphs in Dataset.<br>
+     * Dataset contains SpatialIndex in Context.
+     *
+     * @param datasetGraph
+     * @param srsURI
+     * @return SpatialIndex constructed.
+     * @throws SpatialIndexException
+     */
+    public static SpatialIndexPerGraph buildSpatialIndex(DatasetGraph datasetGraph, String srsURI) throws SpatialIndexException {
+        return buildSpatialIndexPerGraph(datasetGraph, srsURI);
+    }
+
+//    public static SpatialIndexPerGraph buildSpatialIndex(DatasetGraph datasetGraph, String srsURI, boolean indexTreePerGraph) throws SpatialIndexException {
+//        SpatialIndexPerGraph result = indexTreePerGraph
+//            ? buildSpatialIndexPerGraph(datasetGraph, srsURI)
+//            : buildSpatialIndexUnion(datasetGraph, srsURI);
+//        return result;
+//    }
+
+//    public static SpatialIndexPerGraph buildSpatialIndexUnion(DatasetGraph datasetGraph, String srsURI) throws SpatialIndexException {
+//        // we always compute an index tree DGT for the default graph
+//        // if an index per named graph NG is enabled, we compute a separate index tree NGT for each NG, otherwise all
+//        // items will be indexed in the default graph index tree DGT
+//
+//        STRtree treePerGraph;
+//        LOGGER.info("Building Spatial Index - Started");
+//        try (AutoTxn txn = Txn.begin(datasetGraph, TxnType.READ)) {
+//            treePerGraph = STRtreeUtils.buildSpatialIndexTreeUnion(datasetGraph, srsURI);
+//        }
+//        LOGGER.info("Building Spatial Index - Completed");
+//
+//        SRSInfo srsInfo = SRSRegistry.getSRSInfo(srsURI);
+//        STRtreePerGraph trees = new STRtreePerGraph(treePerGraph);
+//        SpatialIndexPerGraph index = new SpatialIndexPerGraph(srsInfo, trees, null);
+//        setSpatialIndex(datasetGraph, index);
+//        return index;
+//    }
+
+    public static SpatialIndexPerGraph buildSpatialIndexPerGraph(DatasetGraph datasetGraph, String srsURI) throws SpatialIndexException {
+        // we always compute an index tree DGT for the default graph
+        // if an index per named graph NG is enabled, we compute a separate index tree NGT for each NG, otherwise all
+        // items will be indexed in the default graph index tree DGT
+
+        STRtreePerGraph treePerGraph;
+        LOGGER.info("Building Spatial Index - Started");
+        try (AutoTxn txn = Txn.autoTxn(datasetGraph, TxnType.READ)) {
+            treePerGraph = STRtreeUtils.buildSpatialIndexTree(datasetGraph, srsURI);
+            txn.commit();
+        }
+        LOGGER.info("Building Spatial Index - Completed");
+
+        SRSInfo srsInfo = SRSRegistry.getSRSInfo(srsURI);
+        SpatialIndexPerGraph index = new SpatialIndexPerGraph(srsInfo, treePerGraph, null);
+        setSpatialIndex(datasetGraph, index);
+        return index;
+    }
+
+    public static Node unwrapGraphName(Graph graph) {
+        Node graphNode = graph instanceof NamedGraph namedGraph
+            ? namedGraph.getGraphName()
+            : null;
+        return graphNode;
+    }
+}
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v2/SpatialIndexerComputation.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v2/SpatialIndexerComputation.java
new file mode 100644
index 0000000000..eb7c182b53
--- /dev/null
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/index/v2/SpatialIndexerComputation.java
@@ -0,0 +1,173 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.jena.geosparql.spatial.index.v2;
+
+import java.lang.invoke.MethodHandles;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.concurrent.CancellationException;
+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.concurrent.atomic.AtomicBoolean;
+
+import org.apache.jena.geosparql.spatial.SpatialIndexException;
+import org.apache.jena.graph.Graph;
+import org.apache.jena.graph.Node;
+import org.apache.jena.query.TxnType;
+import org.apache.jena.sparql.core.DatasetGraph;
+import org.apache.jena.sparql.core.Quad;
+import org.apache.jena.system.AutoTxn;
+import org.apache.jena.system.Txn;
+import org.locationtech.jts.index.strtree.STRtree;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class SpatialIndexerComputation {
+    private static final Logger LOGGER = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+    private DatasetGraph datasetGraph;
+    private List<Node> graphNodes;
+    private ExecutorService executorService;
+    private String srsURI;
+    private int threadCount;
+
+    private final AtomicBoolean requestingCancel = new AtomicBoolean();
+    private volatile boolean cancelOnce = false;
+    private Object cancelLock = new Object();
+
+    private List<Future<Entry<Node, STRtree>>> futures = new ArrayList<>();
+
+    /**
+     * Create an instance of a spatial indexer computation.
+     *
+     * @param datasetGraph The dataset graph for which to index the spatial data.
+     * @param srsURI       The spatial reference system against which to index the data.
+     * @param graphNodes   The names of the physical graphs which to process. May use Quad.defaultGraphIRI but not e.g. Quad.unionGraph.
+     * @param threadCount  Maximum number of threads to use for indexing. Must be at least 1.
+     */
+    public SpatialIndexerComputation(DatasetGraph datasetGraph, String srsURI, List<Node> graphNodes, int threadCount) {
+        this.datasetGraph = datasetGraph;
+        this.graphNodes = graphNodes;
+        this.srsURI = srsURI;
+        this.threadCount = threadCount;
+
+        if (threadCount <= 0) {
+            throw new IllegalArgumentException("Thread count must be greater than 0.");
+        }
+    }
+
+    public List<Node> getGraphNodes() {
+        return graphNodes;
+    }
+
+    private Entry<Node, STRtree> indexOneGraph(Node graphNode) throws SpatialIndexException {
+        LOGGER.info("Started spatial index build for graph {} ...", graphNode);
+        STRtree tree;
+
+        try (AutoTxn txn = Txn.autoTxn(datasetGraph, TxnType.READ)) {
+            Graph graph = Quad.isDefaultGraph(graphNode) // XXX would getGraph work with the default graph URIs?
+                ? datasetGraph.getDefaultGraph()
+                : datasetGraph.getGraph(graphNode);
+            tree = STRtreeUtils.buildSpatialIndexTree(graph, srsURI);
+
+            // XXX This commit is a workaround for DatasetGraphText.abort() causing a NPE in
+            // during multi-threaded spatial index computation.
+            // This is an issue related to the transaction mechanics of DatasetGraphText.
+            txn.commit();
+        }
+        LOGGER.info("Completed spatial index for graph {}", graphNode);
+
+        return Map.entry(graphNode, tree);
+    }
+
+    public SpatialIndexPerGraph call() throws InterruptedException, ExecutionException {
+        synchronized (cancelLock) {
+            if (executorService != null) {
+                throw new IllegalStateException("Task already running.");
+            }
+            executorService = Executors.newFixedThreadPool(threadCount);
+        }
+
+        // Beware: We expect special graphNodes such as Quad.unionGraph to have been resolved
+        // to the explicit graph names before coming here.
+        for (Node graphNode : graphNodes) {
+            checkRequestingCancel();
+            Future<Entry<Node, STRtree>> future = executorService.submit(() -> indexOneGraph(graphNode));
+            futures.add(future);
+        }
+
+        // Collect all futures into a map.
+        Map<Node, STRtree> treeMap = new LinkedHashMap<Node, STRtree>();
+        for (Future<Entry<Node, STRtree>> future : futures) {
+            Entry<Node, STRtree> entry = future.get();
+            Node graphNode = entry.getKey();
+            STRtree tree = entry.getValue();
+            treeMap.put(graphNode, tree);
+        }
+
+        STRtreePerGraph trees = new STRtreePerGraph();
+        trees.setTrees(treeMap);
+        SpatialIndexPerGraph result = new SpatialIndexPerGraph(trees);
+        return result;
+    }
+
+    protected void checkRequestingCancel() {
+        boolean isCancelled = requestingCancel();
+        if (isCancelled) {
+            throw new CancellationException();
+        }
+    }
+
+    public void abort() {
+        synchronized (cancelLock) {
+            requestingCancel.set(true);
+            if (!cancelOnce) {
+                requestCancel();
+                cancelOnce = true;
+            }
+        }
+    }
+
+    public void close() {
+        if (executorService != null) {
+            executorService.shutdownNow();
+            try {
+                executorService.awaitTermination(5, TimeUnit.SECONDS);
+            } catch (InterruptedException e) {
+                throw new RuntimeException("Abandoning an executor serivce that failed to stop.", e);
+            }
+        }
+    }
+
+    protected void requestCancel() { }
+
+    private boolean requestingCancel() {
+        return (requestingCancel != null && requestingCancel.get()) || Thread.interrupted() ;
+    }
+
+    protected void performRequestingCancel() {
+        // Tasks will be aborted on close - so nothing to do here.
+    }
+}
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/property_functions/GenericSpatialPropertyFunction.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/property_functions/GenericSpatialPropertyFunction.java
index aa1b2e3427..4e9b63245a 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/property_functions/GenericSpatialPropertyFunction.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/property_functions/GenericSpatialPropertyFunction.java
@@ -18,7 +18,7 @@
 package org.apache.jena.geosparql.spatial.property_functions;
 
 import java.util.Arrays;
-import java.util.HashSet;
+import java.util.Collection;
 import java.util.Iterator;
 import java.util.List;
 import java.util.stream.Stream;
@@ -33,10 +33,10 @@ import org.apache.jena.geosparql.spatial.ConvertLatLon;
 import org.apache.jena.geosparql.spatial.SearchEnvelope;
 import org.apache.jena.geosparql.spatial.SpatialIndex;
 import org.apache.jena.geosparql.spatial.SpatialIndexException;
+import org.apache.jena.geosparql.spatial.index.v2.SpatialIndexUtils;
 import org.apache.jena.graph.Graph;
 import org.apache.jena.graph.Node;
 import org.apache.jena.graph.Triple;
-import org.apache.jena.rdf.model.Resource;
 import org.apache.jena.sparql.core.Var;
 import org.apache.jena.sparql.engine.ExecutionContext;
 import org.apache.jena.sparql.engine.QueryIterator;
@@ -66,7 +66,7 @@ public abstract class GenericSpatialPropertyFunction extends PFuncSimpleAndList
     @Override
     public final QueryIterator execEvaluated(Binding binding, Node subject, Node predicate, PropFuncArg object, ExecutionContext execCxt) {
         try {
-            spatialIndex = SpatialIndex.retrieve(execCxt);
+            spatialIndex = SpatialIndexUtils.retrieve(execCxt);
             spatialArguments = extractObjectArguments(predicate, object, spatialIndex.getSrsInfo());
             return search(binding, execCxt, subject, spatialArguments.limit);
         } catch (SpatialIndexException ex) {
@@ -184,15 +184,17 @@ public abstract class GenericSpatialPropertyFunction extends PFuncSimpleAndList
 
         //Find all Features in the spatial index which are within the rough search envelope.
         SearchEnvelope searchEnvelope = spatialArguments.searchEnvelope;
-        HashSet<Resource> features = searchEnvelope.check(spatialIndex);
+        Graph activeGraph = execCxt.getActiveGraph();
 
+        Node graphName = SpatialIndexUtils.unwrapGraphName(activeGraph);
+        Collection<Node> features = searchEnvelope.check(spatialIndex, graphName);
         Var subjectVar = Var.alloc(subject.getName());
 
-        Stream<Resource> stream = features.stream();
+        Stream<Node> stream = features.stream();
         if (requireSecondFilter()) {
-            stream = stream.filter(feature -> checkBound(execCxt, feature.asNode()));
+            stream = stream.filter(feature -> checkBound(execCxt, feature));
         }
-        Iterator<Binding> iterator = stream.map(feature -> BindingFactory.binding(binding, subjectVar, feature.asNode()))
+        Iterator<Binding> iterator = stream.map(feature -> BindingFactory.binding(binding, subjectVar, feature))
                 .limit(limit)
                 .iterator();
         return QueryIterPlainWrapper.create(iterator, execCxt);
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/task/AbortableThread.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/task/AbortableThread.java
new file mode 100644
index 0000000000..a32c1d0baa
--- /dev/null
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/task/AbortableThread.java
@@ -0,0 +1,199 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.jena.geosparql.spatial.task;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.BiConsumer;
+
+import org.apache.jena.geosparql.spatial.task.TaskControl.Registration;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Thread base class that provides a {@link #cancel()} method that can run a custom action
+ * (in addition to setting the interrupted flag) as well as a doAfterRun() method
+ * for cleaning up after execution (only gets called if there is a prior call to run).
+ */
+public abstract class AbortableThread<T>
+    extends Thread
+{
+    private static final Logger logger = LoggerFactory.getLogger(AbortableThread.class);
+
+    private final AtomicBoolean requestingCancel;
+    private volatile boolean cancelOnce = false;
+    private Object cancelLock = new Object();
+
+    protected List<BiConsumer<? super T, Throwable>> completionHandlers = new ArrayList<>();
+
+    private boolean isComplete = false;
+    private T value = null;
+    private Throwable throwable;
+
+    private Instant startTime;
+    private String statusMessage;
+
+    /**
+     * Time of the first abort request; null if there is none.
+     * Cancel time will not be set if cancel is called after a task's completion.
+     */
+    private Instant cancelTime;
+    private Instant endTime;
+
+    // XXX Add native support for CompletableFuture?
+
+    public AbortableThread() {
+        this(new AtomicBoolean());
+    }
+
+    public AbortableThread(AtomicBoolean requestingCancel) {
+        super();
+        this.requestingCancel = Objects.requireNonNull(requestingCancel);
+    }
+
+    public final boolean isComplete() {
+        return isComplete;
+    }
+
+    public T getValue() {
+        return value;
+    }
+
+    public Throwable getThrowable() {
+        return throwable;
+    }
+
+    public Instant getStartTime() {
+        return startTime;
+    }
+
+    public Instant getEndTime() {
+        return endTime;
+    }
+
+    public Instant getCancelTime() {
+        return cancelTime;
+    }
+
+    protected void setStatusMessage(String statusMessage) {
+        this.statusMessage = statusMessage;
+    }
+
+    public String getStatusMessage() {
+        return statusMessage;
+    }
+
+    @Override
+    public final void run() {
+        try {
+            startTime = Instant.now();
+            runInternal();
+        } finally {
+            synchronized (cancelLock) {
+                fireEvents();
+                // future.complete(null);
+            }
+        }
+    }
+
+    public final void runInternal() {
+        try {
+            runActual();
+        } catch (Throwable t) {
+            // t.addSuppressed(new RuntimeException("An error occurred."));
+            throwable = t;
+            throw new RuntimeException(t);
+        } finally {
+            endTime = Instant.now();
+            isComplete = true;
+            doAfterRun();
+        }
+    }
+
+    public abstract void runActual() throws Exception;
+
+    /** Returns true iff {@link #cancel()} was called. */
+    public final boolean isCancelled() {
+        return cancelOnce;
+    }
+
+    public final void cancel() {
+        synchronized (cancelLock) {
+            if ( ! cancelOnce && !isComplete ) {
+                // Need to set the flags before allowing subclasses to handle requestCancel() in order
+                // to prevent a race condition. We want to be sure that calls to have hasNext()/nextBinding()
+                // will definitely throw a QueryCancelledException in this class and
+                // not allow a situation in which a subclass component thinks it is cancelled,
+                // while this class does not.
+                if ( requestingCancel != null )
+                    // Signalling from timeouts
+                    requestingCancel.set(true);
+                cancelOnce = true;
+                cancelTime = Instant.now();
+                this.requestCancel();
+            }
+        }
+    }
+
+    protected void requestCancel() {
+        this.interrupt();
+    }
+
+    protected void doAfterRun() {
+    }
+
+    protected void fireEvents() {
+        for (BiConsumer<? super T, Throwable> handler : completionHandlers) {
+            try {
+                handler.accept(null, throwable);
+            } catch (Throwable t) {
+                if (logger.isWarnEnabled()) {
+                    logger.warn("A task completion handler raised an exception.", t);
+                }
+            }
+        }
+    }
+
+    /**
+     * Registered an action to run on completion of the thread. An action is run at most once.
+     *
+     * @param action The action is invoked with any thrown exception - null if there was none.
+     * @return A registration that can be used to unregister the listener early.
+     */
+    protected Registration whenComplete(BiConsumer<? super T, Throwable> action) {
+        Objects.requireNonNull(action);
+        synchronized (cancelLock) {
+            boolean isAdded = completionHandlers.add(action);
+            if (isAdded) {
+                // Immediately resolve if already complete.
+                if (isComplete()) {
+                    action.accept(value, throwable);
+                }
+            }
+            return () -> {
+                synchronized (cancelLock) {
+                    completionHandlers.remove(action);
+                }
+            };
+        }
+    }
+}
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/task/TaskControl.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/task/TaskControl.java
new file mode 100644
index 0000000000..f118c19c29
--- /dev/null
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/task/TaskControl.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.jena.geosparql.spatial.task;
+
+import java.time.Instant;
+import java.util.function.Consumer;
+
+/** An outside view of a running task */
+public interface TaskControl<S> {
+    @FunctionalInterface
+    interface Registration {
+        void dispose();
+    }
+
+    String getLabel();
+    S getSource();
+    void abort();
+    boolean isAborting();
+
+    boolean isComplete();
+    Throwable getThrowable(); // Only meaningful if isComplete is true.
+
+    Instant getStartTime();
+    Instant getEndTime();
+    Instant getCancelTime();
+
+    /** Get the last status message of the task. May be null. */
+    String getStatusMessage();
+
+    /**
+     * Registered actions are run only once, then the registration is removed automatically.
+     *
+     * @param action The action is invoked with any thrown exception - null if there was none.
+     * @return A registration that can be used to unregister the listener early.
+     */
+    Registration whenComplete(Consumer<Throwable> action);
+}
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/task/TaskControlOverAbortableThread.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/task/TaskControlOverAbortableThread.java
new file mode 100644
index 0000000000..986d6d8dbd
--- /dev/null
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/task/TaskControlOverAbortableThread.java
@@ -0,0 +1,105 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.jena.geosparql.spatial.task;
+
+import java.time.Instant;
+import java.util.Objects;
+import java.util.function.Consumer;
+
+public class TaskControlOverAbortableThread<S>
+    implements TaskControl<S>
+{
+    protected String label;
+    protected S source;
+    protected AbortableThread<?> thread;
+
+    public TaskControlOverAbortableThread(String label) {
+        super();
+        this.label = label;
+    }
+
+    public void setThread(AbortableThread<?> thread) {
+        this.thread = thread;
+    }
+
+    public void setSource(S source) {
+        this.source = source;
+    }
+
+    @Override
+    public String getLabel() {
+        return label;
+    }
+
+    @Override
+    public S getSource() {
+        return source;
+    }
+
+    protected AbortableThread<?> requireThread() {
+        Objects.requireNonNull(thread);
+        return thread;
+    }
+
+    @Override
+    public Instant getStartTime() {
+        return requireThread().getStartTime();
+    }
+
+    @Override
+    public Instant getEndTime() {
+        return requireThread().getEndTime();
+    }
+
+    @Override
+    public Instant getCancelTime() {
+        return requireThread().getCancelTime();
+    }
+
+    @Override
+    public void abort() {
+        requireThread().cancel();
+    }
+
+    @Override
+    public boolean isAborting() {
+        return requireThread().isCancelled();
+    }
+
+    @Override
+    public boolean isComplete() {
+        return !requireThread().isAlive();
+    }
+
+    @Override
+    public Throwable getThrowable() {
+        return requireThread().getThrowable();
+    }
+
+    @Override
+    public Registration whenComplete(Consumer<Throwable> action) {
+        // Note: The thread's result value is discarded here.
+        return requireThread().whenComplete((v, t) -> action.accept(t));
+    }
+
+    @Override
+    public String getStatusMessage() {
+        return requireThread().getStatusMessage();
+    }
+}
diff --git a/jena-geosparql/src/main/java/org/locationtech/jts/index/strtree/STRtreeSerializer.java b/jena-geosparql/src/main/java/org/locationtech/jts/index/strtree/STRtreeSerializer.java
new file mode 100644
index 0000000000..127a2f8e9c
--- /dev/null
+++ b/jena-geosparql/src/main/java/org/locationtech/jts/index/strtree/STRtreeSerializer.java
@@ -0,0 +1,168 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.locationtech.jts.index.strtree;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.locationtech.jts.geom.Envelope;
+
+import com.esotericsoftware.kryo.Kryo;
+import com.esotericsoftware.kryo.Serializer;
+import com.esotericsoftware.kryo.io.Input;
+import com.esotericsoftware.kryo.io.Output;
+
+/*
+ * This file is an adapted copy of org.locationtech.jts.index.strtree.IndexSerde from
+ * the Apache Sedona project:
+ *
+ * https://github.com/apache/sedona/blob/sedona-1.7.0/common/src/main/java/org/locationtech/jts/index/strtree/IndexSerde.java
+ *
+ * This file contains the class STRtreeSerializer for kryo-based serialization of
+ * STRtree instances from the Java Topology Suite (JTS).
+ *
+ * The STRtreeSerializer must remain in the org.locationtech.jts.index.strtree package because
+ * it accesses non-public members of STRtree.
+ *
+ * The change over the Sedona version is, that the tree content is written out by handing control
+ * idiomatically back to kryo instead of making assumptions about the tree content.
+ */
+
+/**
+ * Provides methods to efficiently serialize and deserialize the index. trees
+ * are serialized recursively.
+ */
+@SuppressWarnings({ "rawtypes", "unchecked" })
+public class STRtreeSerializer
+    extends Serializer<STRtree>
+{
+    @Override
+    public STRtree read(Kryo kryo, Input input, Class<STRtree> type) {
+        int nodeCapacity = input.readInt();
+        boolean notEmpty = (input.readByte() & 0x01) == 1;
+        if (notEmpty) {
+            boolean built = (input.readByte() & 0x01) == 1;
+            if (built) {
+                // if built, root is not null, set itemBoundables to null
+                STRtree index = new STRtree(nodeCapacity, readSTRtreeNode(kryo, input));
+                return index;
+            } else {
+                // if not built, just read itemBoundables
+                ArrayList itemBoundables = new ArrayList();
+                int itemSize = input.readInt();
+                for (int i = 0; i < itemSize; ++i) {
+                    itemBoundables.add(readItemBoundable(kryo, input));
+                }
+                STRtree index = new STRtree(nodeCapacity, itemBoundables);
+                return index;
+            }
+        } else {
+            return new STRtree(nodeCapacity);
+        }
+    }
+
+    @Override
+    public void write(Kryo kryo, Output output, STRtree tree) {
+        output.writeInt(tree.getNodeCapacity());
+        if (tree.isEmpty()) {
+            output.writeByte(0);
+        } else {
+            output.writeByte(1);
+            // write head
+            boolean isBuilt = tree.getItemBoundables() == null;
+            output.writeByte(isBuilt ? 1 : 0);
+            if (!isBuilt) {
+                // if not built, itemBoundables will not be null, record it
+                ArrayList itemBoundables = tree.getItemBoundables();
+                output.writeInt(itemBoundables.size());
+                for (Object obj : itemBoundables) {
+                    if (!(obj instanceof ItemBoundable)) {
+                        throw new UnsupportedOperationException(
+                                " itemBoundables should only contain ItemBoundable objects ");
+                    }
+                    ItemBoundable itemBoundable = (ItemBoundable) obj;
+                    // write envelope
+                    writeItemBoundable(kryo, output, itemBoundable);
+                }
+            } else {
+                // if built, write from root
+                writeSTRTreeNode(kryo, output, tree.getRoot());
+            }
+        }
+    }
+
+    private void writeSTRTreeNode(Kryo kryo, Output output, AbstractNode node) {
+        // write head
+        output.writeInt(node.getLevel());
+        // write children
+        List children = node.getChildBoundables();
+        int childrenSize = children.size();
+        output.writeInt(childrenSize);
+        // if children not empty, write children
+        if (childrenSize > 0) {
+            if (children.get(0) instanceof AbstractNode) {
+                // write type as 0, non-leaf node
+                output.writeByte(0);
+                for (Object obj : children) {
+                    AbstractNode child = (AbstractNode) obj;
+                    writeSTRTreeNode(kryo, output, child);
+                }
+            } else if (children.get(0) instanceof ItemBoundable) {
+                // write type as 1, leaf node
+                output.writeByte(1);
+                // for leaf node, write items
+                for (Object obj : children) {
+                    writeItemBoundable(kryo, output, (ItemBoundable) obj);
+                }
+            } else {
+                throw new UnsupportedOperationException("wrong node type of STRtree");
+            }
+        }
+    }
+
+    private STRtree.STRtreeNode readSTRtreeNode(Kryo kryo, Input input) {
+        int level = input.readInt();
+        STRtree.STRtreeNode node = new STRtree.STRtreeNode(level);
+        int childrenSize = input.readInt();
+        boolean isLeaf = (input.readByte() & 0x01) == 1;
+        ArrayList children = new ArrayList();
+        if (isLeaf) {
+            for (int i = 0; i < childrenSize; ++i) {
+                children.add(readItemBoundable(kryo, input));
+            }
+        } else {
+            for (int i = 0; i < childrenSize; ++i) {
+                children.add(readSTRtreeNode(kryo, input));
+            }
+        }
+        node.setChildBoundables(children);
+        return node;
+    }
+
+    private void writeItemBoundable(Kryo kryo, Output output, ItemBoundable itemBoundable) {
+        kryo.writeObject(output, itemBoundable.getBounds());
+        kryo.writeClassAndObject(output, itemBoundable.getItem());
+    }
+
+    private ItemBoundable readItemBoundable(Kryo kryo, Input input) {
+        Envelope envelope = kryo.readObject(input, Envelope.class);
+        Object item = kryo.readClassAndObject(input);
+        return new ItemBoundable(envelope, item);
+    }
+}
diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/geo/topological/CancelQueryTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/geo/topological/CancelQueryTest.java
index 7b54ebb904..274a1582dd 100644
--- a/jena-geosparql/src/test/java/org/apache/jena/geosparql/geo/topological/CancelQueryTest.java
+++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/geo/topological/CancelQueryTest.java
@@ -33,6 +33,7 @@ import org.apache.jena.geosparql.implementation.index.IndexConfiguration;
 import org.apache.jena.geosparql.implementation.vocabulary.Geo;
 import org.apache.jena.geosparql.spatial.SpatialIndex;
 import org.apache.jena.geosparql.spatial.SpatialIndexException;
+import org.apache.jena.geosparql.spatial.index.v2.SpatialIndexUtils;
 import org.apache.jena.graph.Graph;
 import org.apache.jena.graph.NodeFactory;
 import org.apache.jena.query.Dataset;
@@ -106,8 +107,8 @@ public class CancelQueryTest {
         // create spatial index
         if (useIndex){
             try {
-                SpatialIndex index = SpatialIndex.buildSpatialIndex(ds);
-                SpatialIndex.setSpatialIndex(ds, index);
+                SpatialIndex index = SpatialIndexUtils.buildSpatialIndex(ds.asDatasetGraph());
+                SpatialIndexUtils.setSpatialIndex(ds, index);
             } catch (SpatialIndexException e) {
                 throw new RuntimeException(e);
             }
diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/geo/topological/GenericPropertyFunctionTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/geo/topological/GenericPropertyFunctionTest.java
index 5a99718258..b20388d4fd 100644
--- a/jena-geosparql/src/test/java/org/apache/jena/geosparql/geo/topological/GenericPropertyFunctionTest.java
+++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/geo/topological/GenericPropertyFunctionTest.java
@@ -17,7 +17,17 @@
  */
 package org.apache.jena.geosparql.geo.topological;
 
-import static org.apache.jena.geosparql.geo.topological.QueryRewriteTestData.*;
+import static org.apache.jena.geosparql.geo.topological.QueryRewriteTestData.FEATURE_A;
+import static org.apache.jena.geosparql.geo.topological.QueryRewriteTestData.FEATURE_B;
+import static org.apache.jena.geosparql.geo.topological.QueryRewriteTestData.FEATURE_D;
+import static org.apache.jena.geosparql.geo.topological.QueryRewriteTestData.GEOMETRY_A;
+import static org.apache.jena.geosparql.geo.topological.QueryRewriteTestData.GEOMETRY_B;
+import static org.apache.jena.geosparql.geo.topological.QueryRewriteTestData.GEOMETRY_C_BLANK;
+import static org.apache.jena.geosparql.geo.topological.QueryRewriteTestData.GEOMETRY_D;
+import static org.apache.jena.geosparql.geo.topological.QueryRewriteTestData.GEOMETRY_F;
+import static org.apache.jena.geosparql.geo.topological.QueryRewriteTestData.GEO_FEATURE_Y;
+import static org.apache.jena.geosparql.geo.topological.QueryRewriteTestData.GEO_FEATURE_Z;
+import static org.apache.jena.geosparql.geo.topological.QueryRewriteTestData.TEST_SRS_URI;
 import static org.junit.Assert.assertEquals;
 
 import java.util.ArrayList;
@@ -30,16 +40,25 @@ import org.apache.jena.geosparql.geo.topological.property_functions.simple_featu
 import org.apache.jena.geosparql.implementation.index.IndexConfiguration.IndexOption;
 import org.apache.jena.geosparql.implementation.index.QueryRewriteIndex;
 import org.apache.jena.geosparql.implementation.vocabulary.Geo;
-import org.apache.jena.geosparql.spatial.SpatialIndex;
 import org.apache.jena.geosparql.spatial.SpatialIndexException;
+import org.apache.jena.geosparql.spatial.index.v2.SpatialIndexUtils;
 import org.apache.jena.graph.Graph;
 import org.apache.jena.graph.Node;
 import org.apache.jena.graph.NodeFactory;
-import org.apache.jena.query.*;
+import org.apache.jena.query.Dataset;
+import org.apache.jena.query.QueryExecution;
+import org.apache.jena.query.QueryExecutionFactory;
+import org.apache.jena.query.QuerySolution;
+import org.apache.jena.query.ResultSet;
 import org.apache.jena.rdf.model.Model;
+import org.apache.jena.rdf.model.ModelFactory;
 import org.apache.jena.rdf.model.Resource;
 import org.apache.jena.vocabulary.RDF;
-import org.junit.*;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
 
 /**
  *
@@ -47,8 +66,11 @@ import org.junit.*;
  */
 public class GenericPropertyFunctionTest {
 
-    private static Model model;
-    private static Dataset dataset;
+    private static Model modelGeoSparql;
+    private static Dataset datasetGeoSparql;
+
+    private static Model modelWgs84;
+    private static Dataset datasetWgs84;
 
     public GenericPropertyFunctionTest() {
     }
@@ -56,8 +78,11 @@ public class GenericPropertyFunctionTest {
     @BeforeClass
     public static void setUpClass() throws SpatialIndexException {
         GeoSPARQLConfig.setup(IndexOption.MEMORY, Boolean.TRUE);
-        model = QueryRewriteTestData.createTestData();
-        dataset = SpatialIndex.wrapModel(model, TEST_SRS_URI);
+        modelGeoSparql = QueryRewriteTestData.addTestDataGeoSparql(ModelFactory.createDefaultModel());
+        datasetGeoSparql = SpatialIndexUtils.wrapModel(modelGeoSparql, TEST_SRS_URI);
+
+        modelWgs84 = QueryRewriteTestData.addTestDataWgs84(ModelFactory.createDefaultModel());
+        datasetWgs84 = SpatialIndexUtils.wrapModel(modelWgs84, TEST_SRS_URI);
     }
 
     @AfterClass
@@ -78,7 +103,7 @@ public class GenericPropertyFunctionTest {
     @Test
     public void testQueryRewrite_geometry_geometry() {
 
-        Graph graph = model.getGraph();
+        Graph graph = modelGeoSparql.getGraph();
         Node subject = GEOMETRY_A.asNode();
         Node predicate = Geo.SF_CONTAINS_NODE;
         Node object = GEOMETRY_B.asNode();
@@ -95,7 +120,7 @@ public class GenericPropertyFunctionTest {
     @Test
     public void testQueryRewrite_geometry_geometry_blank() {
 
-        Graph graph = model.getGraph();
+        Graph graph = modelGeoSparql.getGraph();
         Node subject = GEOMETRY_A.asNode();
         Node predicate = Geo.SF_CONTAINS_NODE;
         Node object = GEOMETRY_C_BLANK.asNode();
@@ -112,8 +137,7 @@ public class GenericPropertyFunctionTest {
     @Test
     public void testQueryRewrite_blank() {
 
-
-        Graph graph = model.getGraph();
+        Graph graph = modelGeoSparql.getGraph();
 
         Boolean expResult = true;
         String id = GEOMETRY_C_BLANK.asNode().getBlankNodeLabel();
@@ -129,7 +153,7 @@ public class GenericPropertyFunctionTest {
     @Test
     public void testQueryRewrite_geometry_geometry_disabled() {
         GeoSPARQLConfig.setup(IndexOption.MEMORY, Boolean.FALSE);
-        Graph graph = model.getGraph();
+        Graph graph = modelGeoSparql.getGraph();
         Node subject = GEOMETRY_A.asNode();
         Node predicate = Geo.SF_CONTAINS_NODE;
         Node object = FEATURE_B.asNode();
@@ -148,7 +172,7 @@ public class GenericPropertyFunctionTest {
     @Test
     public void testQueryRewrite_feature_geometry() {
 
-        Graph graph = model.getGraph();
+        Graph graph = modelGeoSparql.getGraph();
         Node subject = FEATURE_A.asNode();
         Node predicate = Geo.SF_CONTAINS_NODE;
         Node object = GEOMETRY_B.asNode();
@@ -165,7 +189,7 @@ public class GenericPropertyFunctionTest {
     @Test
     public void testQueryRewrite_feature_feature() {
 
-        Graph graph = model.getGraph();
+        Graph graph = modelGeoSparql.getGraph();
         Node subject = FEATURE_A.asNode();
         Node predicate = Geo.SF_CONTAINS_NODE;
         Node object = FEATURE_B.asNode();
@@ -182,7 +206,7 @@ public class GenericPropertyFunctionTest {
     @Test
     public void testQueryRewrite_geometry_feature() {
 
-        Graph graph = model.getGraph();
+        Graph graph = modelGeoSparql.getGraph();
         Node subject = GEOMETRY_A.asNode();
         Node predicate = Geo.SF_CONTAINS_NODE;
         Node object = FEATURE_B.asNode();
@@ -200,7 +224,7 @@ public class GenericPropertyFunctionTest {
     public void testQueryRewrite_geometry_feature_disabled() {
 
         GeoSPARQLConfig.setup(IndexOption.MEMORY, Boolean.FALSE);
-        Graph graph = model.getGraph();
+        Graph graph = modelGeoSparql.getGraph();
         Node subject = GEOMETRY_A.asNode();
         Node predicate = Geo.SF_CONTAINS_NODE;
         Node object = FEATURE_B.asNode();
@@ -219,7 +243,7 @@ public class GenericPropertyFunctionTest {
     @Test
     public void testQueryRewrite_geometry_geometry_false() {
 
-        Graph graph = model.getGraph();
+        Graph graph = modelGeoSparql.getGraph();
         Node subject = GEOMETRY_A.asNode();
         Node predicate = Geo.SF_CONTAINS_NODE;
         Node object = GEOMETRY_D.asNode();
@@ -236,7 +260,7 @@ public class GenericPropertyFunctionTest {
     @Test
     public void testQueryRewrite_feature_geometry_false() {
 
-        Graph graph = model.getGraph();
+        Graph graph = modelGeoSparql.getGraph();
         Node subject = FEATURE_A.asNode();
         Node predicate = Geo.SF_CONTAINS_NODE;
         Node object = GEOMETRY_D.asNode();
@@ -253,7 +277,7 @@ public class GenericPropertyFunctionTest {
     @Test
     public void testQueryRewrite_feature_feature_false() {
 
-        Graph graph = model.getGraph();
+        Graph graph = modelGeoSparql.getGraph();
         Node subject = FEATURE_A.asNode();
         Node predicate = Geo.SF_CONTAINS_NODE;
         Node object = FEATURE_D.asNode();
@@ -270,7 +294,7 @@ public class GenericPropertyFunctionTest {
     @Test
     public void testQueryRewrite_geometry_feature_false() {
 
-        Graph graph = model.getGraph();
+        Graph graph = modelGeoSparql.getGraph();
         Node subject = GEOMETRY_A.asNode();
         Node predicate = Geo.SF_CONTAINS_NODE;
         Node object = FEATURE_D.asNode();
@@ -288,7 +312,7 @@ public class GenericPropertyFunctionTest {
     public void testQueryRewrite_geometry_geometry_asserted() {
 
 
-        Graph graph = model.getGraph();
+        Graph graph = modelGeoSparql.getGraph();
         Node subject = GEOMETRY_A.asNode();
         Node predicate = Geo.SF_CONTAINS_NODE;
         Node object = GEOMETRY_F.asNode();
@@ -306,7 +330,7 @@ public class GenericPropertyFunctionTest {
     public void testQueryRewrite_geometry_geometry_asserted_disabled() {
 
         GeoSPARQLConfig.setup(IndexOption.MEMORY, Boolean.FALSE);
-        Graph graph = model.getGraph();
+        Graph graph = modelGeoSparql.getGraph();
         Node subject = GEOMETRY_A.asNode();
         Node predicate = Geo.SF_CONTAINS_NODE;
         Node object = GEOMETRY_F.asNode();
@@ -325,7 +349,7 @@ public class GenericPropertyFunctionTest {
     @Test
     public void testQueryRewrite_feature_feature_disjoint() {
 
-        Graph graph = model.getGraph();
+        Graph graph = modelGeoSparql.getGraph();
         Node subject = FEATURE_A.asNode();
         Node predicate = Geo.SF_DISJOINT_NODE;
         Node object = FEATURE_D.asNode();
@@ -342,7 +366,7 @@ public class GenericPropertyFunctionTest {
     @Test
     public void testQueryRewrite_feature_feature_disjoint_false() {
 
-        Graph graph = model.getGraph();
+        Graph graph = modelGeoSparql.getGraph();
         Node subject = FEATURE_A.asNode();
         Node predicate = Geo.SF_DISJOINT_NODE;
         Node object = FEATURE_B.asNode();
@@ -369,7 +393,7 @@ public class GenericPropertyFunctionTest {
 
         List<Resource> subjects = new ArrayList<>();
         List<Resource> objects = new ArrayList<>();
-        try (QueryExecution qe = QueryExecutionFactory.create(query, dataset)) {
+        try (QueryExecution qe = QueryExecutionFactory.create(query, datasetGeoSparql)) {
             ResultSet rs = qe.execSelect();
             while (rs.hasNext()) {
                 QuerySolution qs = rs.nextSolution();
@@ -405,7 +429,7 @@ public class GenericPropertyFunctionTest {
                 + "}ORDER by ?obj";
 
         List<Resource> objects = new ArrayList<>();
-        try (QueryExecution qe = QueryExecutionFactory.create(query, dataset)) {
+        try (QueryExecution qe = QueryExecutionFactory.create(query, datasetGeoSparql)) {
             ResultSet rs = qe.execSelect();
             while (rs.hasNext()) {
                 QuerySolution qs = rs.nextSolution();
@@ -438,7 +462,7 @@ public class GenericPropertyFunctionTest {
                 + "}ORDER by ?obj";
 
         List<Resource> objects = new ArrayList<>();
-        try (QueryExecution qe = QueryExecutionFactory.create(query, dataset)) {
+        try (QueryExecution qe = QueryExecutionFactory.create(query, datasetGeoSparql)) {
             ResultSet rs = qe.execSelect();
             while (rs.hasNext()) {
                 QuerySolution qs = rs.nextSolution();
@@ -474,7 +498,7 @@ public class GenericPropertyFunctionTest {
                 + "}ORDER by ?subj";
 
         List<Resource> results = new ArrayList<>();
-        try (QueryExecution qe = QueryExecutionFactory.create(query, dataset)) {
+        try (QueryExecution qe = QueryExecutionFactory.create(query, datasetGeoSparql)) {
             ResultSet rs = qe.execSelect();
             while (rs.hasNext()) {
                 QuerySolution qs = rs.nextSolution();
@@ -509,7 +533,7 @@ public class GenericPropertyFunctionTest {
 
         List<Resource> subjects = new ArrayList<>();
         List<Resource> objects = new ArrayList<>();
-        try (QueryExecution qe = QueryExecutionFactory.create(query, dataset)) {
+        try (QueryExecution qe = QueryExecutionFactory.create(query, datasetGeoSparql)) {
             ResultSet rs = qe.execSelect();
             while (rs.hasNext()) {
                 QuerySolution qs = rs.nextSolution();
@@ -530,11 +554,10 @@ public class GenericPropertyFunctionTest {
 
     /**
      * Test of execEvaluated method, of class GenericPropertyFunction.
+     * @throws SpatialIndexException
      */
     @Test
-    public void testExecEvaluated_both_bound_geo() {
-
-
+    public void testExecEvaluated_both_bound_geo() throws SpatialIndexException {
         String query = "PREFIX geo: <http://www.opengis.net/ont/geosparql#>\n"
                 + "\n"
                 + "SELECT ?subj ?obj\n"
@@ -546,7 +569,7 @@ public class GenericPropertyFunctionTest {
 
         List<Resource> subjects = new ArrayList<>();
         List<Resource> objects = new ArrayList<>();
-        try (QueryExecution qe = QueryExecutionFactory.create(query, dataset)) {
+        try (QueryExecution qe = QueryExecutionFactory.create(query, datasetWgs84)) {
             ResultSet rs = qe.execSelect();
             while (rs.hasNext()) {
                 QuerySolution qs = rs.nextSolution();
@@ -582,7 +605,7 @@ public class GenericPropertyFunctionTest {
                 + "}ORDER by ?subj";
 
         List<Resource> results = new ArrayList<>();
-        try (QueryExecution qe = QueryExecutionFactory.create(query, dataset)) {
+        try (QueryExecution qe = QueryExecutionFactory.create(query, datasetGeoSparql)) {
             ResultSet rs = qe.execSelect();
             while (rs.hasNext()) {
                 QuerySolution qs = rs.nextSolution();
diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/geo/topological/QueryRewriteTestData.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/geo/topological/QueryRewriteTestData.java
index a775c7579a..ce260c7033 100644
--- a/jena-geosparql/src/test/java/org/apache/jena/geosparql/geo/topological/QueryRewriteTestData.java
+++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/geo/topological/QueryRewriteTestData.java
@@ -24,7 +24,6 @@ import org.apache.jena.geosparql.implementation.vocabulary.SRS_URI;
 import org.apache.jena.geosparql.implementation.vocabulary.SpatialExtension;
 import org.apache.jena.rdf.model.Literal;
 import org.apache.jena.rdf.model.Model;
-import org.apache.jena.rdf.model.ModelFactory;
 import org.apache.jena.rdf.model.Resource;
 import org.apache.jena.rdf.model.ResourceFactory;
 import org.apache.jena.vocabulary.RDF;
@@ -56,62 +55,58 @@ public class QueryRewriteTestData {
 
     public static final String TEST_SRS_URI = SRS_URI.OSGB36_CRS;
 
-    private static Model TEST_MODEL = null;
-
-    public static final Model createTestData() {
-
-        if (TEST_MODEL == null) {
-            TEST_MODEL = ModelFactory.createDefaultModel();
-
-            //Geometry
-            TEST_MODEL.add(GEOMETRY_A, Geo.HAS_SERIALIZATION_PROP, ResourceFactory.createTypedLiteral("<http://www.opengis.net/def/crs/EPSG/0/27700> POLYGON((0 0, 10 0, 10 10, 0 10, 0 0))", WKTDatatype.INSTANCE));
-            TEST_MODEL.add(GEOMETRY_B, Geo.HAS_SERIALIZATION_PROP, LITERAL_B);
-            TEST_MODEL.add(GEOMETRY_C_BLANK, Geo.HAS_SERIALIZATION_PROP, ResourceFactory.createTypedLiteral("<http://www.opengis.net/def/crs/EPSG/0/27700> POINT(5 5)", WKTDatatype.INSTANCE));
-            TEST_MODEL.add(GEOMETRY_D, Geo.HAS_SERIALIZATION_PROP, ResourceFactory.createTypedLiteral("<http://www.opengis.net/def/crs/EPSG/0/27700> POINT(11 11)", WKTDatatype.INSTANCE));
-            TEST_MODEL.add(GEOMETRY_A, RDF.type, Geo.GEOMETRY_RES);
-            TEST_MODEL.add(GEOMETRY_B, RDF.type, Geo.GEOMETRY_RES);
-            TEST_MODEL.add(GEOMETRY_C_BLANK, RDF.type, Geo.GEOMETRY_RES);
-            TEST_MODEL.add(GEOMETRY_D, RDF.type, Geo.GEOMETRY_RES);
-            TEST_MODEL.add(GEOMETRY_E, RDF.type, Geo.GEOMETRY_RES);
-            TEST_MODEL.add(GEOMETRY_F, RDF.type, Geo.GEOMETRY_RES);
-
-            //Feature
-            TEST_MODEL.add(FEATURE_A, Geo.HAS_DEFAULT_GEOMETRY_PROP, GEOMETRY_A);
-            TEST_MODEL.add(FEATURE_B, Geo.HAS_DEFAULT_GEOMETRY_PROP, GEOMETRY_B);
-            TEST_MODEL.add(FEATURE_C, Geo.HAS_DEFAULT_GEOMETRY_PROP, GEOMETRY_C_BLANK);
-            TEST_MODEL.add(FEATURE_D, Geo.HAS_DEFAULT_GEOMETRY_PROP, GEOMETRY_D);
-            TEST_MODEL.add(FEATURE_A, Geo.HAS_GEOMETRY_PROP, GEOMETRY_A);
-            TEST_MODEL.add(FEATURE_B, Geo.HAS_GEOMETRY_PROP, GEOMETRY_B);
-            TEST_MODEL.add(FEATURE_C, Geo.HAS_GEOMETRY_PROP, GEOMETRY_C_BLANK);
-            TEST_MODEL.add(FEATURE_D, Geo.HAS_GEOMETRY_PROP, GEOMETRY_D);
-            TEST_MODEL.add(FEATURE_A, RDF.type, Geo.FEATURE_RES);
-            TEST_MODEL.add(FEATURE_B, RDF.type, Geo.FEATURE_RES);
-            TEST_MODEL.add(FEATURE_C, RDF.type, Geo.FEATURE_RES);
-            TEST_MODEL.add(FEATURE_D, RDF.type, Geo.FEATURE_RES);
-
-            //Spatial Objects
-            TEST_MODEL.add(FEATURE_A, RDF.type, Geo.SPATIAL_OBJECT_RES);
-            TEST_MODEL.add(FEATURE_B, RDF.type, Geo.SPATIAL_OBJECT_RES);
-            TEST_MODEL.add(FEATURE_C, RDF.type, Geo.SPATIAL_OBJECT_RES);
-            TEST_MODEL.add(FEATURE_D, RDF.type, Geo.SPATIAL_OBJECT_RES);
-            TEST_MODEL.add(GEOMETRY_A, RDF.type, Geo.SPATIAL_OBJECT_RES);
-            TEST_MODEL.add(GEOMETRY_B, RDF.type, Geo.SPATIAL_OBJECT_RES);
-            TEST_MODEL.add(GEOMETRY_C_BLANK, RDF.type, Geo.SPATIAL_OBJECT_RES);
-            TEST_MODEL.add(GEOMETRY_D, RDF.type, Geo.SPATIAL_OBJECT_RES);
-            TEST_MODEL.add(GEOMETRY_E, RDF.type, Geo.SPATIAL_OBJECT_RES);
-            TEST_MODEL.add(GEOMETRY_F, RDF.type, Geo.SPATIAL_OBJECT_RES);
-
-            //Contains asserted
-            TEST_MODEL.add(GEOMETRY_A, Geo.SF_CONTAINS_PROP, GEOMETRY_F);
+    public static final Model addTestDataGeoSparql(Model model) {
+        //Geometry
+        model.add(GEOMETRY_A, Geo.HAS_SERIALIZATION_PROP, ResourceFactory.createTypedLiteral("<http://www.opengis.net/def/crs/EPSG/0/27700> POLYGON((0 0, 10 0, 10 10, 0 10, 0 0))", WKTDatatype.INSTANCE));
+        model.add(GEOMETRY_B, Geo.HAS_SERIALIZATION_PROP, LITERAL_B);
+        model.add(GEOMETRY_C_BLANK, Geo.HAS_SERIALIZATION_PROP, ResourceFactory.createTypedLiteral("<http://www.opengis.net/def/crs/EPSG/0/27700> POINT(5 5)", WKTDatatype.INSTANCE));
+        model.add(GEOMETRY_D, Geo.HAS_SERIALIZATION_PROP, ResourceFactory.createTypedLiteral("<http://www.opengis.net/def/crs/EPSG/0/27700> POINT(11 11)", WKTDatatype.INSTANCE));
+        model.add(GEOMETRY_A, RDF.type, Geo.GEOMETRY_RES);
+        model.add(GEOMETRY_B, RDF.type, Geo.GEOMETRY_RES);
+        model.add(GEOMETRY_C_BLANK, RDF.type, Geo.GEOMETRY_RES);
+        model.add(GEOMETRY_D, RDF.type, Geo.GEOMETRY_RES);
+        model.add(GEOMETRY_E, RDF.type, Geo.GEOMETRY_RES);
+        model.add(GEOMETRY_F, RDF.type, Geo.GEOMETRY_RES);
+
+        //Feature
+        model.add(FEATURE_A, Geo.HAS_DEFAULT_GEOMETRY_PROP, GEOMETRY_A);
+        model.add(FEATURE_B, Geo.HAS_DEFAULT_GEOMETRY_PROP, GEOMETRY_B);
+        model.add(FEATURE_C, Geo.HAS_DEFAULT_GEOMETRY_PROP, GEOMETRY_C_BLANK);
+        model.add(FEATURE_D, Geo.HAS_DEFAULT_GEOMETRY_PROP, GEOMETRY_D);
+        model.add(FEATURE_A, Geo.HAS_GEOMETRY_PROP, GEOMETRY_A);
+        model.add(FEATURE_B, Geo.HAS_GEOMETRY_PROP, GEOMETRY_B);
+        model.add(FEATURE_C, Geo.HAS_GEOMETRY_PROP, GEOMETRY_C_BLANK);
+        model.add(FEATURE_D, Geo.HAS_GEOMETRY_PROP, GEOMETRY_D);
+        model.add(FEATURE_A, RDF.type, Geo.FEATURE_RES);
+        model.add(FEATURE_B, RDF.type, Geo.FEATURE_RES);
+        model.add(FEATURE_C, RDF.type, Geo.FEATURE_RES);
+        model.add(FEATURE_D, RDF.type, Geo.FEATURE_RES);
+
+        //Spatial Objects
+        model.add(FEATURE_A, RDF.type, Geo.SPATIAL_OBJECT_RES);
+        model.add(FEATURE_B, RDF.type, Geo.SPATIAL_OBJECT_RES);
+        model.add(FEATURE_C, RDF.type, Geo.SPATIAL_OBJECT_RES);
+        model.add(FEATURE_D, RDF.type, Geo.SPATIAL_OBJECT_RES);
+        model.add(GEOMETRY_A, RDF.type, Geo.SPATIAL_OBJECT_RES);
+        model.add(GEOMETRY_B, RDF.type, Geo.SPATIAL_OBJECT_RES);
+        model.add(GEOMETRY_C_BLANK, RDF.type, Geo.SPATIAL_OBJECT_RES);
+        model.add(GEOMETRY_D, RDF.type, Geo.SPATIAL_OBJECT_RES);
+        model.add(GEOMETRY_E, RDF.type, Geo.SPATIAL_OBJECT_RES);
+        model.add(GEOMETRY_F, RDF.type, Geo.SPATIAL_OBJECT_RES);
+
+        //Contains asserted
+        model.add(GEOMETRY_A, Geo.SF_CONTAINS_PROP, GEOMETRY_F);
+
+        return model;
+    }
 
-            //Geo Features
-            TEST_MODEL.add(GEO_FEATURE_Y, SpatialExtension.GEO_LAT_PROP, GEO_FEATURE_LAT);
-            TEST_MODEL.add(GEO_FEATURE_Y, SpatialExtension.GEO_LON_PROP, GEO_FEATURE_LON);
-            TEST_MODEL.add(GEO_FEATURE_Z, SpatialExtension.GEO_LAT_PROP, GEO_FEATURE_LAT);
-            TEST_MODEL.add(GEO_FEATURE_Z, SpatialExtension.GEO_LON_PROP, GEO_FEATURE_LON);
+    public static final Model addTestDataWgs84(Model model) {
+        //Geo Features
+        model.add(GEO_FEATURE_Y, SpatialExtension.GEO_LAT_PROP, GEO_FEATURE_LAT);
+        model.add(GEO_FEATURE_Y, SpatialExtension.GEO_LON_PROP, GEO_FEATURE_LON);
+        model.add(GEO_FEATURE_Z, SpatialExtension.GEO_LAT_PROP, GEO_FEATURE_LAT);
+        model.add(GEO_FEATURE_Z, SpatialExtension.GEO_LON_PROP, GEO_FEATURE_LON);
 
-        }
-        return TEST_MODEL;
+        return model;
     }
-
 }
diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/geo/topological/SpatialObjectGeometryLiteralTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/geo/topological/SpatialObjectGeometryLiteralTest.java
index 24aa748eb6..d4b9e29f56 100644
--- a/jena-geosparql/src/test/java/org/apache/jena/geosparql/geo/topological/SpatialObjectGeometryLiteralTest.java
+++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/geo/topological/SpatialObjectGeometryLiteralTest.java
@@ -17,13 +17,15 @@
  */
 package org.apache.jena.geosparql.geo.topological;
 
-import org.apache.jena.datatypes.DatatypeFormatException;
-import org.apache.jena.datatypes.xsd.XSDDatatype;
 import static org.apache.jena.geosparql.geo.topological.QueryRewriteTestData.FEATURE_B;
 import static org.apache.jena.geosparql.geo.topological.QueryRewriteTestData.GEOMETRY_B;
 import static org.apache.jena.geosparql.geo.topological.QueryRewriteTestData.GEO_FEATURE_LITERAL;
 import static org.apache.jena.geosparql.geo.topological.QueryRewriteTestData.GEO_FEATURE_Y;
 import static org.apache.jena.geosparql.geo.topological.QueryRewriteTestData.LITERAL_B;
+import static org.junit.Assert.assertEquals;
+
+import org.apache.jena.datatypes.DatatypeFormatException;
+import org.apache.jena.datatypes.xsd.XSDDatatype;
 import org.apache.jena.geosparql.implementation.vocabulary.SpatialExtension;
 import org.apache.jena.graph.Graph;
 import org.apache.jena.graph.Node;
@@ -33,7 +35,6 @@ import org.apache.jena.rdf.model.Resource;
 import org.apache.jena.rdf.model.ResourceFactory;
 import org.junit.After;
 import org.junit.AfterClass;
-import static org.junit.Assert.*;
 import org.junit.Before;
 import org.junit.BeforeClass;
 import org.junit.Test;
@@ -44,14 +45,16 @@ import org.junit.Test;
  */
 public class SpatialObjectGeometryLiteralTest {
 
-    private static Model MODEL;
+    private static Model MODEL_GEOSPARQL;
+    private static Model MODEL_WGS84;
 
     public SpatialObjectGeometryLiteralTest() {
     }
 
     @BeforeClass
     public static void setUpClass() {
-        MODEL = QueryRewriteTestData.createTestData();
+        MODEL_GEOSPARQL = QueryRewriteTestData.addTestDataGeoSparql(ModelFactory.createDefaultModel());
+        MODEL_WGS84 = QueryRewriteTestData.addTestDataWgs84(ModelFactory.createDefaultModel());
     }
 
     @AfterClass
@@ -72,7 +75,7 @@ public class SpatialObjectGeometryLiteralTest {
     @Test
     public void testRetrieve() {
 
-        Graph graph = MODEL.getGraph();
+        Graph graph = MODEL_GEOSPARQL.getGraph();
         Node targetSpatialObject = null;
         SpatialObjectGeometryLiteral instance = SpatialObjectGeometryLiteral.retrieve(graph, targetSpatialObject);
 
@@ -87,7 +90,7 @@ public class SpatialObjectGeometryLiteralTest {
     @Test
     public void testRetrieveGeometryLiteral_geometry() {
 
-        Graph graph = MODEL.getGraph();
+        Graph graph = MODEL_GEOSPARQL.getGraph();
         Resource targetSpatialObject = GEOMETRY_B;
         SpatialObjectGeometryLiteral expResult = new SpatialObjectGeometryLiteral(GEOMETRY_B.asNode(), LITERAL_B.asNode());
         SpatialObjectGeometryLiteral result = SpatialObjectGeometryLiteral.retrieve(graph, targetSpatialObject.asNode());
@@ -102,7 +105,7 @@ public class SpatialObjectGeometryLiteralTest {
 
         Resource targetSpatialObject = FEATURE_B;
         SpatialObjectGeometryLiteral expResult = new SpatialObjectGeometryLiteral(FEATURE_B.asNode(), LITERAL_B.asNode());
-        SpatialObjectGeometryLiteral result = SpatialObjectGeometryLiteral.retrieve(MODEL.getGraph(), targetSpatialObject.asNode());
+        SpatialObjectGeometryLiteral result = SpatialObjectGeometryLiteral.retrieve(MODEL_GEOSPARQL.getGraph(), targetSpatialObject.asNode());
         assertEquals(expResult, result);
     }
 
@@ -114,7 +117,7 @@ public class SpatialObjectGeometryLiteralTest {
 
         Resource targetSpatialObject = ResourceFactory.createResource("http://example.org#GeometryE");
 
-        SpatialObjectGeometryLiteral instance = SpatialObjectGeometryLiteral.retrieve(MODEL.getGraph(), targetSpatialObject.asNode());
+        SpatialObjectGeometryLiteral instance = SpatialObjectGeometryLiteral.retrieve(MODEL_GEOSPARQL.getGraph(), targetSpatialObject.asNode());
 
         boolean expResult = false;
         boolean result = instance.isValid();
@@ -129,7 +132,7 @@ public class SpatialObjectGeometryLiteralTest {
 
         Resource targetSpatialObject = ResourceFactory.createResource("http://example.org#X");
 
-        SpatialObjectGeometryLiteral instance = SpatialObjectGeometryLiteral.retrieve(MODEL.getGraph(), targetSpatialObject.asNode());
+        SpatialObjectGeometryLiteral instance = SpatialObjectGeometryLiteral.retrieve(MODEL_GEOSPARQL.getGraph(), targetSpatialObject.asNode());
 
         boolean expResult = false;
         boolean result = instance.isValid();
@@ -144,7 +147,7 @@ public class SpatialObjectGeometryLiteralTest {
 
         Resource targetSpatialObject = GEO_FEATURE_Y;
         SpatialObjectGeometryLiteral expResult = new SpatialObjectGeometryLiteral(GEO_FEATURE_Y.asNode(), GEO_FEATURE_LITERAL.asNode());
-        SpatialObjectGeometryLiteral result = SpatialObjectGeometryLiteral.retrieve(MODEL.getGraph(), targetSpatialObject.asNode());
+        SpatialObjectGeometryLiteral result = SpatialObjectGeometryLiteral.retrieve(MODEL_WGS84.getGraph(), targetSpatialObject.asNode());
         assertEquals(expResult, result);
     }
 
diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/AbstractSpatialIndexGraphLookpTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/AbstractSpatialIndexGraphLookpTest.java
new file mode 100644
index 0000000000..5ec77e72ef
--- /dev/null
+++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/AbstractSpatialIndexGraphLookpTest.java
@@ -0,0 +1,199 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jena.geosparql.spatial;
+
+import org.apache.jena.geosparql.implementation.vocabulary.SRS_URI;
+import org.apache.jena.query.ResultSet;
+import org.apache.jena.query.ResultSetFormatter;
+import org.apache.jena.riot.Lang;
+import org.apache.jena.riot.RDFParserBuilder;
+import org.apache.jena.sparql.algebra.Table;
+import org.apache.jena.sparql.core.DatasetGraph;
+import org.apache.jena.sparql.exec.QueryExec;
+import org.junit.Assert;
+import org.junit.Test;
+
+public abstract class AbstractSpatialIndexGraphLookpTest {
+    protected abstract SpatialIndex buildSpatialIndex(DatasetGraph dsg, String srsUri) throws SpatialIndexException;
+
+    private static boolean enableDebugPrint = false;
+
+    private static void debugPrint(Table table) {
+        if (enableDebugPrint) {
+            System.err.println(ResultSetFormatter.asText(ResultSet.adapt(table.toRowSet())));
+        }
+    }
+
+    // SpatialIndexUtils.buildSpatialIndex(dsg, SRS_URI.DEFAULT_WKT_CRS84);
+    @Test
+    public void mustNotMatchDefaultGraph1() throws SpatialIndexException {
+        DatasetGraph dsg = RDFParserBuilder.create().fromString( """
+                PREFIX eg: <http://www.example.org/>
+                PREFIX geo: <http://www.opengis.net/ont/geosparql#>
+
+                eg:graph1 {
+                  eg:feature1 geo:hasGeometry eg:geometry1 .
+                  eg:geometry1 geo:asWKT "POINT (0.3 0.3)"^^geo:wktLiteral .
+                }
+
+                eg:graph2 {
+                  eg:feature1 geo:hasGeometry eg:geometry1 .
+                  eg:geometry1 geo:asWKT "POINT (0.7 0.7)"^^geo:wktLiteral .
+                }
+            """).lang(Lang.TRIG).toDatasetGraph();
+
+        String queryStr = """
+                PREFIX eg: <http://www.example.org/>
+                PREFIX spatial: <http://jena.apache.org/spatial#>
+                PREFIX geo: <http://www.opengis.net/ont/geosparql#>
+
+                SELECT * {
+                  VALUES ?search {
+                    "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"^^geo:wktLiteral
+                  }
+                  LATERAL {
+                    # GRAPH eg:graph1 { ?feature spatial:intersectBoxGeom(?search) . }
+                    ?feature spatial:intersectBoxGeom(?search) .
+                  }
+                }
+            """;
+
+        buildSpatialIndex(dsg, SRS_URI.DEFAULT_WKT_CRS84);
+        Table table = QueryExec.dataset(dsg).query(queryStr).table();
+        debugPrint(table);
+        Assert.assertTrue(table.isEmpty());
+    }
+
+    @Test
+    public void mustNotMatchDefaultGraph2() throws SpatialIndexException {
+        DatasetGraph dsg = RDFParserBuilder.create().fromString( """
+                PREFIX eg: <http://www.example.org/>
+                PREFIX geo: <http://www.opengis.net/ont/geosparql#>
+
+                # Feature in default graph is outside of query polygon
+                eg:feature1 geo:hasGeometry eg:geometry1 .
+                eg:geometry1 geo:asWKT "POINT (-10 -10)"^^geo:wktLiteral .
+
+                eg:graph1 {
+                  eg:feature1 geo:hasGeometry eg:geometry1 .
+                  eg:geometry1 geo:asWKT "POINT (0.3 0.3)"^^geo:wktLiteral .
+                }
+
+                eg:graph2 {
+                  eg:feature1 geo:hasGeometry eg:geometry1 .
+                  eg:geometry1 geo:asWKT "POINT (0.7 0.7)"^^geo:wktLiteral .
+                }
+            """).lang(Lang.TRIG).toDatasetGraph();
+
+        String queryStr = """
+                PREFIX eg: <http://www.example.org/>
+                PREFIX spatial: <http://jena.apache.org/spatial#>
+                PREFIX geo: <http://www.opengis.net/ont/geosparql#>
+
+                SELECT * {
+                  VALUES ?search {
+                    "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"^^geo:wktLiteral
+                  }
+                  LATERAL {
+                    ?feature spatial:intersectBoxGeom(?search) .
+                  }
+                }
+            """;
+
+        buildSpatialIndex(dsg, SRS_URI.DEFAULT_WKT_CRS84);
+        Table table = QueryExec.dataset(dsg).query(queryStr).table();
+        debugPrint(table);
+        Assert.assertTrue(table.isEmpty());
+    }
+
+    @Test
+    public void mustNotMatchNamedGraph() throws SpatialIndexException {
+        DatasetGraph dsg = RDFParserBuilder.create().fromString( """
+                PREFIX eg: <http://www.example.org/>
+                PREFIX geo: <http://www.opengis.net/ont/geosparql#>
+
+                eg:graph1 {
+                  eg:feature1 geo:hasGeometry eg:geometry1 .
+                  eg:geometry1 geo:asWKT "POINT (-10 -10)"^^geo:wktLiteral .
+                }
+
+                eg:graph2 {
+                  eg:feature1 geo:hasGeometry eg:geometry1 .
+                  eg:geometry1 geo:asWKT "POINT (0.7 0.7)"^^geo:wktLiteral .
+                }
+            """).lang(Lang.TRIG).toDatasetGraph();
+
+        String queryStr = """
+                PREFIX eg: <http://www.example.org/>
+                PREFIX spatial: <http://jena.apache.org/spatial#>
+                PREFIX geo: <http://www.opengis.net/ont/geosparql#>
+
+                SELECT * {
+                  VALUES ?search {
+                    "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"^^geo:wktLiteral
+                  }
+                  LATERAL {
+                    GRAPH eg:graph1 { ?feature spatial:intersectBoxGeom(?search) . }
+                  }
+                }
+            """;
+
+        buildSpatialIndex(dsg, SRS_URI.DEFAULT_WKT_CRS84);
+        Table table = QueryExec.dataset(dsg).query(queryStr).table();
+        debugPrint(table);
+        Assert.assertTrue(table.isEmpty());
+    }
+
+    @Test
+    public void mustMatchNamedGraph() throws SpatialIndexException {
+        DatasetGraph dsg = RDFParserBuilder.create().fromString( """
+                PREFIX eg: <http://www.example.org/>
+                PREFIX geo: <http://www.opengis.net/ont/geosparql#>
+
+                eg:graph1 {
+                  eg:feature1 geo:hasGeometry eg:geometry1 .
+                  eg:geometry1 geo:asWKT "POINT (-10 -10)"^^geo:wktLiteral .
+                }
+
+                eg:graph2 {
+                  eg:feature1 geo:hasGeometry eg:geometry1 .
+                  eg:geometry1 geo:asWKT "POINT (0.7 0.7)"^^geo:wktLiteral .
+                }
+            """).lang(Lang.TRIG).toDatasetGraph();
+
+        String queryStr = """
+                PREFIX eg: <http://www.example.org/>
+                PREFIX spatial: <http://jena.apache.org/spatial#>
+                PREFIX geo: <http://www.opengis.net/ont/geosparql#>
+
+                SELECT * {
+                  VALUES ?search {
+                    "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"^^geo:wktLiteral
+                  }
+                  LATERAL {
+                    GRAPH eg:graph2 { ?feature spatial:intersectBoxGeom(?search) . }
+                  }
+                }
+            """;
+
+        buildSpatialIndex(dsg, SRS_URI.DEFAULT_WKT_CRS84);
+        Table table = QueryExec.dataset(dsg).query(queryStr).table();
+        debugPrint(table);
+        Assert.assertFalse(table.isEmpty());
+    }
+}
diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/SearchEnvelopeTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/SearchEnvelopeTest.java
index 371f18f1ad..2d9a4f394c 100644
--- a/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/SearchEnvelopeTest.java
+++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/SearchEnvelopeTest.java
@@ -17,15 +17,17 @@
  */
 package org.apache.jena.geosparql.spatial;
 
-import java.util.Arrays;
-import java.util.HashSet;
+import static org.junit.Assert.assertEquals;
+
+import java.util.Collection;
+import java.util.Set;
+
 import org.apache.jena.geosparql.implementation.GeometryWrapper;
 import org.apache.jena.geosparql.implementation.datatype.WKTDatatype;
 import org.apache.jena.geosparql.implementation.vocabulary.Unit_URI;
-import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.graph.Node;
 import org.junit.After;
 import org.junit.AfterClass;
-import static org.junit.Assert.assertEquals;
 import org.junit.Before;
 import org.junit.BeforeClass;
 import org.junit.Test;
@@ -364,8 +366,8 @@ public class SearchEnvelopeTest {
         SearchEnvelope instance = SearchEnvelope.build(geometryWrapper, SpatialIndexTestData.WGS_84_SRS_INFO, radius, unitsURI);
 
         //Function Test
-        HashSet<Resource> expResult = new HashSet<>(Arrays.asList(SpatialIndexTestData.LONDON_FEATURE));
-        HashSet<Resource> result = instance.check(spatialIndex);
+        Set<Node> expResult = Set.of(SpatialIndexTestData.LONDON_FEATURE.asNode());
+        Collection<Node> result = instance.check(spatialIndex);
         assertEquals(expResult, result);
     }
 
@@ -384,8 +386,8 @@ public class SearchEnvelopeTest {
         SearchEnvelope instance = SearchEnvelope.build(geometryWrapper, SpatialIndexTestData.WGS_84_SRS_INFO, radius, unitsURI);
 
         //Function Test
-        HashSet<Resource> expResult = new HashSet<>();
-        HashSet<Resource> result = instance.check(spatialIndex);
+        Collection<Node> expResult = Set.of();
+        Collection<Node> result = instance.check(spatialIndex);
         assertEquals(expResult, result);
     }
 
diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/SpatialIndexTestData.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/SpatialIndexTestData.java
index b3ec894998..b917114523 100644
--- a/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/SpatialIndexTestData.java
+++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/SpatialIndexTestData.java
@@ -17,17 +17,28 @@
  */
 package org.apache.jena.geosparql.spatial;
 
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
 import org.apache.jena.geosparql.implementation.GeometryWrapper;
 import org.apache.jena.geosparql.implementation.SRSInfo;
 import org.apache.jena.geosparql.implementation.datatype.WKTDatatype;
 import org.apache.jena.geosparql.implementation.vocabulary.Geo;
 import org.apache.jena.geosparql.implementation.vocabulary.SRS_URI;
+import org.apache.jena.geosparql.spatial.index.v2.STRtreePerGraph;
+import org.apache.jena.geosparql.spatial.index.v2.STRtreeUtils;
+import org.apache.jena.geosparql.spatial.index.v2.SpatialIndexPerGraph;
+import org.apache.jena.geosparql.spatial.index.v2.SpatialIndexUtils;
+import org.apache.jena.graph.Node;
 import org.apache.jena.query.Dataset;
 import org.apache.jena.query.DatasetFactory;
 import org.apache.jena.rdf.model.Model;
 import org.apache.jena.rdf.model.ModelFactory;
 import org.apache.jena.rdf.model.Resource;
 import org.apache.jena.rdf.model.ResourceFactory;
+import org.locationtech.jts.index.strtree.STRtree;
 
 /**
  *
@@ -60,21 +71,28 @@ public class SpatialIndexTestData {
     public static final SRSInfo WGS_84_SRS_INFO = new SRSInfo(SRS_URI.WGS84_CRS);
     public static final SRSInfo OSGB_SRS_INFO = new SRSInfo(SRS_URI.OSGB36_CRS);
 
-    private static SpatialIndex TEST_SPATIAL_INDEX = null;
+    private static SpatialIndexPerGraph TEST_SPATIAL_INDEX = null;
     private static Dataset TEST_DATASET = null;
 
-    public static final SpatialIndex createTestIndex() {
+    public static final List<SpatialIndexItem> getTestItems() {
+        List<SpatialIndexItem> items = List.of(
+                SpatialIndexItem.of(LONDON_GEOMETRY_WRAPPER.getEnvelope(), LONDON_FEATURE.asNode()),
+                SpatialIndexItem.of(NEW_YORK_GEOMETRY_WRAPPER.getEnvelope(), NEW_YORK_FEATURE.asNode()),
+                SpatialIndexItem.of(HONOLULU_GEOMETRY_WRAPPER.getEnvelope(), HONOLULU_FEATURE.asNode()),
+                SpatialIndexItem.of(PERTH_GEOMETRY_WRAPPER.getEnvelope(), PERTH_FEATURE.asNode()),
+                SpatialIndexItem.of(AUCKLAND_GEOMETRY_WRAPPER.getEnvelope(), AUCKLAND_FEATURE.asNode()));
+        return items;
+    }
+
+    public static final SpatialIndexPerGraph createTestIndex() {
 
         if (TEST_SPATIAL_INDEX == null) {
             try {
-                SpatialIndex spatialIndex = new SpatialIndex(100, SRS_URI.WGS84_CRS);
-                spatialIndex.insertItem(LONDON_GEOMETRY_WRAPPER.getEnvelope(), LONDON_FEATURE);
-                spatialIndex.insertItem(NEW_YORK_GEOMETRY_WRAPPER.getEnvelope(), NEW_YORK_FEATURE);
-                spatialIndex.insertItem(HONOLULU_GEOMETRY_WRAPPER.getEnvelope(), HONOLULU_FEATURE);
-                spatialIndex.insertItem(PERTH_GEOMETRY_WRAPPER.getEnvelope(), PERTH_FEATURE);
-                spatialIndex.insertItem(AUCKLAND_GEOMETRY_WRAPPER.getEnvelope(), AUCKLAND_FEATURE);
-
-                spatialIndex.build();
+                // SpatialIndexPerGraph spatialIndex = new SpatialIndexPerGraph(100, SRS_URI.WGS84_CRS);
+                List<SpatialIndexItem> items = getTestItems();
+                STRtree tree = STRtreeUtils.buildSpatialIndexTree(items);
+                STRtreePerGraph index = new STRtreePerGraph(tree);
+                SpatialIndexPerGraph spatialIndex = new SpatialIndexPerGraph(index);
                 TEST_SPATIAL_INDEX = spatialIndex;
             } catch (SpatialIndexException ex) {
 
@@ -102,11 +120,15 @@ public class SpatialIndexTestData {
 
             dataset.setDefaultModel(model);
             SpatialIndex spatialIndex = createTestIndex();
-            SpatialIndex.setSpatialIndex(dataset, spatialIndex);
+            SpatialIndexUtils.setSpatialIndex(dataset, spatialIndex);
             TEST_DATASET = dataset;
         }
 
         return TEST_DATASET;
     }
 
+    public static Set<Node> asNodes(Collection<Resource> resources) {
+        return resources.stream().map(Resource::asNode).collect(Collectors.toSet());
+    }
+
 }
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SpatialIndexItem.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/TestSpatialIndexGraphLookupV1.java
similarity index 53%
copy from jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SpatialIndexItem.java
copy to jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/TestSpatialIndexGraphLookupV1.java
index 8e6e7a0386..3bbd875620 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SpatialIndexItem.java
+++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/TestSpatialIndexGraphLookupV1.java
@@ -17,34 +17,20 @@
  */
 package org.apache.jena.geosparql.spatial;
 
-import org.apache.jena.rdf.model.Resource;
-import org.locationtech.jts.geom.Envelope;
-
-/**
- *
- *
- */
-public class SpatialIndexItem {
-
-    private final Envelope envelope;
-    private final Resource item;
-
-    public SpatialIndexItem(Envelope envelope, Resource item) {
-        this.envelope = envelope;
-        this.item = item;
-    }
-
-    public Envelope getEnvelope() {
-        return envelope;
-    }
-
-    public Resource getItem() {
-        return item;
-    }
-
+import org.apache.jena.geosparql.spatial.index.v1.SpatialIndexAdapterV1;
+import org.apache.jena.geosparql.spatial.index.v1.SpatialIndexV1;
+import org.apache.jena.query.DatasetFactory;
+import org.apache.jena.sparql.core.DatasetGraph;
+import org.junit.Ignore;
+
+@SuppressWarnings("removal")
+@Ignore /** These tests reveal issues with the first version of the spatial index which did not use per-graph indexes. */
+public class TestSpatialIndexGraphLookupV1
+    extends AbstractSpatialIndexGraphLookpTest
+{
     @Override
-    public String toString() {
-        return "SpatialIndexItem{" + "envelope=" + envelope + ", item=" + item + '}';
+    protected SpatialIndex buildSpatialIndex(DatasetGraph dsg, String srsUri) throws SpatialIndexException {
+        SpatialIndexV1 v1 = SpatialIndexV1.buildSpatialIndex(DatasetFactory.wrap(dsg), srsUri);
+        return new SpatialIndexAdapterV1(v1);
     }
-
 }
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SpatialIndexException.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/TestSpatialIndexGraphLookupV2.java
similarity index 68%
copy from jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SpatialIndexException.java
copy to jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/TestSpatialIndexGraphLookupV2.java
index a50bd949fb..508da2fc79 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/spatial/SpatialIndexException.java
+++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/TestSpatialIndexGraphLookupV2.java
@@ -17,17 +17,14 @@
  */
 package org.apache.jena.geosparql.spatial;
 
-/**
- *
- *
- */
-public class SpatialIndexException extends Exception {
-
-    public SpatialIndexException(String msg) {
-        super(msg);
-    }
+import org.apache.jena.geosparql.spatial.index.v2.SpatialIndexUtils;
+import org.apache.jena.sparql.core.DatasetGraph;
 
-    public SpatialIndexException(String msg, Throwable cause) {
-        super(msg, cause);
+public class TestSpatialIndexGraphLookupV2
+    extends AbstractSpatialIndexGraphLookpTest
+{
+    @Override
+    protected SpatialIndex buildSpatialIndex(DatasetGraph dsg, String srsUri) throws SpatialIndexException {
+        return SpatialIndexUtils.buildSpatialIndex(dsg, srsUri);
     }
 }
diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/index/v2/SimpleGraphSerializer.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/index/v2/SimpleGraphSerializer.java
new file mode 100644
index 0000000000..8235ad3020
--- /dev/null
+++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/index/v2/SimpleGraphSerializer.java
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jena.geosparql.spatial.index.v2;
+
+import org.apache.jena.graph.Graph;
+import org.apache.jena.graph.Triple;
+import org.apache.jena.sparql.graph.GraphFactory;
+import org.apache.jena.util.iterator.ExtendedIterator;
+
+import com.esotericsoftware.kryo.Kryo;
+import com.esotericsoftware.kryo.Serializer;
+import com.esotericsoftware.kryo.io.Input;
+import com.esotericsoftware.kryo.io.Output;
+
+/** Only used for testing Node_Graph serialization. */
+public class SimpleGraphSerializer
+    extends Serializer<Graph>
+{
+    @Override
+    public void write(Kryo kryo, Output output, Graph graph) {
+        ExtendedIterator<Triple> it = graph.find();
+        try {
+            it.forEach(t -> kryo.writeObjectOrNull(output, t, Triple.class));
+        } finally {
+            it.close();
+        }
+        kryo.writeObjectOrNull(output, null, Triple.class);
+    }
+
+    @Override
+    public Graph read(Kryo kryo, Input input, Class<Graph> type) {
+        Graph result = GraphFactory.createDefaultGraph();
+        for (;;) {
+            Triple t = kryo.readObjectOrNull(input, Triple.class);
+            if (t == null) {
+                break;
+            }
+            result.add(t);
+        }
+        return result;
+    }
+}
diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/index/v2/SpatialIndexTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/index/v2/SpatialIndexTest.java
new file mode 100644
index 0000000000..5eb321e604
--- /dev/null
+++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/index/v2/SpatialIndexTest.java
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jena.geosparql.spatial.index.v2;
+
+import static org.junit.Assert.assertEquals;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.jena.geosparql.implementation.GeometryWrapperFactory;
+import org.apache.jena.geosparql.implementation.SRSInfo;
+import org.apache.jena.geosparql.implementation.datatype.WKTDatatype;
+import org.apache.jena.geosparql.implementation.vocabulary.SRS_URI;
+import org.apache.jena.geosparql.spatial.SearchEnvelope;
+import org.apache.jena.geosparql.spatial.SpatialIndex;
+import org.apache.jena.geosparql.spatial.SpatialIndexException;
+import org.apache.jena.geosparql.spatial.SpatialIndexItem;
+import org.apache.jena.geosparql.spatial.SpatialIndexTestData;
+import org.apache.jena.geosparql.spatial.index.compat.SpatialIndexIo;
+import org.apache.jena.geosparql.spatial.index.v1.SpatialIndexV1;
+import org.apache.jena.geosparql.spatial.index.v2.GeometryGenerator.GeometryType;
+import org.apache.jena.graph.Node;
+import org.apache.jena.query.DatasetFactory;
+import org.apache.jena.sparql.core.DatasetGraph;
+import org.apache.jena.sparql.core.DatasetGraphFactory;
+import org.junit.Assert;
+import org.junit.Test;
+import org.locationtech.jts.geom.Envelope;
+
+@SuppressWarnings("removal")
+public class SpatialIndexTest {
+
+    @Test
+    public void testLegacyLoading() throws IOException, SpatialIndexException {
+        Path file = Files.createTempFile("jena-", ".spatial.index");
+        try {
+            List<SpatialIndexItem> items = SpatialIndexTestData.getTestItems();
+
+            SpatialIndexV1.save(file.toFile(), items, SRS_URI.DEFAULT_WKT_CRS84);
+
+            SpatialIndex index = SpatialIndexIo.load(file, true);
+            Envelope envelope = new Envelope(-90, 0, 0, 90);
+            Collection<Node> actual = index.query(envelope, null);
+            Set<Node> expected = Set.of(SpatialIndexTestData.LONDON_FEATURE.asNode(), SpatialIndexTestData.NEW_YORK_FEATURE.asNode());
+            Assert.assertEquals(expected, actual);
+        } finally {
+            Files.delete(file);
+        }
+    }
+
+    @Test
+    public void testSerdeSpatialIndex() throws IOException, SpatialIndexException {
+        // create spatial index
+        SpatialIndexPerGraph index1 = SpatialIndexTestData.createTestIndex();
+
+        // query index 1
+        SRSInfo srsInfo1 = index1.getSrsInfo();
+        SearchEnvelope searchEnvelope1 = SearchEnvelope.build(GeometryWrapperFactory.createPolygon(srsInfo1.getDomainEnvelope(), WKTDatatype.URI), srsInfo1);
+        Collection<Node> res1 = searchEnvelope1.check(index1);
+
+        // save to tmp file
+        // File file = new File("/tmp/test-spatial.index"); //File.createTempFile( "jena", "spatial.index");
+        Path file = Files.createTempFile("jena-", ".spatial-index");
+        try {
+            SpatialIndexIoKryo.save(file, index1);
+
+            // load from tmp file as new index 2
+            SpatialIndex index2 = SpatialIndexIo.load(file);
+
+            // query index 2
+            SRSInfo srsInfo2 = index2.getSrsInfo();
+            SearchEnvelope searchEnvelope2 = SearchEnvelope.build(GeometryWrapperFactory.createPolygon(srsInfo2.getDomainEnvelope(), WKTDatatype.URI), srsInfo2);
+            Collection<Node> res2 = searchEnvelope2.check(index2);
+
+            assertEquals(srsInfo1, srsInfo2);
+            assertEquals(res1, res2);
+        } finally {
+            Files.deleteIfExists(file);
+        }
+    }
+
+    /**
+     * Generate a dataset with geometries of all sorts,
+     * build the spatial index for it and
+     * attempt to save/load it.
+     */
+    @Test
+    public void testSerdeSpatialIndex2() throws IOException, SpatialIndexException {
+        DatasetGraph dsg = DatasetGraphFactory.create();
+
+        Envelope envelope = new Envelope(-170, 170, -85, 85);
+        Map<GeometryType, Number> config = GeometryGenerator.createConfig(100);
+        long expectedItemCount = GeometryGenerator.generateGraph(dsg.getDefaultGraph(), envelope, config);
+
+        Path file = Files.createTempFile("jena-", ".spatial-index");
+        try {
+            // Query the built index (no IO)
+            SpatialIndexPerGraph indexA = (SpatialIndexPerGraph)SpatialIndexUtils.buildSpatialIndex(dsg);
+            long itemCountA = indexA.query(envelope, null).size();
+            Assert.assertEquals(expectedItemCount, itemCountA);
+
+            // Save the index with the latest format and load from file.
+            SpatialIndexIoKryo.save(file, indexA);
+            SpatialIndex indexB = SpatialIndexIoKryo.load(file);
+            long itemCountB = indexB.query(envelope, null).size();
+            Assert.assertEquals(expectedItemCount, itemCountB);
+
+            // Save the index with legacy format and load from file.
+            // File must not exist or the index might/will attempt to load it.
+            Files.delete(file);
+            SpatialIndexV1.buildSpatialIndex(DatasetFactory.wrap(dsg), file.toFile());
+            SpatialIndex indexC = SpatialIndexIo.load(file, true);
+            long itemCountC = indexC.query(envelope, null).size();
+            Assert.assertEquals(expectedItemCount, itemCountC);
+        } finally {
+            Files.deleteIfExists(file);
+        }
+    }
+}
diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/index/v2/TestNodeSerializer.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/index/v2/TestNodeSerializer.java
new file mode 100644
index 0000000000..69b546540c
--- /dev/null
+++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/index/v2/TestNodeSerializer.java
@@ -0,0 +1,118 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jena.geosparql.spatial.index.v2;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+
+import org.apache.jena.datatypes.BaseDatatype;
+import org.apache.jena.datatypes.xsd.XSDDatatype;
+import org.apache.jena.geosparql.kryo.NodeSerializer;
+import org.apache.jena.graph.Graph;
+import org.apache.jena.graph.Node;
+import org.apache.jena.graph.NodeFactory;
+import org.apache.jena.graph.Node_Graph;
+import org.apache.jena.graph.TextDirection;
+import org.apache.jena.graph.Triple;
+import org.apache.jena.graph.impl.GraphMatcher;
+import org.apache.jena.sparql.core.Var;
+import org.apache.jena.sparql.graph.GraphFactory;
+import org.apache.jena.vocabulary.RDF;
+import org.junit.Assert;
+import org.junit.Test;
+
+import com.esotericsoftware.kryo.Kryo;
+import com.esotericsoftware.kryo.Serializer;
+import com.esotericsoftware.kryo.io.Input;
+import com.esotericsoftware.kryo.io.Output;
+
+public class TestNodeSerializer {
+
+    private static final Node S = NodeFactory.createURI("http://www.example.org/s");
+    private static final Node B = NodeFactory.createBlankNode("abcde");
+    private static final Triple T = Triple.create(S, RDF.Nodes.type, B);
+    private static final Graph G = GraphFactory.createDefaultGraph();
+
+    private static final Kryo kryo = new Kryo();
+
+    static {
+        G.add(T);
+        NodeSerializer.register(kryo);
+        KryoRegistratorSpatialIndexV2.registerTripleSerializer(kryo);
+        registerGraphSerializer(kryo);
+    }
+
+    @Test public void testUri() { assertRoundtrip(S); }
+
+    @Test public void testBnode() { assertRoundtrip(NodeFactory.createBlankNode()); }
+
+    @Test public void testLitStr() { assertRoundtrip(NodeFactory.createLiteralString("hello")); }
+    @Test public void testLitDtStd() { assertRoundtrip(NodeFactory.createLiteralDT("1", XSDDatatype.XSDinteger)); }
+    @Test public void testLitDtCustom() { assertRoundtrip(NodeFactory.createLiteralDT("1", new BaseDatatype("http://www.example.org/myDatatype"))); }
+    @Test public void testLitLang() { assertRoundtrip(NodeFactory.createLiteralLang("hello", "en")); }
+
+    @Test public void testLitDirLangNone() { assertRoundtrip(NodeFactory.createLiteralDirLang("hello", "en", Node.noTextDirection)); }
+    @Test public void testLitDirLangLtr() { assertRoundtrip(NodeFactory.createLiteralDirLang("hello", "en", TextDirection.LTR)); }
+    @Test public void testLitDirLangRtl() { assertRoundtrip(NodeFactory.createLiteralDirLang("hello", "en", TextDirection.RTL)); }
+
+    @Test public void testStar() { assertRoundtrip(NodeFactory.createTripleTerm(T)); }
+
+    @Test public void testVarRdf() { assertRoundtrip(NodeFactory.createVariable("rdfVar")); }
+    @Test public void testVarSparql() { assertRoundtrip(Var.alloc("sparqlVar")); }
+
+    @Test public void testAny() { assertRoundtrip(Node.ANY); }
+
+    @Test public void testGraph() {
+        Node_Graph after = (Node_Graph)roundtrip(NodeFactory.createGraphNode(G));
+        Assert.assertTrue(GraphMatcher.equals(G, after.getGraph()));
+    }
+
+    // For completeness also test Node_Graph serialization
+    private static void registerGraphSerializer(Kryo kryo) {
+        Graph prototype = GraphFactory.createDefaultGraph();
+        Class<?> prototypeClass = prototype.getClass();
+        Serializer<Graph> graphSerializer = new SimpleGraphSerializer();
+        kryo.register(Graph.class, graphSerializer);
+        kryo.register(prototypeClass, graphSerializer);
+    }
+
+    public static Node roundtrip(Node expected) {
+        byte[] bytes;
+        try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
+            try (Output out = new Output(baos)) {
+                kryo.writeClassAndObject(out, expected);
+                out.flush();
+            }
+            bytes = baos.toByteArray();
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+
+        Node result;
+        try (Input in = new Input(new ByteArrayInputStream(bytes))) {
+            result = (Node)kryo.readClassAndObject(in);
+        }
+        return result;
+    }
+
+    public static void assertRoundtrip(Node expected) {
+        Node actual = roundtrip(expected);
+        Assert.assertEquals(expected, actual);
+    }
+}
diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/property_functions/GenericSpatialPropertyFunctionTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/property_functions/GenericSpatialPropertyFunctionTest.java
index 724f4d2dac..0b2ff3e6e8 100644
--- a/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/property_functions/GenericSpatialPropertyFunctionTest.java
+++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/property_functions/GenericSpatialPropertyFunctionTest.java
@@ -17,15 +17,18 @@
  */
 package org.apache.jena.geosparql.spatial.property_functions;
 
+import static org.junit.Assert.assertEquals;
+
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
+
 import org.apache.jena.datatypes.xsd.XSDDatatype;
 import org.apache.jena.geosparql.configuration.GeoSPARQLConfig;
 import org.apache.jena.geosparql.implementation.vocabulary.SpatialExtension;
-import org.apache.jena.geosparql.spatial.SpatialIndex;
 import org.apache.jena.geosparql.spatial.SpatialIndexException;
 import org.apache.jena.geosparql.spatial.SpatialIndexTestData;
+import org.apache.jena.geosparql.spatial.index.v2.SpatialIndexUtils;
 import org.apache.jena.query.Dataset;
 import org.apache.jena.query.QueryExecution;
 import org.apache.jena.query.QueryExecutionFactory;
@@ -37,7 +40,6 @@ import org.apache.jena.rdf.model.Resource;
 import org.apache.jena.rdf.model.ResourceFactory;
 import org.junit.After;
 import org.junit.AfterClass;
-import static org.junit.Assert.*;
 import org.junit.Before;
 import org.junit.BeforeClass;
 import org.junit.Test;
@@ -201,7 +203,7 @@ public class GenericSpatialPropertyFunctionTest {
         Resource geoFeature = ResourceFactory.createResource("http://example.org/GeoFeatureX");
         model.add(geoFeature, SpatialExtension.GEO_LAT_PROP, ResourceFactory.createTypedLiteral("0.0", XSDDatatype.XSDfloat));
         model.add(geoFeature, SpatialExtension.GEO_LON_PROP, ResourceFactory.createTypedLiteral("0.0", XSDDatatype.XSDfloat));
-        Dataset dataset = SpatialIndex.wrapModel(model);
+        Dataset dataset = SpatialIndexUtils.wrapModel(model);
 
         String query = "PREFIX spatial: <http://jena.apache.org/spatial#>\n"
                 + "\n"
diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/property_functions/cardinal/EastGeomPFTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/property_functions/cardinal/EastGeomPFTest.java
index 91e6cb00e7..ef707118c1 100644
--- a/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/property_functions/cardinal/EastGeomPFTest.java
+++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/property_functions/cardinal/EastGeomPFTest.java
@@ -17,16 +17,21 @@
  */
 package org.apache.jena.geosparql.spatial.property_functions.cardinal;
 
+import static org.junit.Assert.assertEquals;
+
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.HashSet;
+import java.util.Collection;
 import java.util.List;
+import java.util.Set;
+
 import org.apache.jena.geosparql.configuration.GeoSPARQLConfig;
 import org.apache.jena.geosparql.implementation.GeometryWrapper;
 import org.apache.jena.geosparql.spatial.CardinalDirection;
 import org.apache.jena.geosparql.spatial.SearchEnvelope;
 import org.apache.jena.geosparql.spatial.SpatialIndex;
 import org.apache.jena.geosparql.spatial.SpatialIndexTestData;
+import org.apache.jena.graph.Node;
 import org.apache.jena.query.Dataset;
 import org.apache.jena.query.QueryExecution;
 import org.apache.jena.query.QueryExecutionFactory;
@@ -35,7 +40,6 @@ import org.apache.jena.query.ResultSet;
 import org.apache.jena.rdf.model.Resource;
 import org.junit.After;
 import org.junit.AfterClass;
-import static org.junit.Assert.*;
 import org.junit.Before;
 import org.junit.BeforeClass;
 import org.junit.Test;
@@ -91,8 +95,10 @@ public class EastGeomPFTest {
         GeometryWrapper geometryWrapper = SpatialIndexTestData.HONOLULU_GEOMETRY_WRAPPER;
         EastGeomPF instance = new EastGeomPF();
         SearchEnvelope searchEnvelope = instance.buildSearchEnvelope(geometryWrapper, SpatialIndexTestData.WGS_84_SRS_INFO); //Needed to initialise the search.
-        HashSet<Resource> expResult = new HashSet<>(Arrays.asList(SpatialIndexTestData.LONDON_FEATURE, SpatialIndexTestData.HONOLULU_FEATURE, SpatialIndexTestData.NEW_YORK_FEATURE));
-        HashSet<Resource> result = searchEnvelope.check(spatialIndex);
+        Set<Node> expResult = SpatialIndexTestData.asNodes(
+                List.of(SpatialIndexTestData.LONDON_FEATURE, SpatialIndexTestData.HONOLULU_FEATURE, SpatialIndexTestData.NEW_YORK_FEATURE));
+        Collection<Node> result = searchEnvelope.check(spatialIndex);
+
         assertEquals(expResult, result);
     }
 
@@ -108,8 +114,9 @@ public class EastGeomPFTest {
         GeometryWrapper geometryWrapper = SpatialIndexTestData.PERTH_GEOMETRY_WRAPPER;
         EastGeomPF instance = new EastGeomPF();
         SearchEnvelope searchEnvelope = instance.buildSearchEnvelope(geometryWrapper, SpatialIndexTestData.WGS_84_SRS_INFO); //Needed to initialise the search.
-        HashSet<Resource> expResult = new HashSet<>(Arrays.asList(SpatialIndexTestData.AUCKLAND_FEATURE, SpatialIndexTestData.PERTH_FEATURE, SpatialIndexTestData.HONOLULU_FEATURE, SpatialIndexTestData.NEW_YORK_FEATURE));
-        HashSet<Resource> result = searchEnvelope.check(spatialIndex);
+        Set<Node> expResult = SpatialIndexTestData.asNodes(
+                List.of(SpatialIndexTestData.AUCKLAND_FEATURE, SpatialIndexTestData.PERTH_FEATURE, SpatialIndexTestData.HONOLULU_FEATURE, SpatialIndexTestData.NEW_YORK_FEATURE));
+        Collection<Node> result = searchEnvelope.check(spatialIndex);
         assertEquals(expResult, result);
     }
 
diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/property_functions/cardinal/EastPFTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/property_functions/cardinal/EastPFTest.java
index 38896523c0..32e04f0b2c 100644
--- a/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/property_functions/cardinal/EastPFTest.java
+++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/property_functions/cardinal/EastPFTest.java
@@ -17,16 +17,15 @@
  */
 package org.apache.jena.geosparql.spatial.property_functions.cardinal;
 
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.HashSet;
-import java.util.List;
+import java.util.*;
+
 import org.apache.jena.geosparql.configuration.GeoSPARQLConfig;
 import org.apache.jena.geosparql.implementation.GeometryWrapper;
 import org.apache.jena.geosparql.spatial.CardinalDirection;
 import org.apache.jena.geosparql.spatial.SearchEnvelope;
 import org.apache.jena.geosparql.spatial.SpatialIndex;
 import org.apache.jena.geosparql.spatial.SpatialIndexTestData;
+import org.apache.jena.graph.Node;
 import org.apache.jena.query.Dataset;
 import org.apache.jena.query.QueryExecution;
 import org.apache.jena.query.QueryExecutionFactory;
@@ -91,8 +90,8 @@ public class EastPFTest {
         GeometryWrapper geometryWrapper = SpatialIndexTestData.HONOLULU_GEOMETRY_WRAPPER;
         EastPF instance = new EastPF();
         SearchEnvelope searchEnvelope = instance.buildSearchEnvelope(geometryWrapper, SpatialIndexTestData.WGS_84_SRS_INFO); //Needed to initialise the search.
-        HashSet<Resource> expResult = new HashSet<>(Arrays.asList(SpatialIndexTestData.LONDON_FEATURE, SpatialIndexTestData.HONOLULU_FEATURE, SpatialIndexTestData.NEW_YORK_FEATURE));
-        HashSet<Resource> result = searchEnvelope.check(spatialIndex);
+        Set<Node> expResult = SpatialIndexTestData.asNodes(List.of(SpatialIndexTestData.LONDON_FEATURE, SpatialIndexTestData.HONOLULU_FEATURE, SpatialIndexTestData.NEW_YORK_FEATURE));
+        Collection<Node> result = searchEnvelope.check(spatialIndex);
         assertEquals(expResult, result);
     }
 
@@ -108,8 +107,8 @@ public class EastPFTest {
         GeometryWrapper geometryWrapper = SpatialIndexTestData.PERTH_GEOMETRY_WRAPPER;
         EastPF instance = new EastPF();
         SearchEnvelope searchEnvelope = instance.buildSearchEnvelope(geometryWrapper, SpatialIndexTestData.WGS_84_SRS_INFO); //Needed to initialise the search.
-        HashSet<Resource> expResult = new HashSet<>(Arrays.asList(SpatialIndexTestData.AUCKLAND_FEATURE, SpatialIndexTestData.PERTH_FEATURE, SpatialIndexTestData.HONOLULU_FEATURE, SpatialIndexTestData.NEW_YORK_FEATURE));
-        HashSet<Resource> result = searchEnvelope.check(spatialIndex);
+        Set<Node> expResult = SpatialIndexTestData.asNodes(List.of(SpatialIndexTestData.AUCKLAND_FEATURE, SpatialIndexTestData.PERTH_FEATURE, SpatialIndexTestData.HONOLULU_FEATURE, SpatialIndexTestData.NEW_YORK_FEATURE));
+        Collection<Node> result = searchEnvelope.check(spatialIndex);
         assertEquals(expResult, result);
     }
 
diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/property_functions/cardinal/WestGeomPFTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/property_functions/cardinal/WestGeomPFTest.java
index abda7afebf..7ebe0276a8 100644
--- a/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/property_functions/cardinal/WestGeomPFTest.java
+++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/property_functions/cardinal/WestGeomPFTest.java
@@ -17,16 +17,21 @@
  */
 package org.apache.jena.geosparql.spatial.property_functions.cardinal;
 
+import static org.junit.Assert.assertEquals;
+
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.HashSet;
+import java.util.Collection;
 import java.util.List;
+import java.util.Set;
+
 import org.apache.jena.geosparql.configuration.GeoSPARQLConfig;
 import org.apache.jena.geosparql.implementation.GeometryWrapper;
 import org.apache.jena.geosparql.spatial.CardinalDirection;
 import org.apache.jena.geosparql.spatial.SearchEnvelope;
 import org.apache.jena.geosparql.spatial.SpatialIndex;
 import org.apache.jena.geosparql.spatial.SpatialIndexTestData;
+import org.apache.jena.graph.Node;
 import org.apache.jena.query.Dataset;
 import org.apache.jena.query.QueryExecution;
 import org.apache.jena.query.QueryExecutionFactory;
@@ -35,7 +40,6 @@ import org.apache.jena.query.ResultSet;
 import org.apache.jena.rdf.model.Resource;
 import org.junit.After;
 import org.junit.AfterClass;
-import static org.junit.Assert.*;
 import org.junit.Before;
 import org.junit.BeforeClass;
 import org.junit.Test;
@@ -91,8 +95,8 @@ public class WestGeomPFTest {
         GeometryWrapper geometryWrapper = SpatialIndexTestData.PERTH_GEOMETRY_WRAPPER;
         WestGeomPF instance = new WestGeomPF();
         SearchEnvelope searchEnvelope = instance.buildSearchEnvelope(geometryWrapper, SpatialIndexTestData.WGS_84_SRS_INFO); //Needed to initialise the search.
-        HashSet<Resource> expResult = new HashSet<>(Arrays.asList(SpatialIndexTestData.LONDON_FEATURE, SpatialIndexTestData.PERTH_FEATURE));
-        HashSet<Resource> result = searchEnvelope.check(spatialIndex);
+        Set<Node> expResult = SpatialIndexTestData.asNodes(List.of(SpatialIndexTestData.LONDON_FEATURE, SpatialIndexTestData.PERTH_FEATURE));
+        Collection<Node> result = searchEnvelope.check(spatialIndex);
         assertEquals(expResult, result);
     }
 
@@ -108,8 +112,8 @@ public class WestGeomPFTest {
         GeometryWrapper geometryWrapper = SpatialIndexTestData.HONOLULU_GEOMETRY_WRAPPER;
         WestGeomPF instance = new WestGeomPF();
         SearchEnvelope searchEnvelope = instance.buildSearchEnvelope(geometryWrapper, SpatialIndexTestData.WGS_84_SRS_INFO); //Needed to initialise the search.
-        HashSet<Resource> expResult = new HashSet<>(Arrays.asList(SpatialIndexTestData.AUCKLAND_FEATURE, SpatialIndexTestData.PERTH_FEATURE, SpatialIndexTestData.HONOLULU_FEATURE));
-        HashSet<Resource> result = searchEnvelope.check(spatialIndex);
+        Set<Node> expResult = SpatialIndexTestData.asNodes(List.of(SpatialIndexTestData.AUCKLAND_FEATURE, SpatialIndexTestData.PERTH_FEATURE, SpatialIndexTestData.HONOLULU_FEATURE));
+        Collection<Node> result = searchEnvelope.check(spatialIndex);
         assertEquals(expResult, result);
     }
 
diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/property_functions/cardinal/WestPFTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/property_functions/cardinal/WestPFTest.java
index 4aea6a5969..ce70c2212e 100644
--- a/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/property_functions/cardinal/WestPFTest.java
+++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/spatial/property_functions/cardinal/WestPFTest.java
@@ -17,16 +17,15 @@
  */
 package org.apache.jena.geosparql.spatial.property_functions.cardinal;
 
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.HashSet;
-import java.util.List;
+import java.util.*;
+
 import org.apache.jena.geosparql.configuration.GeoSPARQLConfig;
 import org.apache.jena.geosparql.implementation.GeometryWrapper;
 import org.apache.jena.geosparql.spatial.CardinalDirection;
 import org.apache.jena.geosparql.spatial.SearchEnvelope;
 import org.apache.jena.geosparql.spatial.SpatialIndex;
 import org.apache.jena.geosparql.spatial.SpatialIndexTestData;
+import org.apache.jena.graph.Node;
 import org.apache.jena.query.Dataset;
 import org.apache.jena.query.QueryExecution;
 import org.apache.jena.query.QueryExecutionFactory;
@@ -91,8 +90,8 @@ public class WestPFTest {
         GeometryWrapper geometryWrapper = SpatialIndexTestData.PERTH_GEOMETRY_WRAPPER;
         WestPF instance = new WestPF();
         SearchEnvelope searchEnvelope = instance.buildSearchEnvelope(geometryWrapper, SpatialIndexTestData.WGS_84_SRS_INFO); //Needed to initialise the search.
-        HashSet<Resource> expResult = new HashSet<>(Arrays.asList(SpatialIndexTestData.LONDON_FEATURE, SpatialIndexTestData.PERTH_FEATURE));
-        HashSet<Resource> result = searchEnvelope.check(spatialIndex);
+        Set<Node> expResult = SpatialIndexTestData.asNodes(List.of(SpatialIndexTestData.LONDON_FEATURE, SpatialIndexTestData.PERTH_FEATURE));
+        Collection<Node> result = searchEnvelope.check(spatialIndex);
         assertEquals(expResult, result);
     }
 
@@ -108,8 +107,8 @@ public class WestPFTest {
         GeometryWrapper geometryWrapper = SpatialIndexTestData.HONOLULU_GEOMETRY_WRAPPER;
         WestPF instance = new WestPF();
         SearchEnvelope searchEnvelope = instance.buildSearchEnvelope(geometryWrapper, SpatialIndexTestData.WGS_84_SRS_INFO); //Needed to initialise the search.
-        HashSet<Resource> expResult = new HashSet<>(Arrays.asList(SpatialIndexTestData.AUCKLAND_FEATURE, SpatialIndexTestData.PERTH_FEATURE, SpatialIndexTestData.HONOLULU_FEATURE));
-        HashSet<Resource> result = searchEnvelope.check(spatialIndex);
+        Set<Node> expResult = SpatialIndexTestData.asNodes(List.of(SpatialIndexTestData.AUCKLAND_FEATURE, SpatialIndexTestData.PERTH_FEATURE, SpatialIndexTestData.HONOLULU_FEATURE));
+        Collection<Node> result = searchEnvelope.check(spatialIndex);
         assertEquals(expResult, result);
     }
 
diff --git a/pom.xml b/pom.xml
index cc3e852174..825b0c8380 100644
--- a/pom.xml
+++ b/pom.xml
@@ -50,7 +50,7 @@
     <url>https://gitbox.apache.org/repos/asf/jena.git</url>
     <tag>HEAD</tag>
   </scm>
-  
+
   <properties>
     <!-- Build -->
     <java.version>17</java.version>
@@ -70,7 +70,7 @@
     <ver.jetty>12.0.22</ver.jetty>
     <ver.jakarta-servlet>6.1.0</ver.jakarta-servlet>
 
-    <!-- 
+    <!--
          These two go together
          See jena-fuseki-webapp and https://shiro.apache.org/jakarta-ee.html
     -->
@@ -115,6 +115,7 @@
     <ver.org.openjdk.jmh>1.37</ver.org.openjdk.jmh>
 
     <!-- GeoSPARQL related -->
+    <ver.kryo>4.0.3</ver.kryo>
     <ver.jaxb-api>2.3.1</ver.jaxb-api>
     <ver.jakarta-xml-bind>4.0.2</ver.jakarta-xml-bind>
     <ver.jcommander>1.82</ver.jcommander>
@@ -134,7 +135,7 @@
     <ver.plugin.spdx>0.7.0</ver.plugin.spdx>
 
     <!--
-         See jena-benchmarks/jena-benchmarks-jmh/pom.xml 
+         See jena-benchmarks/jena-benchmarks-jmh/pom.xml
          Overidden by command line "-Dbenchmark.skip=false"
     -->
     <benchmark.skip>true</benchmark.skip>
@@ -145,7 +146,7 @@
     <system>JIRA</system>
     <url>https://issues.apache.org/jira/browse/JENA</url>
   </issueManagement>
-  
+
   <mailingLists>
     <mailingList>
       <name>Jena Users Mailing List</name>
@@ -186,7 +187,7 @@
   <profiles>
     <profile>
       <!--
-          This is the dev profile, typically used locally with 
+          This is the dev profile, typically used locally with
           "mvn clean install -Pdev".
           It builds the modules shipped in apache-jena binaries
           but does not build these binaries.
@@ -204,7 +205,7 @@
         <module>jena-iri3986</module>
         <module>jena-langtag</module>
         <module>jena-base</module>
-        
+
         <module>jena-core</module>
         <module>jena-arq</module>
         <module>jena-ontapi</module>
@@ -215,11 +216,11 @@
         <module>jena-db</module>
         <module>jena-tdb1</module>
         <module>jena-tdb2</module>
-        
+
         <module>apache-jena-libs</module>
 
         <module>jena-text</module>
-        
+
         <module>jena-cmds</module>
         <module>jena-geosparql</module>
         <module>jena-fuseki2</module>
@@ -289,9 +290,9 @@
 
         <module>jena-geosparql</module>
         <module>jena-fuseki2/jena-fuseki-geosparql</module>
-        
+
         <!--
-            Tests of artifacts that require additional 
+            Tests of artifacts that require additional
             modules built later in the build process.
             For example, using Fuseki as a test server to
             test remote client APIs.
@@ -299,7 +300,7 @@
         <module>jena-integration-tests</module>
 
         <module>jena-benchmarks</module>
-        
+
         <!-- Binary distribution -->
         <module>apache-jena</module>
 
@@ -325,7 +326,7 @@
         <type>pom</type>
         <scope>import</scope>
       </dependency>
-      
+
       <dependency>
         <groupId>junit</groupId>
         <artifactId>junit</artifactId>
@@ -338,7 +339,7 @@
         <artifactId>commons-codec</artifactId>
         <version>${ver.commons-codec}</version>
       </dependency>
-      
+
       <dependency>
         <groupId>commons-io</groupId>
         <artifactId>commons-io</artifactId>
@@ -350,7 +351,7 @@
         <artifactId>commons-</artifactId>
         <version>${ver.commons-io}</version>
       </dependency>
-      
+
       <dependency>
         <groupId>org.apache.commons</groupId>
         <artifactId>commons-compress</artifactId>
@@ -376,7 +377,7 @@
         <artifactId>protobuf-java</artifactId>
         <version>${ver.protobuf}</version>
       </dependency>
-      
+
       <dependency>
         <groupId>org.apache.thrift</groupId>
         <artifactId>libthrift</artifactId>
@@ -391,7 +392,7 @@
             <artifactId>*</artifactId>
           </exclusion>
 
-          <!-- 
+          <!--
                Jena does not use the RPC capabilities of Thrift,
                only the binary data format.
           -->
@@ -447,24 +448,24 @@
              <groupId>commons-logging</groupId>
              <artifactId>commons-logging</artifactId>
            </exclusion>
-         </exclusions>         
+         </exclusions>
        </dependency>
 
       <!--
           com.google.errorprone:error_prone_annotations
-          is used by gson, guava and caffeine. 
+          is used by gson, guava and caffeine.
           It can lead to dependency convergence errors.
 
           The dependencies using it are all at the same depth.
           Either explicitly depend here or choose one
           route and exclude from the others or exclude
           systematically. If the dependency plugin is being used,
-    it will likely report errors because it is stricter than 
+    it will likely report errors because it is stricter than
     the maven resolution rule.
 
           Gson is probably the one to choose as the preferred route.
           https://github.com/google/gson/issues/2681#issuecomment-2125845040
-          
+
           If using runtime checking tools, checkerframework may be needed
           https://checkerframework.org/manual/#faq-runtime-retention
 
@@ -530,7 +531,7 @@
         <artifactId>collection</artifactId>
         <version>${ver.dexxcollection}</version>
       </dependency>
-      
+
       <!-- JSON-LD 1.1 read support -->
       <!-- Titanium JSON-LD -->
       <dependency>
@@ -541,7 +542,7 @@
       </dependency>
       <!-- Eclipse Public License 2.0 (category-B) -->
       <!--
-          Not required if using org.glassfish:jakarta.json because 
+          Not required if using org.glassfish:jakarta.json because
           that contains a copy. Less shading warnings if not used
           in jena-arq.
       -->
@@ -629,7 +630,7 @@
         <version>${ver.slf4j}</version>
       </dependency>
 
-      <!-- 
+      <!--
            java.util.logging (JUL)
            Do not use jul-to-slf4j and slf4j-jdk14 together.
       -->
@@ -740,7 +741,7 @@
         <version>${ver.mockito}</version>
         <scope>test</scope>
       </dependency>
-      
+
       <dependency>
         <groupId>org.awaitility</groupId>
         <artifactId>awaitility</artifactId>
@@ -761,6 +762,12 @@
 
       <!-- jena-geosparql, jena-fuseki-geosparql related -->
 
+      <dependency>
+        <groupId>com.esotericsoftware</groupId>
+        <artifactId>kryo</artifactId>
+        <version>${ver.kryo}</version>
+      </dependency>
+
       <dependency>
         <groupId>com.beust</groupId>
         <artifactId>jcommander</artifactId>
@@ -772,7 +779,7 @@
         <artifactId>jakarta.xml.bind-api</artifactId>
         <version>${ver.jakarta-xml-bind}</version>
       </dependency>
-      
+
       <!-- Needed by jena-geosparql -->
       <dependency>
         <groupId>javax.xml.bind</groupId>
@@ -838,6 +845,23 @@
 
       <!-- END jena geosparql -->
 
+      <!-- BEGIN jena-fuseki-mod-geosparql -->
+      <!-- Dependencies to test the mod's HTML interface. -->
+      <dependency>
+        <groupId>org.seleniumhq.selenium</groupId>
+        <artifactId>selenium-java</artifactId>
+        <version>4.18.1</version>
+        <scope>test</scope>
+      </dependency>
+
+      <dependency>
+        <groupId>io.github.bonigarcia</groupId>
+        <artifactId>webdrivermanager</artifactId>
+        <version>5.8.0</version>
+        <scope>test</scope>
+      </dependency>
+      <!-- END jena-fuseki-mod-geosparql -->
+
     </dependencies>
 
   </dependencyManagement>
@@ -889,7 +913,7 @@
         <groupId>org.apache.rat</groupId>
         <artifactId>apache-rat-plugin</artifactId>
         <version>${ver.plugin.rat}</version>
-        <!-- 
+        <!--
              Run only if maven is running from this POM,
              not when run in a sub-module with this as parent.
         -->
@@ -1019,9 +1043,9 @@
             </execution>
             <!-- see jar-plugin
                  <execution>
-                 <id>attach-sources-test</id> 
+                 <id>attach-sources-test</id>
                  <goals>
-                 <goal>test-jar-no-fork</goal> 
+                 <goal>test-jar-no-fork</goal>
                  </goals>
                  </execution>
             -->