From 68f2aad670d86d8325c34f0e017fd0a237409f80 Mon Sep 17 00:00:00 2001
From: Ian Patterson
Date: Wed, 16 Sep 2026 16:37:40 -0500
Subject: [PATCH 01/12] Remove DescendingCursor.java and modify RangeCursor as
newer versions of H2 support cursors with reverse iteration.
---
sensorhub-datastore-h2/build.gradle | 7 +-
.../java/org/h2/mvstore/DescendingCursor.java | 177 ------------------
.../main/java/org/h2/mvstore/RangeCursor.java | 26 ++-
3 files changed, 17 insertions(+), 193 deletions(-)
delete mode 100644 sensorhub-datastore-h2/src/main/java/org/h2/mvstore/DescendingCursor.java
diff --git a/sensorhub-datastore-h2/build.gradle b/sensorhub-datastore-h2/build.gradle
index 6dfa92750..14bd46965 100644
--- a/sensorhub-datastore-h2/build.gradle
+++ b/sensorhub-datastore-h2/build.gradle
@@ -4,7 +4,7 @@ ext.details = 'Storage module backed by a H2 MVStore object database'
dependencies {
implementation project(':sensorhub-core')
implementation project(':sensorhub-utils-kryo')
- embeddedApi 'com.h2database:h2-mvstore:1.4.200'
+ embeddedApi 'com.h2database:h2-mvstore:2.5.250'
embeddedApi 'org.apache.lucene:lucene-analyzers-common:8.11.2'
testImplementation(testFixtures(project(':sensorhub-core')))
}
@@ -34,3 +34,8 @@ ext.pom >>= {
}
}
}
+
+// TEMP (H2 port): show all compiler errors; remove before merging
+tasks.withType(JavaCompile) {
+ options.compilerArgs << "-Xmaxerrs" << "2000"
+}
diff --git a/sensorhub-datastore-h2/src/main/java/org/h2/mvstore/DescendingCursor.java b/sensorhub-datastore-h2/src/main/java/org/h2/mvstore/DescendingCursor.java
deleted file mode 100644
index 0b352df47..000000000
--- a/sensorhub-datastore-h2/src/main/java/org/h2/mvstore/DescendingCursor.java
+++ /dev/null
@@ -1,177 +0,0 @@
-/***************************** BEGIN LICENSE BLOCK ***************************
-
-The contents of this file are subject to the Mozilla Public License, v. 2.0.
-If a copy of the MPL was not distributed with this file, You can obtain one
-at http://mozilla.org/MPL/2.0/.
-
-Software distributed under the License is distributed on an "AS IS" basis,
-WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
-for the specific language governing rights and limitations under the License.
-
-Copyright (C) 2026 GeoRobotix Inc. All Rights Reserved.
-
-******************************* END LICENSE BLOCK ***************************/
-
-package org.h2.mvstore;
-
-import java.util.Iterator;
-import java.util.NoSuchElementException;
-
-
-/**
- * A cursor to iterate over elements in ascending order.
- *
- * @param the key type
- * @param the value type
- */
-public class DescendingCursor implements Iterator {
- private final K to;
- private CursorPos cursorPos;
- private CursorPos keeper;
- private K current;
- private K last;
- private V lastValue;
- private Page lastPage;
-
- public DescendingCursor(Page root, K from) {
- this(root, from, null);
- }
-
- public DescendingCursor(Page root, K from, K to) {
- this.cursorPos = traverseDown(root, from);
- this.to = to;
- }
-
- @Override
- @SuppressWarnings("unchecked")
- public boolean hasNext() {
- if (cursorPos != null) {
- while (current == null) {
- Page page = cursorPos.page;
- int index = cursorPos.index;
- if (index < 0) {
- CursorPos tmp = cursorPos;
- cursorPos = cursorPos.parent;
- tmp.parent = keeper;
- keeper = tmp;
- if(cursorPos == null)
- {
- return false;
- }
- } else {
- while (!page.isLeaf()) {
- page = page.getChildPage(index);
- int numKeys = page.isLeaf() ? page.getKeyCount() : page.map.getChildPageCount(page);
- if (keeper == null) {
- cursorPos = new CursorPos(page, numKeys-1, cursorPos);
- } else {
- CursorPos tmp = keeper;
- keeper = keeper.parent;
- tmp.parent = cursorPos;
- tmp.page = page;
- tmp.index = numKeys-1;
- cursorPos = tmp;
- }
- index = numKeys-1;
- }
- if (index >= 0) {
- K key = (K) page.getKey(index);
- if (to != null && page.map.getKeyType().compare(key, to) < 0) {
- return false;
- }
- current = last = key;
- lastValue = (V) page.getValue(index);
- lastPage = page;
- }
- }
- --cursorPos.index;
- }
- }
- return current != null;
- }
-
- @Override
- public K next() {
- if(!hasNext()) {
- throw new NoSuchElementException();
- }
- current = null;
- return last;
- }
-
- /**
- * Get the last read key if there was one.
- *
- * @return the key or null
- */
- public K getKey() {
- return last;
- }
-
- /**
- * Get the last read value if there was one.
- *
- * @return the value or null
- */
- public V getValue() {
- return lastValue;
- }
-
- /**
- * Get the page where last retrieved key is located.
- *
- * @return the page
- */
- Page getPage() {
- return lastPage;
- }
-
- /**
- * Skip over that many entries. This method is relatively fast (for this map
- * implementation) even if many entries need to be skipped.
- *
- * @param n the number of entries to skip
- */
- public void skip(long n) {
- if (n < 10) {
- while (n-- > 0 && hasNext()) {
- next();
- }
- } else if(hasNext()) {
- assert cursorPos != null;
- CursorPos cp = cursorPos;
- CursorPos parent;
- while ((parent = cp.parent) != null) cp = parent;
- Page root = cp.page;
- @SuppressWarnings("unchecked")
- MVMap map = (MVMap) root.map;
- long index = map.getKeyIndex(next());
- last = map.getKey(index + n);
- this.cursorPos = traverseDown(root, last);
- }
- }
-
- @Override
- public void remove() {
- throw DataUtils.newUnsupportedOperationException(
- "Removal is not supported");
- }
-
- /**
- * Fetch the next entry that is equal or larger than the given key, starting
- * from the given page. This method retains the stack.
- *
- * @param p the page to start from
- * @param key the key to search, null means search for the first key
- */
- private static CursorPos traverseDown(Page p, Object key) {
- CursorPos cursorPos = key == null ? p.getPrependCursorPos(null) : CursorPos.traverseDown(p, key);
- if (cursorPos.index < 0) {
- cursorPos.index = -cursorPos.index - 1;
- // select previous instead of next
- cursorPos.index--;
- }
- return cursorPos;
- }
-}
-
diff --git a/sensorhub-datastore-h2/src/main/java/org/h2/mvstore/RangeCursor.java b/sensorhub-datastore-h2/src/main/java/org/h2/mvstore/RangeCursor.java
index 5ac45156d..a4348ad97 100644
--- a/sensorhub-datastore-h2/src/main/java/org/h2/mvstore/RangeCursor.java
+++ b/sensorhub-datastore-h2/src/main/java/org/h2/mvstore/RangeCursor.java
@@ -25,7 +25,11 @@
/**
*
- * Wrapper for native H2 Cursor and DescendingCursor
+ * Wrapper for native H2 Cursor adding support for descending iteration
+ * and key/value/entry streams.
+ *
+ * When iterating in descending order, {@code from} must be greater than
+ * or equal to {@code to}. Both bounds are inclusive in either direction.
*
*
* @author Alex Robin
@@ -35,7 +39,7 @@
*/
public class RangeCursor implements Iterator
{
- final Iterator cursor;
+ final Cursor cursor;
final MVMap map;
final boolean descending;
@@ -54,21 +58,19 @@ public RangeCursor(MVMap map, K from, K to)
public RangeCursor(MVMap map, K from, K to, boolean descending)
{
- this.cursor = descending ?
- new DescendingCursor(map.getRootPage(), from, to) :
- new Cursor(map.getRootPage(), from, to);
+ this.cursor = map.cursor(from, to, descending);
this.map = map;
this.descending = descending;
}
-
+
@Override
public boolean hasNext()
{
return cursor.hasNext();
}
-
+
@Override
public K next()
{
@@ -76,21 +78,15 @@ public K next()
}
- @SuppressWarnings("unchecked")
public K getKey()
{
- return descending ?
- ((DescendingCursor)cursor).getKey() :
- ((Cursor)cursor).getKey();
+ return cursor.getKey();
}
- @SuppressWarnings("unchecked")
public V getValue()
{
- return descending ?
- ((DescendingCursor)cursor).getValue() :
- ((Cursor)cursor).getValue();
+ return cursor.getValue();
}
From 5e99b993545612032d9fbc06a6efc5814199aef3 Mon Sep 17 00:00:00 2001
From: Ian Patterson
Date: Wed, 16 Sep 2026 17:48:34 -0500
Subject: [PATCH 02/12] =?UTF-8?q?Updated=20KryoDataType.java=20to=20use=20?=
=?UTF-8?q?BasicDataType
- *
- * @author Alex Robin
- * @date Nov 2, 2018
- */
-public interface RadixKeyDataType extends DataType
-{
-
- /**
- * Compare the part of the key starting at offset with a tree node prefix
- * @param fullKey
- * @param prefix
- * @param context search context provides the offset and receives comparison
- * results (compare flag and match length)
- */
- void comparePrefix(Object fullKey, Object prefix, SearchContext context);
-
-
- /**
- * @param prefix Prefix stored in tree node
- * @return Length of key prefix object
- */
- int getPrefixLength(Object prefix);
-
-
- /**
- * Split prefix at given index
- * @param prefix
- * @param splitIndex
- * @return array of length 2 containing begin and end of key/prefix
- */
- Object[] splitKey(Object prefix, int splitIndex);
-
-
- /**
- * @param fullKey
- * @param offset
- * @return return end of key starting at offset
- */
- Object tailKey(Object fullKey, int offset);
-
-
- /**
- * Generates the complete key from individual prefixes obtained from the
- * search context (matching node stack)
- * @param context
- * @return The complete key
- */
- Object buildFullKey(SearchContext context);
-
-
- /**
- * @param value value object being inserted or removed from the map
- * @return the key to use to store this value in B-Tree page
- */
- Object getValueKey(Object value);
-
-}
diff --git a/sensorhub-datastore-h2/src/main/java/org/h2/mvstore/RadixTreeCursor.java b/sensorhub-datastore-h2/src/main/java/org/h2/mvstore/RadixTreeCursor.java
deleted file mode 100644
index 3b1f0c2fb..000000000
--- a/sensorhub-datastore-h2/src/main/java/org/h2/mvstore/RadixTreeCursor.java
+++ /dev/null
@@ -1,182 +0,0 @@
-/***************************** BEGIN LICENSE BLOCK ***************************
-
-The contents of this file are subject to the Mozilla Public License, v. 2.0.
-If a copy of the MPL was not distributed with this file, You can obtain one
-at http://mozilla.org/MPL/2.0/.
-
-Software distributed under the License is distributed on an "AS IS" basis,
-WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
-for the specific language governing rights and limitations under the License.
-
-Copyright (C) 2020 Sensia Software LLC. All Rights Reserved.
-
-******************************* END LICENSE BLOCK ***************************/
-
-package org.h2.mvstore;
-
-import java.util.Iterator;
-import java.util.NoSuchElementException;
-import java.util.Spliterator;
-import java.util.Spliterators;
-import java.util.stream.Stream;
-import java.util.stream.StreamSupport;
-import org.h2.mvstore.MVRadixTreeMap.SearchContext;
-import org.vast.util.Asserts;
-
-
-public class RadixTreeCursor implements Iterator
-{
- private final MVRadixTreeMap map;
- private final SearchContext context;
- private final K from;
- private K currentKey, nextKey;
- private boolean initialized;
- private boolean allValues;
- private Cursor valueCursor;
-
-
- RadixTreeCursor(MVRadixTreeMap map, Page root, K from)
- {
- this.map = map;
- this.from = from;
- this.context = new SearchContext();
- }
-
-
- RadixTreeCursor(MVRadixTreeMap map, Page root, K from, boolean allValues)
- {
- this(map, root, from);
- this.allValues = allValues;
- }
-
-
- @Override
- public boolean hasNext()
- {
- if (nextKey == null)
- fetchNext();
-
- return nextKey != null;
- }
-
-
- @Override
- public K next()
- {
- if (nextKey == null && !hasNext())
- throw new NoSuchElementException();
-
- currentKey = nextKey;
- nextKey = null;
- return currentKey;
- }
-
-
- public K getKey()
- {
- return currentKey;
- }
-
-
- public V getValue()
- {
- Asserts.checkNotNull(currentKey);
-
- if (valueCursor == null)
- return null;
- else
- return valueCursor.getValue();
- }
-
-
- @Override
- public void remove()
- {
- map.remove(getKey());
- }
-
-
- private void fetchNext()
- {
- // get next value if any
- if (valueCursor != null && valueCursor.hasNext())
- {
- valueCursor.next();
- nextKey = currentKey;
- return;
- }
-
- // else go to next key
- K key = null;
- if (!initialized)
- {
- key = map.getPreviousOrNext(from, false, false, context);
- initialized = true;
- }
- else
- {
- if (map.getPreviousOrNext(context, false))
- key = map.buildFullKey(context);
- }
-
- if (key != null)
- {
- context.offset = 0;
- map.comparePrefix(key, from, context);
- if (context.fullNodeMatch)
- {
- nextKey = key;
- setValueCursor();
- }
- }
- }
-
-
- @SuppressWarnings("unchecked")
- private void setValueCursor()
- {
- if (allValues)
- {
- Page selectedPage = context.getSelectedPage();
- Page valuePage = map.getValuePage(selectedPage);
- valueCursor = new Cursor<>(valuePage, (K)valuePage.getKey(0));
- if (valueCursor.hasNext())
- valueCursor.next();
- }
- }
-
-
- public Stream keyStream()
- {
- allValues = false;
- Spliterator it = Spliterators.spliteratorUnknownSize(this, Spliterator.ORDERED | Spliterator.DISTINCT);
- return StreamSupport.stream(it, false);
- }
-
-
- /**
- * @return The stream of selected values.
- * (The returned stream can contain duplicated elements; call its distinct()
- * method if duplicates cannot be handled by client)
- */
- public Stream valueStream()
- {
- Spliterator it = Spliterators.spliteratorUnknownSize(new Iterator() {
- @Override
- public boolean hasNext()
- {
- return RadixTreeCursor.this.hasNext();
- }
-
- @Override
- public V next()
- {
- RadixTreeCursor.this.next();
- return getValue();
- }
- }, Spliterator.ORDERED);
-
- return StreamSupport.stream(it, false);
- }
-
-}
\ No newline at end of file
diff --git a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/index/FullTextIndex.java b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/index/FullTextIndex.java
index 5f4d285b0..4f642460b 100644
--- a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/index/FullTextIndex.java
+++ b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/index/FullTextIndex.java
@@ -49,7 +49,6 @@ public class FullTextIndex>
{
protected static final Pattern NUMBER_PATTERN = Pattern.compile("^\\.?\\d+(\\.\\d+)?");
- //MVRadixTreeMap radixTreeMap;
MVBTreeMap, Boolean> btreeMap;
/* Default analyzer */
@@ -58,9 +57,6 @@ public class FullTextIndex>
public FullTextIndex(MVStore mvStore, String mapName, DataType valueType)
{
- //this.radixTreeMap = mvStore.openMap(mapName, new MVRadixTreeMap.Builder()
- // .keyType(new ResourceRadixKeyDataType())
- // .valueType(valueType));
this.btreeMap = mvStore.openMap(mapName, new MVBTreeMap.Builder, Boolean>()
.keyType(new FullTextKeyDataType(valueType))
.valueType(new MVVoidDataType()));
@@ -73,7 +69,6 @@ public void add(K key, T res)
HashSet tokenSet = new HashSet<>();
addToTokenSet(res, tokenSet);
for (String token: tokenSet)
- //radixTreeMap.put(token.getBytes(), key);
btreeMap.put(new FullTextKey<>(token, key), Boolean.TRUE);
}
@@ -90,17 +85,12 @@ public void remove(K key, T res)
HashSet tokenSet = new HashSet<>();
addToTokenSet(res, tokenSet);
for (String token: tokenSet)
- //radixTreeMap.remove(token.getBytes(), key);
btreeMap.put(new FullTextKey<>(token, key), Boolean.TRUE);
}
public Stream selectKeys(FullTextFilter filter)
{
- //return getKeywordStream(filter)
- // .flatMap(w -> radixTreeMap.entryCursor(w.getBytes()).valueStream())
- // .distinct(); // remove duplicate IDs
-
return getKeywordStream(filter)
.flatMap(w -> {
var startKey = new FullTextKey(w, null);
@@ -153,21 +143,6 @@ protected Stream getKeywordStream(FullTextFilter filter)
public Stream addFullTextPostFilter(Stream pkStream, FullTextFilter filter)
{
- /*Set keywordSet = getKeywordStream(filter)
- .map(String::getBytes)
- .collect(Collectors.toSet());
-
- return pkStream.filter(k -> {
- if (k == null)
- return false;
- for (byte[] w: keywordSet)
- {
- if (radixTreeMap.containsValue(w, k))
- return true;
- }
- return false;
- });*/
-
var keywordSet = getKeywordStream(filter)
.collect(Collectors.toSet());
@@ -187,7 +162,6 @@ public Stream addFullTextPostFilter(Stream pkStream, FullTextFilter filter
public void clear()
{
- //radixTreeMap.clear();
btreeMap.clear();
}
diff --git a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/index/ResourceRadixKeyDataType.java b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/index/ResourceRadixKeyDataType.java
deleted file mode 100644
index 6ff32dec7..000000000
--- a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/index/ResourceRadixKeyDataType.java
+++ /dev/null
@@ -1,86 +0,0 @@
-/***************************** BEGIN LICENSE BLOCK ***************************
-
-The contents of this file are subject to the Mozilla Public License, v. 2.0.
-If a copy of the MPL was not distributed with this file, You can obtain one
-at http://mozilla.org/MPL/2.0/.
-
-Software distributed under the License is distributed on an "AS IS" basis,
-WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
-for the specific language governing rights and limitations under the License.
-
-Copyright (C) 2020 Sensia Software LLC. All Rights Reserved.
-
-******************************* END LICENSE BLOCK ***************************/
-
-package org.sensorhub.impl.datastore.h2.index;
-
-import org.h2.mvstore.RadixKeyBytesDataType;
-import org.sensorhub.api.resource.ResourceKey;
-
-
-/**
- *
- * Implementation with prefix key as byte array and value key as long.
- *
- *
- * @author Alex Robin
- * @date Nov 1, 2018
- */
-public class ResourceRadixKeyDataType extends RadixKeyBytesDataType
-{
-
- @Override
- public Object getValueKey(Object value)
- {
- // extract long id from value object
- long id;
- if (value instanceof ResourceKey>)
- id = ((ResourceKey>)value).getInternalID().getIdAsLong();
- else if (value instanceof Long)
- id = (Long)value;
- else
- throw new IllegalArgumentException("Unsupported value type");
-
- // convert to byte[]
- byte[] bytes = new byte[8];
- bytes[0] = (byte) (id >> 56);
- bytes[1] = (byte) (id >> 48);
- bytes[2] = (byte) (id >> 40);
- bytes[3] = (byte) (id >> 32);
- bytes[4] = (byte) (id >> 24);
- bytes[5] = (byte) (id >> 16);
- bytes[6] = (byte) (id >> 8);
- bytes[7] = (byte) (id /*>> 0*/);
- return bytes;
- }
-
-
- /*@Override
- public void write(WriteBuffer wbuf, Object obj)
- {
- if (obj instanceof Long)
- {
- wbuf.put((byte)0);
- wbuf.putVarLong((Long)obj);
- }
- else
- super.write(wbuf, obj);
- }
-
-
- @Override
- public Object read(ByteBuffer buf)
- {
- // read array length, then byte[]
- int l = DataUtils.readVarInt(buf);
-
- // case of value key as long
- if (l == 0)
- return DataUtils.readVarLong(buf);
-
- byte[] key = new byte[l];
- buf.get(key);
- return key;
- }*/
-
-}
diff --git a/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/PrintDbInfo.java b/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/PrintDbInfo.java
index 6781bf659..a504d1293 100644
--- a/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/PrintDbInfo.java
+++ b/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/PrintDbInfo.java
@@ -37,7 +37,6 @@ public static void main(String[] args) throws Exception
scanPages(db.foiStore.featuresIndex);
scanPages(db.foiStore.idsIndex);
scanPages(db.foiStore.uidsIndex);
- //scanPages(db.foiStore.fullTextIndex.radixTreeMap);
//scanPages(db.foiStore.spatialIndex.rTreeMap);
//Thread.sleep(100000);
From af35d7820a132117eef86dc97288b341106426ac Mon Sep 17 00:00:00 2001
From: Ian Patterson
Date: Tue, 29 Sep 2026 16:15:01 -0500
Subject: [PATCH 08/12] Add db version marker and compatibility check
---
.../sensorhub/impl/datastore/h2/H2Utils.java | 101 ++++++
.../impl/datastore/h2/MVFeatureDatabase.java | 5 +-
.../datastore/h2/MVObsSystemDatabase.java | 22 +-
.../impl/datastore/h2/TestMVCommandStore.java | 10 +-
.../h2/TestMVCommandStreamStore.java | 10 +-
.../datastore/h2/TestMVDataStreamStore.java | 10 +-
.../datastore/h2/TestMVDatabaseVersion.java | 290 ++++++++++++++++++
.../impl/datastore/h2/TestMVFeatureStore.java | 10 +-
.../impl/datastore/h2/TestMVObsStore.java | 10 +-
9 files changed, 424 insertions(+), 44 deletions(-)
create mode 100644 sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/TestMVDatabaseVersion.java
diff --git a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/H2Utils.java b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/H2Utils.java
index 7853352de..caa72db8f 100644
--- a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/H2Utils.java
+++ b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/H2Utils.java
@@ -14,15 +14,21 @@
package org.sensorhub.impl.datastore.h2;
+import java.io.File;
import java.nio.ByteBuffer;
import java.time.Instant;
+import java.util.Arrays;
import java.util.Map;
+import org.h2.engine.Constants;
import org.h2.mvstore.DataUtils;
import org.h2.mvstore.MVMap;
import org.h2.mvstore.MVStore;
+import org.h2.mvstore.MVStoreException;
import org.h2.mvstore.WriteBuffer;
import org.h2.mvstore.rtree.Spatial;
import org.h2.mvstore.rtree.SpatialDataType;
+import org.h2.mvstore.type.StringDataType;
+import org.sensorhub.api.datastore.DataStoreException;
import org.sensorhub.api.datastore.TemporalFilter;
import org.sensorhub.impl.datastore.h2.kryo.KryoDataType;
import org.vast.util.Asserts;
@@ -38,6 +44,12 @@
public class H2Utils
{
public static final int CURRENT_VERSION = 5;
+
+ // Compatibility bounds are deliberate, not derived from the running H2 version.
+ static final String MIN_SUPPORTED_H2_VERSION = "2.5.250";
+ static final String MAX_SUPPORTED_H2_VERSION = "2.5.250";
+ static final String DATABASE_MAP_NAME = "@database";
+ static final String H2_VERSION_KEY = "h2.version";
static final String DATASTORES_MAP_NAME = "@datastores";
static final String GEOM_DIM_ERROR = "Only 2D and 3D geometries are supported";
@@ -80,6 +92,95 @@ static class StoreInfoDataType extends KryoDataType
};
}
}
+
+
+ /**
+ * Opens a database and checks its H2 version before any application maps are
+ * opened. Missing metadata in an existing file requires migration.
+ * @param storagePath database file path
+ * @param builder storage options (the file name and autocommit delay are set here)
+ * @param autoCommitDelay autocommit delay in milliseconds after validation
+ * @return the compatible store; the caller is responsible for closing it
+ * @throws DataStoreException if the file format or version is unsupported
+ */
+ public static MVStore openMVStore(String storagePath, MVStore.Builder builder, int autoCommitDelay)
+ throws DataStoreException
+ {
+ var file = new File(storagePath);
+ boolean newFile = !file.exists() || file.length() == 0;
+ MVStore store;
+ try
+ {
+ store = builder.fileName(storagePath).autoCommitDisabled().open();
+ }
+ catch (MVStoreException e)
+ {
+ if (e.getErrorCode() == DataUtils.ERROR_UNSUPPORTED_FORMAT)
+ throw new DataStoreException("Unsupported H2 file format: " + storagePath, e);
+ throw e;
+ }
+
+ boolean compatible = false;
+ try
+ {
+ var metadataBuilder = new MVMap.Builder()
+ .keyType(StringDataType.INSTANCE)
+ .valueType(StringDataType.INSTANCE);
+ if (newFile)
+ {
+ store.openMap(DATABASE_MAP_NAME, metadataBuilder).put(H2_VERSION_KEY, Constants.VERSION);
+ store.commit();
+ }
+ else
+ {
+ String version = store.hasMap(DATABASE_MAP_NAME)
+ ? store.openMap(DATABASE_MAP_NAME, metadataBuilder).get(H2_VERSION_KEY) : null;
+ if (version == null)
+ migrateDatabase(storagePath, null);
+
+ var components = parseH2Version(version);
+ if (Arrays.compare(components, parseH2Version(MIN_SUPPORTED_H2_VERSION)) < 0)
+ migrateDatabase(storagePath, version);
+ if (Arrays.compare(components, parseH2Version(MAX_SUPPORTED_H2_VERSION)) > 0)
+ throw new DataStoreException("Unsupported H2 database version " + version +
+ " in " + storagePath + "; supported range is " + MIN_SUPPORTED_H2_VERSION +
+ " to " + MAX_SUPPORTED_H2_VERSION);
+ }
+
+ store.setAutoCommitDelay(autoCommitDelay);
+ compatible = true;
+ return store;
+ }
+ finally
+ {
+ if (!compatible)
+ store.closeImmediately();
+ }
+ }
+
+
+ private static int[] parseH2Version(String version) throws DataStoreException
+ {
+ if (version == null || !version.matches("[0-9]+\\.[0-9]+\\.[0-9]+"))
+ throw new DataStoreException("Invalid H2 database version: " + version);
+ try
+ {
+ return Arrays.stream(version.split("\\.")).mapToInt(Integer::parseInt).toArray();
+ }
+ catch (NumberFormatException e)
+ {
+ throw new DataStoreException("Invalid H2 database version: " + version, e);
+ }
+ }
+
+
+ private static void migrateDatabase(String storagePath, String sourceVersion) throws DataStoreException
+ {
+ // Migration of legacy files is not part of this change; files that need it are rejected here.
+ throw new DataStoreException("Database requires migration: " + storagePath +
+ " (H2 version " + (sourceVersion == null ? "missing" : sourceVersion) +
+ "; supported range is " + MIN_SUPPORTED_H2_VERSION + " to " + MAX_SUPPORTED_H2_VERSION + ")");
+ }
public static MVDataStoreInfo getDataStoreInfo(MVStore mvStore, String dataStoreName)
diff --git a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVFeatureDatabase.java b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVFeatureDatabase.java
index e7f9ecb7d..323ee3c9e 100644
--- a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVFeatureDatabase.java
+++ b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVFeatureDatabase.java
@@ -49,7 +49,7 @@ protected void doStart() throws SensorHubException
if (!FileUtils.isSafeFilePath(config.storagePath))
throw new DataStoreException("Storage path contains illegal characters: " + config.storagePath);
- MVStore.Builder builder = new MVStore.Builder().fileName(config.storagePath);
+ MVStore.Builder builder = new MVStore.Builder();
if (config.readOnly)
builder.readOnly();
@@ -63,8 +63,7 @@ protected void doStart() throws SensorHubException
if (config.useCompression)
builder.compress();
- mvStore = builder.open();
- mvStore.setAutoCommitDelay(config.autoCommitPeriod*1000);
+ mvStore = H2Utils.openMVStore(config.storagePath, builder, config.autoCommitPeriod*1000);
mvStore.setVersionsToKeep(0);
// open feature store
diff --git a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVObsSystemDatabase.java b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVObsSystemDatabase.java
index 2aa65b300..51217c00c 100644
--- a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVObsSystemDatabase.java
+++ b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVObsSystemDatabase.java
@@ -82,7 +82,7 @@ protected void doStart() throws SensorHubException
{
try
{
- MVStore.Builder builder = new MVStore.Builder().fileName(config.storagePath);
+ MVStore.Builder builder = new MVStore.Builder();
builder.backgroundExceptionHandler((t, e) -> {
getLogger().error("Error in H2 background thread {}", t.getName(), e);
});
@@ -102,8 +102,7 @@ protected void doStart() throws SensorHubException
if (config.useCompression)
builder.compress();
- mvStore = builder.open();
- mvStore.setAutoCommitDelay(config.autoCommitPeriod*1000);
+ mvStore = H2Utils.openMVStore(config.storagePath, builder, config.autoCommitPeriod*1000);
mvStore.setVersionsToKeep(0);
var idScope = getDatabaseNum() != null ? getDatabaseNum() : 0;
@@ -177,14 +176,15 @@ protected void beforeStop()
@Override
protected void doStop() throws SensorHubException
{
- if (mvStore != null)
- {
- // must call commit first to make sure kryo persistent class resolver
- // is updated before we serialize it again in close
- mvStore.commit();
- mvStore.close();
- mvStore = null;
- }
+ // Startup may have rejected the file before acquiring a compatible store.
+ if (mvStore == null)
+ return;
+
+ // must call commit first to make sure kryo persistent class resolver
+ // is updated before we serialize it again in close
+ mvStore.commit();
+ mvStore.close();
+ mvStore = null;
// log store info if debug is enabled
if (config.printDebugInfoOnClose && getLogger().isDebugEnabled() && new File(config.storagePath).exists())
diff --git a/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/TestMVCommandStore.java b/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/TestMVCommandStore.java
index cfa9db9e6..ee30cf0c6 100644
--- a/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/TestMVCommandStore.java
+++ b/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/TestMVCommandStore.java
@@ -39,13 +39,13 @@ protected MVCommandStoreImpl initStore() throws Exception
}
- protected void forceReadBackFromStorage()
+ protected void forceReadBackFromStorage() throws Exception
{
this.cmdStore = openMVStore();
}
- private MVCommandStoreImpl openMVStore()
+ private MVCommandStoreImpl openMVStore() throws Exception
{
if (mvStore != null)
{
@@ -54,11 +54,9 @@ private MVCommandStoreImpl openMVStore()
System.out.println("MVStore flushed to disk");
}
- mvStore = new MVStore.Builder()
- .fileName(dbFile.getAbsolutePath())
+ mvStore = H2Utils.openMVStore(dbFile.getAbsolutePath(), new MVStore.Builder()
.autoCommitBufferSize(10)
- .cacheSize(10)
- .open();
+ .cacheSize(10), 1000);
return MVCommandStoreImpl.open(mvStore, DATABASE_NUM, IdProviderType.SEQUENTIAL,
MVDataStoreInfo.builder()
diff --git a/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/TestMVCommandStreamStore.java b/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/TestMVCommandStreamStore.java
index 6e4d78e66..92b26785d 100644
--- a/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/TestMVCommandStreamStore.java
+++ b/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/TestMVCommandStreamStore.java
@@ -37,13 +37,13 @@ protected MVCommandStreamStoreImpl initStore() throws Exception
}
- protected void forceReadBackFromStorage()
+ protected void forceReadBackFromStorage() throws Exception
{
this.cmdStreamStore = openMVStore();
}
- private MVCommandStreamStoreImpl openMVStore()
+ private MVCommandStreamStoreImpl openMVStore() throws Exception
{
if (mvStore != null)
{
@@ -52,11 +52,9 @@ private MVCommandStreamStoreImpl openMVStore()
System.out.println("MVStore flushed to disk");
}
- mvStore = new MVStore.Builder()
- .fileName(dbFile.getAbsolutePath())
+ mvStore = H2Utils.openMVStore(dbFile.getAbsolutePath(), new MVStore.Builder()
.autoCommitBufferSize(10)
- .cacheSize(10)
- .open();
+ .cacheSize(10), 1000);
var cmdStore = MVCommandStoreImpl.open(mvStore, DATABASE_NUM, IdProviderType.SEQUENTIAL,
MVDataStoreInfo.builder()
diff --git a/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/TestMVDataStreamStore.java b/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/TestMVDataStreamStore.java
index 0d12fca1f..f8321d788 100644
--- a/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/TestMVDataStreamStore.java
+++ b/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/TestMVDataStreamStore.java
@@ -37,13 +37,13 @@ protected MVDataStreamStoreImpl initStore() throws Exception
}
- protected void forceReadBackFromStorage()
+ protected void forceReadBackFromStorage() throws Exception
{
this.dataStreamStore = openMVStore();
}
- private MVDataStreamStoreImpl openMVStore()
+ private MVDataStreamStoreImpl openMVStore() throws Exception
{
if (mvStore != null)
{
@@ -52,11 +52,9 @@ private MVDataStreamStoreImpl openMVStore()
System.out.println("MVStore flushed to disk");
}
- mvStore = new MVStore.Builder()
- .fileName(dbFile.getAbsolutePath())
+ mvStore = H2Utils.openMVStore(dbFile.getAbsolutePath(), new MVStore.Builder()
.autoCommitBufferSize(10)
- .cacheSize(10)
- .open();
+ .cacheSize(10), 1000);
var obsStore = MVObsStoreImpl.open(mvStore, DATABASE_NUM, IdProviderType.SEQUENTIAL,
MVDataStoreInfo.builder()
diff --git a/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/TestMVDatabaseVersion.java b/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/TestMVDatabaseVersion.java
new file mode 100644
index 000000000..69d871ba7
--- /dev/null
+++ b/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/TestMVDatabaseVersion.java
@@ -0,0 +1,290 @@
+/***************************** BEGIN LICENSE BLOCK ***************************
+
+The contents of this file are subject to the Mozilla Public License, v. 2.0.
+If a copy of the MPL was not distributed with this file, You can obtain one
+at http://mozilla.org/MPL/2.0/.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+for the specific language governing rights and limitations under the License.
+
+Copyright (C) 2026 GeoRobotix Innovative Research, LLC. All Rights Reserved.
+
+******************************* END LICENSE BLOCK ***************************/
+
+package org.sensorhub.impl.datastore.h2;
+
+import static org.junit.Assert.*;
+import java.io.File;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.StandardOpenOption;
+import org.h2.engine.Constants;
+import org.h2.mvstore.DataUtils;
+import org.h2.mvstore.MVMap;
+import org.h2.mvstore.MVStore;
+import org.h2.mvstore.MVStoreException;
+import org.h2.mvstore.type.StringDataType;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.sensorhub.api.datastore.DataStoreException;
+import org.sensorhub.impl.module.AbstractModule;
+
+
+public class TestMVDatabaseVersion
+{
+ @Rule
+ public TemporaryFolder files = new TemporaryFolder();
+
+
+ @Test
+ public void testNewFilesAndReadOnlyReopen() throws Exception
+ {
+ for (File file: new File[] {new File(files.getRoot(), "new.dat"), files.newFile()})
+ {
+ try (var store = H2Utils.openMVStore(file.getPath(), new MVStore.Builder(), 1234))
+ {
+ assertEquals(Constants.VERSION, metadata(store).get(H2Utils.H2_VERSION_KEY));
+ assertEquals(1234, store.getAutoCommitDelay());
+ store.openMap("payload").put("key", "value");
+ }
+ try (var store = H2Utils.openMVStore(file.getPath(), new MVStore.Builder(), 0))
+ {
+ assertEquals(Constants.VERSION, metadata(store).get(H2Utils.H2_VERSION_KEY));
+ assertEquals("value", store.openMap("payload").get("key"));
+ }
+ byte[] before = Files.readAllBytes(file.toPath());
+ try (var store = H2Utils.openMVStore(file.getPath(), new MVStore.Builder().readOnly(), 0))
+ {
+ assertEquals(Constants.VERSION, metadata(store).get(H2Utils.H2_VERSION_KEY));
+ }
+ assertArrayEquals(before, Files.readAllBytes(file.toPath()));
+ }
+ }
+
+
+ @Test
+ public void testMissingMetadataMapRequiresMigration() throws Exception
+ {
+ assertRejected(createFile(null, false), "Database requires migration");
+ }
+
+
+ @Test
+ public void testMissingVersionKeyRequiresMigration() throws Exception
+ {
+ assertRejected(createFile(null, true), "Database requires migration");
+ }
+
+
+ @Test
+ public void testEmptyExistingMVStoreRequiresMigration() throws Exception
+ {
+ File file = files.newFile();
+ try (var store = new MVStore.Builder().fileName(file.getPath()).open())
+ {
+ assertTrue(store.getMapNames().isEmpty());
+ }
+ assertTrue(file.length() > 0);
+ assertRejected(file, "Database requires migration");
+ }
+
+
+ @Test
+ public void testOlderVersionsRequireMigration() throws Exception
+ {
+ for (String version: new String[] {"2.5.249", "2.4.999", "1.4.200"})
+ assertRejected(createFile(version, true), "Database requires migration");
+ }
+
+
+ @Test
+ public void testNewerVersionsAreRejected() throws Exception
+ {
+ for (String version: new String[] {"2.5.251", "2.10.1", "3.0.0"})
+ assertRejected(createFile(version, true), "Unsupported H2 database version " + version);
+ }
+
+
+ @Test
+ public void testMalformedVersionsAreRejected() throws Exception
+ {
+ for (String version: new String[] {"", "bad", "2.5", "2.5.250.extra", "-2.5.250", "2.5.999999999999"})
+ assertRejected(createFile(version, true), "Invalid H2 database version");
+ }
+
+
+ @Test
+ public void testUnsupportedPhysicalFormatsAreNotMigrated() throws Exception
+ {
+ for (int format: new int[] {1, 4})
+ {
+ File file = files.newFile();
+ // Two checksummed H2 headers, declaring an old or future native format.
+ String header = "H:2,blockSize:1000,format:" + format + ",created:0";
+ byte[] bytes = header.getBytes(StandardCharsets.ISO_8859_1);
+ int checksum = DataUtils.getFletcher32(bytes, 0, bytes.length);
+ bytes = (header + ",fletcher:" + Integer.toHexString(checksum) + "\n")
+ .getBytes(StandardCharsets.ISO_8859_1);
+ var buffer = ByteBuffer.allocate(8192);
+ buffer.put(bytes);
+ buffer.position(4096);
+ buffer.put(bytes);
+ Files.write(file.toPath(), buffer.array());
+
+ var error = assertThrows(DataStoreException.class,
+ () -> H2Utils.openMVStore(file.getPath(), new MVStore.Builder(), 0));
+ assertTrue(error.getMessage().contains("Unsupported H2 file format"));
+ assertTrue(error.getCause() instanceof MVStoreException);
+ assertEquals(DataUtils.ERROR_UNSUPPORTED_FORMAT, ((MVStoreException)error.getCause()).getErrorCode());
+ assertArrayEquals(buffer.array(), Files.readAllBytes(file.toPath()));
+ try (var channel = FileChannel.open(file.toPath(), StandardOpenOption.WRITE);
+ var lock = channel.tryLock())
+ {
+ assertNotNull(lock);
+ }
+ }
+ }
+
+
+ @Test
+ public void testBothDatabaseModulesCreateAndReopen() throws Exception
+ {
+ for (boolean featureDatabase: new boolean[] {false, true})
+ {
+ File file = files.newFile();
+ var database = database(file, featureDatabase, false);
+ try
+ {
+ database.init();
+ database.start();
+ assertTrue(database.isStarted());
+ }
+ finally
+ {
+ database.stop();
+ }
+ byte[] before = Files.readAllBytes(file.toPath());
+ database = database(file, featureDatabase, true);
+ try
+ {
+ database.init();
+ database.start();
+ assertTrue(database.isStarted());
+ }
+ finally
+ {
+ database.stop();
+ }
+ assertArrayEquals(before, Files.readAllBytes(file.toPath()));
+ }
+ }
+
+
+ @Test
+ public void testBothDatabaseModulesRejectBeforeReadingApplicationMaps() throws Exception
+ {
+ for (boolean featureDatabase: new boolean[] {false, true})
+ {
+ File file = createFile(null, false);
+ byte[] before = Files.readAllBytes(file.toPath());
+ var database = database(file, featureDatabase, false);
+ try
+ {
+ database.init();
+ database.start();
+ assertFalse(database.isStarted());
+ Throwable error = database.getCurrentError();
+ assertNotNull(error);
+ while (error.getCause() != null)
+ error = error.getCause();
+ assertTrue(error.getMessage(), error.getMessage().contains("Database requires migration"));
+ }
+ finally
+ {
+ database.stop();
+ }
+ assertUnchangedAndUnlocked(file, before);
+ }
+ }
+
+
+ private MVMap metadata(MVStore store)
+ {
+ return store.openMap(H2Utils.DATABASE_MAP_NAME, new MVMap.Builder()
+ .keyType(StringDataType.INSTANCE).valueType(StringDataType.INSTANCE));
+ }
+
+
+ private File createFile(String version, boolean metadataMap) throws Exception
+ {
+ File file = files.newFile();
+ try (var store = new MVStore.Builder().fileName(file.getPath()).open())
+ {
+ if (metadataMap)
+ {
+ var metadata = metadata(store);
+ if (version != null)
+ metadata.put(H2Utils.H2_VERSION_KEY, version);
+ }
+ // Deliberately incompatible application metadata: validation must precede decoding it.
+ var datastores = store.openMap(H2Utils.DATASTORES_MAP_NAME);
+ datastores.put("sys_store", "not Kryo data");
+ datastores.put("feature_store", "not Kryo data");
+ }
+ return file;
+ }
+
+
+ private void assertRejected(File file, String message) throws Exception
+ {
+ byte[] before = Files.readAllBytes(file.toPath());
+ for (boolean readOnly: new boolean[] {false, true})
+ {
+ var builder = new MVStore.Builder();
+ if (readOnly)
+ builder.readOnly();
+ var error = assertThrows(DataStoreException.class,
+ () -> H2Utils.openMVStore(file.getPath(), builder, 1000));
+ assertTrue(error.getMessage(), error.getMessage().contains(message));
+ assertUnchangedAndUnlocked(file, before);
+ }
+ }
+
+
+ private void assertUnchangedAndUnlocked(File file, byte[] before) throws Exception
+ {
+ assertArrayEquals(before, Files.readAllBytes(file.toPath()));
+ // A writable reopen proves rejection released the lock, without committing changes.
+ var store = new MVStore.Builder().fileName(file.getPath()).autoCommitDisabled().open();
+ store.closeImmediately();
+ assertArrayEquals(before, Files.readAllBytes(file.toPath()));
+ }
+
+
+ private AbstractModule> database(File file, boolean featureDatabase, boolean readOnly)
+ {
+ if (featureDatabase)
+ {
+ var database = new MVFeatureDatabase();
+ var config = new MVFeatureDatabaseConfig();
+ config.databaseNum = 1;
+ config.storagePath = file.getPath();
+ config.readOnly = readOnly;
+ database.setConfiguration(config);
+ return database;
+ }
+ var database = new MVObsSystemDatabase();
+ var config = new MVObsSystemDatabaseConfig();
+ config.databaseNum = 1;
+ config.storagePath = file.getPath();
+ config.readOnly = readOnly;
+ // In particular, failed startup must not compact an incompatible file.
+ config.compactOnClose = !readOnly;
+ database.setConfiguration(config);
+ return database;
+ }
+}
diff --git a/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/TestMVFeatureStore.java b/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/TestMVFeatureStore.java
index cd38ef61e..39bd1e4d2 100644
--- a/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/TestMVFeatureStore.java
+++ b/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/TestMVFeatureStore.java
@@ -37,13 +37,13 @@ protected MVFeatureStoreImpl initStore() throws Exception
}
- protected void forceReadBackFromStorage()
+ protected void forceReadBackFromStorage() throws Exception
{
this.featureStore = openMVStore();
}
- private MVFeatureStoreImpl openMVStore()
+ private MVFeatureStoreImpl openMVStore() throws Exception
{
if (mvStore != null)
{
@@ -52,11 +52,9 @@ private MVFeatureStoreImpl openMVStore()
System.out.println("MVStore flushed to disk");
}
- mvStore = new MVStore.Builder()
- .fileName(dbFile.getAbsolutePath())
+ mvStore = H2Utils.openMVStore(dbFile.getAbsolutePath(), new MVStore.Builder()
.autoCommitBufferSize(100)
- .cacheSize(1)
- .open();
+ .cacheSize(1), 1000);
return MVFeatureStoreImpl.open(mvStore, DATABASE_NUM, IdProviderType.SEQUENTIAL, MVDataStoreInfo.builder()
.withName(DATASTORE_NAME)
diff --git a/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/TestMVObsStore.java b/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/TestMVObsStore.java
index 1de63964f..578554bbe 100644
--- a/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/TestMVObsStore.java
+++ b/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/TestMVObsStore.java
@@ -39,13 +39,13 @@ protected MVObsStoreImpl initStore() throws Exception
}
- protected void forceReadBackFromStorage()
+ protected void forceReadBackFromStorage() throws Exception
{
this.obsStore = openMVStore();
}
- private MVObsStoreImpl openMVStore()
+ private MVObsStoreImpl openMVStore() throws Exception
{
if (mvStore != null)
{
@@ -54,11 +54,9 @@ private MVObsStoreImpl openMVStore()
System.out.println("MVStore flushed to disk");
}
- mvStore = new MVStore.Builder()
- .fileName(dbFile.getAbsolutePath())
+ mvStore = H2Utils.openMVStore(dbFile.getAbsolutePath(), new MVStore.Builder()
.autoCommitBufferSize(10)
- .cacheSize(1)
- .open();
+ .cacheSize(1), 1000);
return MVObsStoreImpl.open(mvStore, DATABASE_NUM, IdProviderType.SEQUENTIAL,
MVDataStoreInfo.builder()
From f26795d8d27e1391890411526b49e9780989be95 Mon Sep 17 00:00:00 2001
From: Ian Patterson
Date: Tue, 29 Sep 2026 16:21:10 -0500
Subject: [PATCH 09/12] remove temporary compiler setting and ignore IDE run
configs
---
.gitignore | 1 +
sensorhub-datastore-h2/build.gradle | 5 -----
2 files changed, 1 insertion(+), 5 deletions(-)
diff --git a/.gitignore b/.gitignore
index b6e3e768f..2ef653757 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,4 @@
**/.classpath
**/.idea
**/*.iml
+**/.run/
diff --git a/sensorhub-datastore-h2/build.gradle b/sensorhub-datastore-h2/build.gradle
index 14bd46965..bff7f5316 100644
--- a/sensorhub-datastore-h2/build.gradle
+++ b/sensorhub-datastore-h2/build.gradle
@@ -34,8 +34,3 @@ ext.pom >>= {
}
}
}
-
-// TEMP (H2 port): show all compiler errors; remove before merging
-tasks.withType(JavaCompile) {
- options.compilerArgs << "-Xmaxerrs" << "2000"
-}
From 3a96490593aa52b9204a1bda2a1aae65e1fb71d7 Mon Sep 17 00:00:00 2001
From: Ian Patterson
Date: Wed, 30 Sep 2026 10:04:14 -0500
Subject: [PATCH 10/12] Add migration tooling and make a few constants public
for reuse in the tool.
The migration tool will eventually be separated from this module, but I am packaging it here so that we can easily automigrate old files during this transitory period.
---
sensorhub-datastore-h2/build.gradle | 78 +++
.../legacy/LegacyStoreFactoryImpl.java | 105 ++++
.../h2/migrate/legacy/LegacyStoreImpl.java | 261 ++++++++++
.../DatabaseMigrationRequiredException.java | 40 ++
.../sensorhub/impl/datastore/h2/H2Utils.java | 25 +-
.../impl/datastore/h2/MVFeatureDatabase.java | 2 +-
.../datastore/h2/MVObsSystemDatabase.java | 16 +-
.../impl/datastore/h2/migrate/FileOps.java | 212 +++++++++
.../impl/datastore/h2/migrate/H2Migrate.java | 152 ++++++
.../datastore/h2/migrate/LegacyLoader.java | 242 ++++++++++
.../h2/migrate/MigrationException.java | 37 ++
.../impl/datastore/h2/migrate/Migrator.java | 448 ++++++++++++++++++
.../datastore/h2/migrate/RawDataType.java | 126 +++++
.../datastore/h2/migrate/SourceReader.java | 269 +++++++++++
.../datastore/h2/migrate/StoreLayout.java | 226 +++++++++
.../impl/datastore/h2/migrate/Verifier.java | 270 +++++++++++
.../datastore/h2/migrate/spi/LegacyStore.java | 157 ++++++
.../h2/migrate/TestLegacyLoader.java | 134 ++++++
.../h2/migrate/TestMigrateFailures.java | 274 +++++++++++
.../h2/migrate/TestMigrateNative.java | 120 +++++
.../h2/migrate/TestMigrateRoundTrip.java | 226 +++++++++
.../datastore/h2/migrate/TestSupport.java | 404 ++++++++++++++++
22 files changed, 3808 insertions(+), 16 deletions(-)
create mode 100644 sensorhub-datastore-h2/src/legacy/java/org/sensorhub/impl/datastore/h2/migrate/legacy/LegacyStoreFactoryImpl.java
create mode 100644 sensorhub-datastore-h2/src/legacy/java/org/sensorhub/impl/datastore/h2/migrate/legacy/LegacyStoreImpl.java
create mode 100644 sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/DatabaseMigrationRequiredException.java
create mode 100644 sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/FileOps.java
create mode 100644 sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/H2Migrate.java
create mode 100644 sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/LegacyLoader.java
create mode 100644 sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/MigrationException.java
create mode 100644 sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/Migrator.java
create mode 100644 sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/RawDataType.java
create mode 100644 sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/SourceReader.java
create mode 100644 sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/StoreLayout.java
create mode 100644 sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/Verifier.java
create mode 100644 sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/spi/LegacyStore.java
create mode 100644 sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/migrate/TestLegacyLoader.java
create mode 100644 sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/migrate/TestMigrateFailures.java
create mode 100644 sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/migrate/TestMigrateNative.java
create mode 100644 sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/migrate/TestMigrateRoundTrip.java
create mode 100644 sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/migrate/TestSupport.java
diff --git a/sensorhub-datastore-h2/build.gradle b/sensorhub-datastore-h2/build.gradle
index bff7f5316..b7cac40b3 100644
--- a/sensorhub-datastore-h2/build.gradle
+++ b/sensorhub-datastore-h2/build.gradle
@@ -1,20 +1,98 @@
description = 'OSH Datastore (H2)'
ext.details = 'Storage module backed by a H2 MVStore object database'
+// Legacy H2 1.4 adapters used by the migration tool (org.sensorhub.impl.datastore.h2.migrate).
+// Compiled against H2 1.4.200 only and loaded at runtime in an isolated classloader; the 1.4
+// jar is never on a classpath. By default both jars are embedded as resources under
+// META-INF/h2migrate so migrations work from a plain node install. Build with
+// -PembedH2MigrationLibs=false to leave them out; the tool then needs a legacy-libs/ directory
+// next to the node's lib/ (see the h2migrateLegacyLibs task) or --legacy-libs.
+ext.embedH2MigrationLibs = !project.hasProperty('embedH2MigrationLibs') ||
+ Boolean.parseBoolean(project.property('embedH2MigrationLibs').toString())
+
+sourceSets {
+ legacy {
+ java.srcDir 'src/legacy/java'
+ compileClasspath += files(sourceSets.main.java.classesDirectory)
+ }
+}
+
+configurations {
+ legacyH2
+}
+
dependencies {
implementation project(':sensorhub-core')
implementation project(':sensorhub-utils-kryo')
embeddedApi 'com.h2database:h2-mvstore:2.5.250'
embeddedApi 'org.apache.lucene:lucene-analyzers-common:8.11.2'
testImplementation(testFixtures(project(':sensorhub-core')))
+
+ legacyCompileOnly 'com.h2database:h2-mvstore:1.4.200'
+ legacyH2 'com.h2database:h2-mvstore:1.4.200'
+}
+
+compileLegacyJava.dependsOn compileJava
+
+tasks.register('legacyJar', Jar) {
+ description = 'Packages the H2 1.4 migration adapters (published with classifier h2migrate-legacy)'
+ from sourceSets.legacy.output
+ archiveClassifier = 'h2migrate-legacy'
+}
+
+def legacyLibsDir = layout.buildDirectory.dir('h2migrate/legacy-libs')
+
+tasks.register('h2migrateLegacyLibs', Sync) {
+ description = 'Assembles the H2 1.4 jar and the migration adapters into build/h2migrate/legacy-libs ' +
+ '(copy that directory next to a node\'s lib/ as legacy-libs/, or pass it with --legacy-libs)'
+ from(tasks.named('legacyJar'))
+ from(configurations.legacyH2)
+ into legacyLibsDir
+}
+
+if (embedH2MigrationLibs) {
+ def embeddedLegacyDir = layout.buildDirectory.dir('generated-resources/h2migrate')
+ tasks.register('embedLegacyLibs', Sync) {
+ description = 'Embeds the H2 1.4 jar and the migration adapters as resources of this module'
+ from(tasks.named('legacyJar'))
+ from(configurations.legacyH2)
+ into embeddedLegacyDir.map { it.dir('META-INF/h2migrate') }
+ doLast {
+ def dir = embeddedLegacyDir.get().dir('META-INF/h2migrate').asFile
+ new File(dir, 'index.txt').text = dir.listFiles().findAll { it.name.endsWith('.jar') }.collect { it.name }.sort().join('\n') + '\n'
+ }
+ }
+ sourceSets.main.output.dir(embeddedLegacyDir, builtBy: 'embedLegacyLibs')
+}
+
+tasks.register('migrate', JavaExec) {
+ description = 'Runs the H2 database migration tool; pass arguments with -PmigrateArgs=" [options]"'
+ classpath = sourceSets.main.runtimeClasspath
+ mainClass = 'org.sensorhub.impl.datastore.h2.migrate.H2Migrate'
+ if (!embedH2MigrationLibs) {
+ dependsOn 'h2migrateLegacyLibs'
+ systemProperty 'h2migrate.legacyLibs', legacyLibsDir.get().asFile.absolutePath
+ }
+ if (project.hasProperty('migrateArgs'))
+ args project.migrateArgs.split('\\s+')
}
test {
+ // default builds exercise the embedded libraries; an opt-out build uses the directory
+ if (!embedH2MigrationLibs) {
+ dependsOn 'h2migrateLegacyLibs'
+ systemProperty 'h2migrate.legacyLibs', legacyLibsDir.get().asFile.absolutePath
+ }
filter {
excludeTestsMatching 'org.sensorhub.impl.datastore.h2.TestMVFeatureStore.test*Throughput'
}
}
+// publish the adapters next to the module so operators can fetch them without a source build
+afterEvaluate {
+ publishing.publications.mavenJava.artifact(tasks.legacyJar)
+}
+
// add info to OSGI manifest
osgi {
manifest {
diff --git a/sensorhub-datastore-h2/src/legacy/java/org/sensorhub/impl/datastore/h2/migrate/legacy/LegacyStoreFactoryImpl.java b/sensorhub-datastore-h2/src/legacy/java/org/sensorhub/impl/datastore/h2/migrate/legacy/LegacyStoreFactoryImpl.java
new file mode 100644
index 000000000..0b5d11068
--- /dev/null
+++ b/sensorhub-datastore-h2/src/legacy/java/org/sensorhub/impl/datastore/h2/migrate/legacy/LegacyStoreFactoryImpl.java
@@ -0,0 +1,105 @@
+/***************************** BEGIN LICENSE BLOCK ***************************
+
+The contents of this file are subject to the Mozilla Public License, v. 2.0.
+If a copy of the MPL was not distributed with this file, You can obtain one
+at http://mozilla.org/MPL/2.0/.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+for the specific language governing rights and limitations under the License.
+
+Copyright (C) 2026 GeoRobotix Innovative Research, LLC. All Rights Reserved.
+
+******************************* END LICENSE BLOCK ***************************/
+
+package org.sensorhub.impl.datastore.h2.migrate.legacy;
+
+import org.h2.mvstore.MVMap;
+import org.h2.mvstore.MVStore;
+import org.h2.mvstore.rtree.MVRTreeMap;
+import org.h2.mvstore.rtree.SpatialKey;
+import org.sensorhub.impl.datastore.h2.migrate.spi.LegacyStore;
+
+
+/**
+ * Instantiated reflectively by the tool inside the legacy classloader.
+ */
+public final class LegacyStoreFactoryImpl implements LegacyStore.Factory
+{
+ public LegacyStoreFactoryImpl()
+ {
+ }
+
+
+ @Override
+ public LegacyStore openReadOnly(String fileName)
+ {
+ return new LegacyStoreImpl(fileName);
+ }
+
+
+ @Override
+ public LegacyStore.Writer openForWrite(String fileName)
+ {
+ return new WriterImpl(fileName);
+ }
+
+
+ @Override
+ public void compact(String fileName)
+ {
+ org.h2.mvstore.MVStoreTool.compact(fileName, false);
+ }
+
+
+ /**
+ * Writes a genuine H2 1.4 (format 1) file from raw entries. Test fixture support.
+ */
+ static final class WriterImpl implements LegacyStore.Writer
+ {
+ private final MVStore store;
+
+
+ WriterImpl(String fileName)
+ {
+ this.store = new MVStore.Builder()
+ .fileName(fileName)
+ .autoCommitDisabled()
+ .open();
+ }
+
+
+ @Override
+ public MapWriter openMap(String mapName, LegacyStore.EntryComparator keyComparator)
+ {
+ MVMap map = store.openMap(mapName, new MVMap.Builder()
+ .keyType(new LegacyStoreImpl.RecordingDataType(null, keyComparator))
+ .valueType(new LegacyStoreImpl.RecordingDataType(null, null)));
+ return (key, decodedKey, value) -> map.put(new LegacyStoreImpl.RawEntry(key, decodedKey), new LegacyStoreImpl.RawEntry(value, null));
+ }
+
+
+ @Override
+ public RTreeMapWriter openRTreeMap(String mapName, int dimensions)
+ {
+ MVRTreeMap map = store.openMap(mapName, new MVRTreeMap.Builder()
+ .dimensions(dimensions)
+ .valueType(new LegacyStoreImpl.RecordingDataType(null, null)));
+ return (id, minMax, value) -> map.put(new SpatialKey(id, minMax), new LegacyStoreImpl.RawEntry(value, null));
+ }
+
+
+ @Override
+ public void commit()
+ {
+ store.commit();
+ }
+
+
+ @Override
+ public void close()
+ {
+ store.close();
+ }
+ }
+}
diff --git a/sensorhub-datastore-h2/src/legacy/java/org/sensorhub/impl/datastore/h2/migrate/legacy/LegacyStoreImpl.java b/sensorhub-datastore-h2/src/legacy/java/org/sensorhub/impl/datastore/h2/migrate/legacy/LegacyStoreImpl.java
new file mode 100644
index 000000000..aebe0d3e2
--- /dev/null
+++ b/sensorhub-datastore-h2/src/legacy/java/org/sensorhub/impl/datastore/h2/migrate/legacy/LegacyStoreImpl.java
@@ -0,0 +1,261 @@
+/***************************** BEGIN LICENSE BLOCK ***************************
+
+The contents of this file are subject to the Mozilla Public License, v. 2.0.
+If a copy of the MPL was not distributed with this file, You can obtain one
+at http://mozilla.org/MPL/2.0/.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+for the specific language governing rights and limitations under the License.
+
+Copyright (C) 2026 GeoRobotix Innovative Research, LLC. All Rights Reserved.
+
+******************************* END LICENSE BLOCK ***************************/
+
+package org.sensorhub.impl.datastore.h2.migrate.legacy;
+
+import java.nio.ByteBuffer;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import org.h2.engine.Constants;
+import org.h2.mvstore.Cursor;
+import org.h2.mvstore.DataUtils;
+import org.h2.mvstore.MVMap;
+import org.h2.mvstore.MVStore;
+import org.h2.mvstore.Page;
+import org.h2.mvstore.WriteBuffer;
+import org.h2.mvstore.rtree.MVRTreeMap;
+import org.h2.mvstore.rtree.SpatialKey;
+import org.h2.mvstore.type.DataType;
+import org.sensorhub.impl.datastore.h2.migrate.spi.LegacyStore;
+
+
+/**
+ * Read-only H2 1.4 store. Every map is opened with {@link RecordingDataType}s
+ * so entries come out as exact bytes.
+ */
+final class LegacyStoreImpl implements LegacyStore
+{
+ private final String fileName;
+ private final MVStore store;
+
+
+ LegacyStoreImpl(String fileName)
+ {
+ this.fileName = fileName;
+ this.store = new MVStore.Builder()
+ .fileName(fileName)
+ .readOnly()
+ .open();
+ }
+
+
+ @Override
+ public String getFileName()
+ {
+ return fileName;
+ }
+
+
+ @Override
+ public String getH2Version()
+ {
+ return Constants.VERSION;
+ }
+
+
+ @Override
+ public Set getMapNames()
+ {
+ return Collections.unmodifiableSet(new TreeSet<>(store.getMapNames()));
+ }
+
+
+ @Override
+ public Map getMapMeta(String mapName)
+ {
+ String id = store.getMetaMap().get(DataUtils.META_NAME + mapName);
+ if (id == null)
+ throw new IllegalArgumentException("Unknown map: " + mapName);
+ String config = store.getMetaMap().get("map." + id);
+ if (config == null)
+ throw new IllegalStateException("Missing metadata for map: " + mapName);
+ return DataUtils.parseMap(config);
+ }
+
+
+ @Override
+ public boolean isRTree(String mapName)
+ {
+ return "rtree".equals(getMapMeta(mapName).get("type"));
+ }
+
+
+ @Override
+ public long readMap(String mapName, LegacyStore.EntryDecoder keyDecoder, LegacyStore.EntryDecoder valueDecoder, LegacyStore.RawEntrySink sink)
+ {
+ if (isRTree(mapName))
+ throw new IllegalArgumentException("Map is an R-tree, use readRTreeMap: " + mapName);
+
+ MVMap map = store.openMap(mapName, new MVMap.Builder()
+ .keyType(new RecordingDataType(keyDecoder, null))
+ .valueType(new RecordingDataType(valueDecoder, null)));
+
+ long count = 0;
+ Cursor cursor = map.cursor(null);
+ while (cursor.hasNext())
+ {
+ RawEntry key = (RawEntry)cursor.next();
+ RawEntry value = (RawEntry)cursor.getValue();
+ sink.entry(key.bytes, key.decoded, value.bytes, value.decoded);
+ count++;
+ }
+ return count;
+ }
+
+
+ @Override
+ public long readRTreeMap(String mapName, int dimensions, LegacyStore.EntryDecoder valueDecoder, LegacyStore.SpatialEntrySink sink)
+ {
+ // no metadata check here: the caller knows the map's type from the store layout,
+ // and files compacted by H2 1.4 have lost the type:rtree marker
+ MVRTreeMap map = store.openMap(mapName, new MVRTreeMap.Builder()
+ .dimensions(dimensions)
+ .valueType(new RecordingDataType(valueDecoder, null)));
+
+ return walk(map.getRootPage(), dimensions, sink);
+ }
+
+
+ private long walk(Page page, int dimensions, LegacyStore.SpatialEntrySink sink)
+ {
+ long count = 0;
+ if (page.isLeaf())
+ {
+ for (int i = 0; i < page.getKeyCount(); i++)
+ {
+ SpatialKey key = (SpatialKey)page.getKey(i);
+ RawEntry value = (RawEntry)page.getValue(i);
+ float[] minMax = new float[2 * dimensions];
+ for (int d = 0; d < dimensions; d++)
+ {
+ minMax[2 * d] = key.min(d);
+ minMax[2 * d + 1] = key.max(d);
+ }
+ sink.entry(key.getId(), minMax, value.bytes, value.decoded);
+ count++;
+ }
+ }
+ else
+ {
+ // R-tree node pages hold one child per key; the extra raw slot is an empty placeholder
+ for (int i = 0; i < page.getKeyCount(); i++)
+ count += walk(page.getChildPage(i), dimensions, sink);
+ }
+ return count;
+ }
+
+
+ @Override
+ public void close()
+ {
+ store.closeImmediately();
+ }
+
+
+ /**
+ * Key or value as stored in an H2 1.4 page: the exact bytes plus, when the
+ * entry was read through a decoder, the decoded object.
+ */
+ static final class RawEntry
+ {
+ final byte[] bytes;
+ final Object decoded;
+
+
+ RawEntry(byte[] bytes, Object decoded)
+ {
+ this.bytes = bytes;
+ this.decoded = decoded;
+ }
+ }
+
+
+ /**
+ * H2 1.4 {@link DataType} that finds entry boundaries by delegating the
+ * decode to the caller (the real OSH datatype on the H2 2.5 side) and
+ * records the exact bytes consumed. Writes emit recorded bytes verbatim.
+ */
+ static final class RecordingDataType implements DataType
+ {
+ private final LegacyStore.EntryDecoder decoder;
+ private final LegacyStore.EntryComparator comparator;
+
+
+ RecordingDataType(LegacyStore.EntryDecoder decoder, LegacyStore.EntryComparator comparator)
+ {
+ this.decoder = decoder;
+ this.comparator = comparator;
+ }
+
+
+ @Override
+ public int compare(Object a, Object b)
+ {
+ if (comparator == null)
+ throw new UnsupportedOperationException("No key comparator configured for this map");
+ RawEntry ra = (RawEntry)a;
+ RawEntry rb = (RawEntry)b;
+ return comparator.compare(ra.bytes, ra.decoded, rb.bytes, rb.decoded);
+ }
+
+
+ @Override
+ public int getMemory(Object obj)
+ {
+ return ((RawEntry)obj).bytes.length + 32;
+ }
+
+
+ @Override
+ public void write(WriteBuffer buff, Object obj)
+ {
+ buff.put(((RawEntry)obj).bytes);
+ }
+
+
+ @Override
+ public void write(WriteBuffer buff, Object[] obj, int len, boolean key)
+ {
+ for (int i = 0; i < len; i++)
+ write(buff, obj[i]);
+ }
+
+
+ @Override
+ public Object read(ByteBuffer buff)
+ {
+ if (decoder == null)
+ throw new UnsupportedOperationException("No decoder configured for this map");
+ int start = buff.position();
+ Object decoded = decoder.decode(buff);
+ int end = buff.position();
+ if (end < start)
+ throw new IllegalStateException("Decoder moved buffer backwards");
+ byte[] bytes = new byte[end - start];
+ buff.position(start);
+ buff.get(bytes);
+ return new RawEntry(bytes, decoded);
+ }
+
+
+ @Override
+ public void read(ByteBuffer buff, Object[] obj, int len, boolean key)
+ {
+ for (int i = 0; i < len; i++)
+ obj[i] = read(buff);
+ }
+ }
+}
diff --git a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/DatabaseMigrationRequiredException.java b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/DatabaseMigrationRequiredException.java
new file mode 100644
index 000000000..f0156348e
--- /dev/null
+++ b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/DatabaseMigrationRequiredException.java
@@ -0,0 +1,40 @@
+/***************************** BEGIN LICENSE BLOCK ***************************
+
+The contents of this file are subject to the Mozilla Public License, v. 2.0.
+If a copy of the MPL was not distributed with this file, You can obtain one
+at http://mozilla.org/MPL/2.0/.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+for the specific language governing rights and limitations under the License.
+
+Copyright (C) 2026 GeoRobotix Innovative Research, LLC. All Rights Reserved.
+
+******************************* END LICENSE BLOCK ***************************/
+
+package org.sensorhub.impl.datastore.h2;
+
+import org.sensorhub.api.datastore.DataStoreException;
+
+
+/**
+ * Thrown when a database file was written by an older H2 version or lacks the
+ * version marker, so it must be migrated before the module can open it. The
+ * message tells the operator how (autoMigrate flag or command line tool).
+ */
+public class DatabaseMigrationRequiredException extends DataStoreException
+{
+ private static final long serialVersionUID = 1L;
+
+
+ public DatabaseMigrationRequiredException(String message)
+ {
+ super(message);
+ }
+
+
+ public DatabaseMigrationRequiredException(String message, Throwable cause)
+ {
+ super(message, cause);
+ }
+}
diff --git a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/H2Utils.java b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/H2Utils.java
index caa72db8f..26b82337e 100644
--- a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/H2Utils.java
+++ b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/H2Utils.java
@@ -48,10 +48,10 @@ public class H2Utils
// Compatibility bounds are deliberate, not derived from the running H2 version.
static final String MIN_SUPPORTED_H2_VERSION = "2.5.250";
static final String MAX_SUPPORTED_H2_VERSION = "2.5.250";
- static final String DATABASE_MAP_NAME = "@database";
- static final String H2_VERSION_KEY = "h2.version";
+ public static final String DATABASE_MAP_NAME = "@database";
+ public static final String H2_VERSION_KEY = "h2.version";
- static final String DATASTORES_MAP_NAME = "@datastores";
+ public static final String DATASTORES_MAP_NAME = "@datastores";
static final String GEOM_DIM_ERROR = "Only 2D and 3D geometries are supported";
private static final SpatialFactory SPATIAL_FACTORY = new SpatialFactory();
@@ -116,7 +116,8 @@ public static MVStore openMVStore(String storagePath, MVStore.Builder builder, i
catch (MVStoreException e)
{
if (e.getErrorCode() == DataUtils.ERROR_UNSUPPORTED_FORMAT)
- throw new DataStoreException("Unsupported H2 file format: " + storagePath, e);
+ throw new DatabaseMigrationRequiredException("Unsupported H2 file format: " + storagePath +
+ " (written by an older H2 version). " + migrationHint(storagePath), e);
throw e;
}
@@ -176,10 +177,20 @@ private static int[] parseH2Version(String version) throws DataStoreException
private static void migrateDatabase(String storagePath, String sourceVersion) throws DataStoreException
{
- // Migration of legacy files is not part of this change; files that need it are rejected here.
- throw new DataStoreException("Database requires migration: " + storagePath +
+ // Migration (backup, interim copy, verification, atomic swap) is implemented in
+ // org.sensorhub.impl.datastore.h2.migrate. It runs at startup only when the database
+ // config enables autoMigrate; otherwise the operator runs it from the command line.
+ throw new DatabaseMigrationRequiredException("Database requires migration: " + storagePath +
" (H2 version " + (sourceVersion == null ? "missing" : sourceVersion) +
- "; supported range is " + MIN_SUPPORTED_H2_VERSION + " to " + MAX_SUPPORTED_H2_VERSION + ")");
+ "; supported range is " + MIN_SUPPORTED_H2_VERSION + " to " + MAX_SUPPORTED_H2_VERSION + "). " +
+ migrationHint(storagePath));
+ }
+
+
+ static String migrationHint(String storagePath)
+ {
+ return "Enable 'autoMigrate' in the database module config to migrate it at startup, " +
+ "or stop the node and run: java -cp 'lib/*' org.sensorhub.impl.datastore.h2.migrate.H2Migrate " + storagePath;
}
diff --git a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVFeatureDatabase.java b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVFeatureDatabase.java
index 323ee3c9e..7bee71f4c 100644
--- a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVFeatureDatabase.java
+++ b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVFeatureDatabase.java
@@ -34,7 +34,7 @@
*/
public class MVFeatureDatabase extends AbstractModule implements IFeatureDatabase
{
- final static String FEATURE_STORE_NAME = "feature_store";
+ public final static String FEATURE_STORE_NAME = "feature_store";
MVStore mvStore;
MVFeatureStoreImpl featureStore;
diff --git a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVObsSystemDatabase.java b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVObsSystemDatabase.java
index 51217c00c..7d4cef854 100644
--- a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVObsSystemDatabase.java
+++ b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVObsSystemDatabase.java
@@ -47,14 +47,14 @@
*/
public class MVObsSystemDatabase extends AbstractModule implements IObsSystemDatabase, IProcedureDatabase, IObsSystemDatabaseModule
{
- final static String KRYO_CLASS_MAP_NAME = "kryo_class_map";
- final static String SYSTEM_STORE_NAME = "sys_store";
- final static String DEPL_STORE_NAME = "depl_store";
- final static String FOI_STORE_NAME = "foi_store";
- final static String OBS_STORE_NAME = "obs_store";
- final static String CMD_STORE_NAME = "cmd_store";
- final static String PROC_STORE_NAME = "proc_store";
- final static String PROP_STORE_NAME = "prop_store";
+ public final static String KRYO_CLASS_MAP_NAME = "kryo_class_map";
+ public final static String SYSTEM_STORE_NAME = "sys_store";
+ public final static String DEPL_STORE_NAME = "depl_store";
+ public final static String FOI_STORE_NAME = "foi_store";
+ public final static String OBS_STORE_NAME = "obs_store";
+ public final static String CMD_STORE_NAME = "cmd_store";
+ public final static String PROC_STORE_NAME = "proc_store";
+ public final static String PROP_STORE_NAME = "prop_store";
MVStore mvStore;
MVSystemStoreImpl sysStore;
diff --git a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/FileOps.java b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/FileOps.java
new file mode 100644
index 000000000..6f36217bd
--- /dev/null
+++ b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/FileOps.java
@@ -0,0 +1,212 @@
+/***************************** BEGIN LICENSE BLOCK ***************************
+
+The contents of this file are subject to the Mozilla Public License, v. 2.0.
+If a copy of the MPL was not distributed with this file, You can obtain one
+at http://mozilla.org/MPL/2.0/.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+for the specific language governing rights and limitations under the License.
+
+Copyright (C) 2026 GeoRobotix Innovative Research, LLC. All Rights Reserved.
+
+******************************* END LICENSE BLOCK ***************************/
+
+package org.sensorhub.impl.datastore.h2.migrate;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.channels.FileLock;
+import java.nio.channels.OverlappingFileLockException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.StandardOpenOption;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.HexFormat;
+import java.util.regex.Pattern;
+
+
+/**
+ * File-level steps of the migration: header inspection, lock probing,
+ * verified backup, instatement by rename (the original is retired as .old).
+ */
+final class FileOps
+{
+ private static final Pattern FORMAT = Pattern.compile("(?:^|,)format:(\\d+)(?:,|$)");
+ private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'");
+
+
+ private FileOps()
+ {
+ }
+
+
+ /**
+ * Reads the H2 file header and returns its "format" field.
+ * H2 1.4 writes format 1; H2 2.x writes format 2 or 3.
+ */
+ static int readH2Format(Path file) throws IOException, MigrationException
+ {
+ byte[] head = new byte[8192];
+ int n;
+ try (InputStream is = Files.newInputStream(file))
+ {
+ n = is.readNBytes(head, 0, head.length);
+ }
+ String text = new String(head, 0, n, StandardCharsets.ISO_8859_1);
+ int start = text.indexOf("H:2,");
+ if (start < 0)
+ throw new MigrationException("Not an H2 MVStore file (no header found): " + file);
+ int end = text.indexOf('\n', start);
+ String header = end < 0 ? text.substring(start) : text.substring(start, end);
+ var m = FORMAT.matcher(header);
+ if (!m.find())
+ throw new MigrationException("H2 header has no format field: " + file);
+ return Integer.parseInt(m.group(1));
+ }
+
+
+ /**
+ * Fails if another process holds a lock on the file (a running node).
+ */
+ static void checkNotLocked(Path file) throws IOException, MigrationException
+ {
+ try (FileChannel ch = FileChannel.open(file, StandardOpenOption.READ, StandardOpenOption.WRITE))
+ {
+ FileLock lock = ch.tryLock();
+ if (lock == null)
+ throw new MigrationException("Database file is locked by another process: " + file);
+ lock.release();
+ }
+ catch (OverlappingFileLockException e)
+ {
+ throw new MigrationException("Database file is locked by this process: " + file);
+ }
+ }
+
+
+ /**
+ * Copies the file next to itself (or into backupDir) and verifies the copy
+ * by length and SHA-256.
+ * @return path of the verified backup
+ */
+ static Path backup(Path file, Path backupDir, String tag) throws IOException, MigrationException
+ {
+ Path dir = backupDir != null ? backupDir : file.toAbsolutePath().getParent();
+ Files.createDirectories(dir);
+ String stamp = ZonedDateTime.now(ZoneOffset.UTC).format(TS);
+ Path backup = dir.resolve(file.getFileName() + "." + tag + "-backup-" + stamp);
+ if (Files.exists(backup))
+ throw new MigrationException("Backup target already exists: " + backup);
+
+ Files.copy(file, backup, StandardCopyOption.COPY_ATTRIBUTES);
+ fsync(backup);
+
+ long srcLen = Files.size(file);
+ long dstLen = Files.size(backup);
+ if (srcLen != dstLen)
+ throw new MigrationException("Backup length mismatch: " + srcLen + " vs " + dstLen + " for " + backup);
+ String srcHash = sha256(file);
+ String dstHash = sha256(backup);
+ if (!srcHash.equals(dstHash))
+ throw new MigrationException("Backup checksum mismatch for " + backup);
+ return backup;
+ }
+
+
+ static String sha256(Path file) throws IOException
+ {
+ MessageDigest md;
+ try
+ {
+ md = MessageDigest.getInstance("SHA-256");
+ }
+ catch (NoSuchAlgorithmException e)
+ {
+ throw new IllegalStateException(e);
+ }
+ try (FileChannel ch = FileChannel.open(file, StandardOpenOption.READ))
+ {
+ ByteBuffer buf = ByteBuffer.allocate(1 << 20);
+ while (ch.read(buf) >= 0)
+ {
+ buf.flip();
+ md.update(buf);
+ buf.clear();
+ }
+ }
+ return HexFormat.of().formatHex(md.digest());
+ }
+
+
+ /**
+ * Puts the interim file in place without ever overwriting or deleting the
+ * original: the original is renamed to {@code retired} (its bytes untouched),
+ * then the interim file is renamed onto the now free target path. If the second
+ * rename fails, the original is renamed back.
+ */
+ static void instate(Path interim, Path target, Path retired) throws IOException
+ {
+ if (Files.exists(retired))
+ throw new IOException("Cannot retire the original, file already exists: " + retired);
+ fsync(interim);
+ Files.move(target, retired, StandardCopyOption.ATOMIC_MOVE);
+ try
+ {
+ Files.move(interim, target, StandardCopyOption.ATOMIC_MOVE);
+ }
+ catch (IOException e)
+ {
+ Files.move(retired, target, StandardCopyOption.ATOMIC_MOVE);
+ throw e;
+ }
+ fsyncDirectory(target.toAbsolutePath().getParent());
+ }
+
+
+ static void fsync(Path file) throws IOException
+ {
+ try (FileChannel ch = FileChannel.open(file, StandardOpenOption.READ))
+ {
+ ch.force(true);
+ }
+ }
+
+
+ static void fsyncDirectory(Path dir)
+ {
+ if (dir == null)
+ return;
+ try (FileChannel ch = FileChannel.open(dir, StandardOpenOption.READ))
+ {
+ ch.force(true);
+ }
+ catch (IOException e)
+ {
+ // directories cannot be opened for sync on every platform; best effort
+ }
+ }
+
+
+ static void deleteQuietly(Path file)
+ {
+ if (file == null)
+ return;
+ try
+ {
+ Files.deleteIfExists(file);
+ }
+ catch (IOException e)
+ {
+ // best effort cleanup
+ }
+ }
+}
diff --git a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/H2Migrate.java b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/H2Migrate.java
new file mode 100644
index 000000000..3020542a0
--- /dev/null
+++ b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/H2Migrate.java
@@ -0,0 +1,152 @@
+/***************************** BEGIN LICENSE BLOCK ***************************
+
+The contents of this file are subject to the Mozilla Public License, v. 2.0.
+If a copy of the MPL was not distributed with this file, You can obtain one
+at http://mozilla.org/MPL/2.0/.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+for the specific language governing rights and limitations under the License.
+
+Copyright (C) 2026 GeoRobotix Innovative Research, LLC. All Rights Reserved.
+
+******************************* END LICENSE BLOCK ***************************/
+
+package org.sensorhub.impl.datastore.h2.migrate;
+
+import java.nio.file.Paths;
+import java.util.Locale;
+
+
+/**
+ * Command line entry point.
+ *