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 6dfa92750..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: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'))) + + 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/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/FullTextKeyDataType.java b/sensorhub-datastore-h2/src/main/java/org/h2/mvstore/FullTextKeyDataType.java index 98e80e23b..f977e9a76 100644 --- a/sensorhub-datastore-h2/src/main/java/org/h2/mvstore/FullTextKeyDataType.java +++ b/sensorhub-datastore-h2/src/main/java/org/h2/mvstore/FullTextKeyDataType.java @@ -15,10 +15,11 @@ package org.h2.mvstore; import java.nio.ByteBuffer; +import org.h2.mvstore.type.BasicDataType; import org.h2.mvstore.type.DataType; -public class FullTextKeyDataType implements DataType +public class FullTextKeyDataType extends BasicDataType implements ObjectArrayDataType { DataType refKeyDataType; @@ -64,14 +65,6 @@ public void write(WriteBuffer wbuf, Object obj) } - @Override - public void write(WriteBuffer wbuf, Object[] obj, int len, boolean key) - { - for (int i=0; i extends MVMap { - protected MVBTreeMap(Map config) + protected MVBTreeMap(Map config, DataType keyType, DataType valueType) { - super(config); + super(config, keyType, valueType); + } + + + protected MVBTreeMap(MVBTreeMap source) + { + super(source); + } + + + @Override + protected MVBTreeMap cloneIt() + { + // H2 uses this hook to create read-only version snapshots. + return new MVBTreeMap<>(this); } @@ -53,9 +66,9 @@ public K getFullKey(Object key) * This can be used when a partial key is used for retrieval */ @SuppressWarnings({ "unchecked" }) - private K getFullKey(Page p, Object key) + private K getFullKey(Page p, Object key) { - int x = p.binarySearch(key); + int x = p.binarySearch((K)key); if (!p.isLeaf()) { if (x < 0) { x = -x - 1; @@ -66,7 +79,7 @@ private K getFullKey(Page p, Object key) return getFullKey(p, key); } if (x >= 0) { - return (K)p.getKey(x); + return p.getKey(x); } return null; } @@ -82,9 +95,9 @@ public Entry getEntry(Object key) * Same code as binarySearch method but returning full entry */ @SuppressWarnings({ "unchecked" }) - private Entry getEntry(Page p, Object key) + private Entry getEntry(Page p, Object key) { - int x = p.binarySearch(key); + int x = p.binarySearch((K)key); if (!p.isLeaf()) { if (x < 0) { x = -x - 1; @@ -95,7 +108,7 @@ private Entry getEntry(Page p, Object key) return getEntry(p, key); } if (x >= 0) { - return new SimpleEntry<>((K)p.getKey(x), (V)p.getValue(x)); + return new SimpleEntry<>(p.getKey(x), p.getValue(x)); } return null; } @@ -128,8 +141,7 @@ public Entry lowerEntry(K key) /* * Same code as getMinMax method but returning full entry */ - @SuppressWarnings("unchecked") - private Entry getMinMaxEntry(Page p, K key, boolean min, boolean excluding) { + private Entry getMinMaxEntry(Page p, K key, boolean min, boolean excluding) { int x = p.binarySearch(key); if (p.isLeaf()) { if (x < 0) { @@ -140,7 +152,7 @@ private Entry getMinMaxEntry(Page p, K key, boolean min, boolean excluding if (x < 0 || x >= p.getKeyCount()) { return null; } - return new SimpleEntry<>((K)p.getKey(x), (V)p.getValue(x)); + return new SimpleEntry<>(p.getKey(x), p.getValue(x)); } if (x++ < 0) { x = -x; @@ -171,62 +183,36 @@ public Stream keyStream() * @param the key type * @param the value type */ - public static class Builder implements MapBuilder, K, V> + public static class Builder extends MVMap.BasicBuilder, K, V> { - protected DataType keyType; - protected DataType valueType; - - - public Builder keyType(DataType keyType) + public Builder() { - this.keyType = keyType; - return this; } - public Builder valueType(DataType valueType) - { - this.valueType = valueType; - return this; - } @Override - public DataType getKeyType() + public Builder keyType(DataType keyType) { - return keyType; + setKeyType(keyType); + return this; } - @Override - public DataType getValueType() - { - return valueType; - } @Override - public void setKeyType(DataType dataType) + public Builder valueType(DataType valueType) { - this.keyType = dataType; + setValueType(valueType); + return this; } - @Override - public void setValueType(DataType dataType) - { - this.valueType = dataType; - } @Override - public MVBTreeMap create(MVStore store, Map config) + protected MVBTreeMap create(Map config) { - if (keyType == null) - keyType = new ObjectDataType(); - - if (valueType == null) - valueType = new ObjectDataType(); - - config.put("store", store); - config.put("key", keyType); - config.put("val", valueType); - - return new MVBTreeMap<>(config); + Object type = config.get("type"); + if (type == null) + return new MVBTreeMap<>(config, getKeyType(), getValueType()); + throw new IllegalArgumentException("Incompatible map type"); } } } diff --git a/sensorhub-datastore-h2/src/main/java/org/h2/mvstore/MVRadixTreeMap.java b/sensorhub-datastore-h2/src/main/java/org/h2/mvstore/MVRadixTreeMap.java deleted file mode 100644 index 67d50cbc6..000000000 --- a/sensorhub-datastore-h2/src/main/java/org/h2/mvstore/MVRadixTreeMap.java +++ /dev/null @@ -1,1015 +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.AbstractSet; -import java.util.Deque; -import java.util.Iterator; -import java.util.LinkedList; -import java.util.Map; -import java.util.Set; -import org.h2.mvstore.Page.PageReference; -import org.h2.mvstore.type.DataType; -import org.h2.mvstore.type.ObjectDataType; -import org.vast.util.Asserts; - - -/** - *

- * Implementation of a radix tree for use as an H2 MVStore map.
- * The last child of a page is used to store the reference to a binary tree - * that contains the values. - * - * WARNING: This is NOT working since H2 MVStore version 1.4.198 - *

- * - * @author Alex Robin - * @param Type of key usable with this map - * @param Type of value usable with this map - * @date Oct 9, 2018 - */ -public class MVRadixTreeMap extends MVMap -{ - - static class SearchNode - { - Page page; - int childIndex; - - SearchNode(Page page, int childIndex) - { - this.page = page; - this.childIndex = childIndex; - } - - public String toString() - { - return new String((byte[])page.getKey(childIndex)); - } - } - - /* - * Class used to store compare and search results - */ - static class SearchContext - { - // current offset in full key where to start comparison with prefix - int offset; - - // comparison result flag: 0 if prefix match, -1 if smaller, 1 if bigger - int compare; - - // length of match between prefix and full key, starting at offset - int matchLength; - - // length remaining in key for further matching - int keyRemaining; - - // true if node key is present in its entirety in full key at offset - boolean fullNodeMatch; - - // found index within page (negative if no exact match found) - int index; - - // found page - // this is the page attached to index if index>=0 - // otherwise it is the parent page - Page page; - - // stacks containing info of each node along the path to the selected key - Deque nodeStack = new LinkedList<>(); - - Page getSelectedPage() - { - if (!nodeStack.isEmpty()) - { - SearchNode lastNode = nodeStack.peek(); - if (lastNode.childIndex < 0) - return lastNode.page; - else - return lastNode.page.getChildPage(lastNode.childIndex); - } - else - return page.getChildPage(index); - } - - boolean isExactMatch() - { - return fullNodeMatch && index >= 0; - } - } - - - MVRadixTreeMap(Map config) - { - super(config); - } - - - /*@Override - void setRootPos(long rootPos, long version) - { - root = rootPos == 0 ? createEmptyNode(-1) : readPage(rootPos); - root.setVersion(version); - }*/ - - - @Override - public V get(Page root, Object key) - { - Page valuePage = getValuePage(root, key); - - if (valuePage != null) - return getFirstValue(valuePage); - else - return null; - } - - - /* - * Look for value key in B-Tree pages - */ - protected boolean containsValueKey(Page p, Object key) { - int x = p.binarySearch(key); - if (!p.isLeaf()) { - if (x < 0) { - x = -x - 1; - } else { - x++; - } - p = p.getChildPage(x); - return containsValueKey(p, key); - } - if (x >= 0) { - return true; - } - return false; - } - - - public boolean containsValue(Object key, V value) - { - Page valuePage = getValuePage(getRootPage(), key); - if (valuePage == null) - return false; - - return containsValueKey(valuePage, getValueKey(value)); - } - - - protected Page getValuePage(Page root, Object key) - { - // use prefix search to look for matching key index and page - SearchContext context = new SearchContext(); - prefixBinarySearch(root, key, context, false); - - // if exact match was found, return the first leaf value, if any - if (context.isExactMatch()) - return getValuePage(context.getSelectedPage()); - else - return null; - } - - - protected Page getValuePage(Page p) - { - // values are always in the last child page - int valuesNodeIndex = p.getKeyCount(); - if (p.getCounts(valuesNodeIndex) > 0) - return p.getChildPage(valuesNodeIndex); - - return null; - } - - - protected boolean hasValues(Page p) - { - // values are always in the last child page (after the last key) - int valuesNodeIndex = p.getKeyCount(); - return (p.getCounts(valuesNodeIndex) > 0); - } - - - @SuppressWarnings("unchecked") - protected V getFirstValue(Page valuePage) - { - while (!valuePage.isLeaf()) - valuePage = valuePage.getChildPage(0); - - if (valuePage.getKeyCount() == 0) - return null; - - return (V)valuePage.getValue(0); - } - - - /* - * Look recursively for the given prefix in the page tree - */ - protected void prefixBinarySearch(Page p, Object key, SearchContext context, boolean recordStack) - { - checkKeyNotNull(key); - - int x = prefixBinarySearchInPage(p, key, context); - context.index = x; - context.page = p; - - // return if key cannot match anymore - if (x < 0) - return; - - // add result to context - Object childPrefix = p.getKey(x); - Page childPage = p.getChildPage(x); - - // record stack for traversal - if (recordStack) - context.nodeStack.push(new SearchNode(p, x)); - - // stop here if done matching prefix - if (context.keyRemaining == 0 || !context.fullNodeMatch) - return; - - // increase offset for next prefix comparison - context.offset = context.offset + getPrefixLength(childPrefix); - prefixBinarySearch(childPage, key, context, recordStack); - } - - - /* - * Find the closest key in the page using binary search - * A positive result indicates an exact match with a key at the given index - * A negative result gives the index where the key could be inserted, minus one - */ - protected int prefixBinarySearchInPage(Page p, Object key, SearchContext context) - { - int low = 0; - int high = p.getKeyCount() - 1; - - while (low <= high) - { - int x = (low + high) >>> 1; - comparePrefix(key, p.getKey(x), context); - if (context.compare > 0) - low = x + 1; - else if (context.compare < 0) - high = x - 1; - else - return x; - } - - return -(low + 1); - } - - - public long getChildCount(K key) - { - SearchContext context = new SearchContext(); - prefixBinarySearch(getRootPage(), key, context, false); - if (context.keyRemaining == 0 && context.index >= 0) - return context.page.getCounts(context.index); - else - return 0; - } - - - /*@Override - protected K getFirstLast(Page root, boolean first) - { - SearchContext context = new SearchContext(); - if (getFirstLast(getRootPage(), first, true, context)) - return buildFullKey(context); - else - return null; - }*/ - - - /* - * Find first or last key in a tree branch starting from the given page - */ - protected boolean getFirstLast(Page p, boolean first, boolean excludeSelf, SearchContext context) - { - boolean exclude = excludeSelf; - - while (p != null) - { - if (!exclude && (first || p.getKeyCount() == 0) && hasValues(p)) - { - context.nodeStack.push(new SearchNode(p, -1)); - return true; - } - - exclude = false; - - // stop here if no more children - if (p.getKeyCount() == 0) - return false; - - // get next child index to scan - int childIndex = first ? 0 : p.getKeyCount()-1; - - // add to stack as we go down - context.nodeStack.push(new SearchNode(p, childIndex)); - - p = p.getChildPage(childIndex); - } - - return false; - } - - - /*@Override - protected K getMinMax(K key, boolean min, boolean excluding) - { - SearchContext context = new SearchContext(); - return getPreviousOrNext(key, min, excluding, context); - }*/ - - - protected K getPreviousOrNext(K key, boolean previous, boolean excluding, SearchContext context) - { - // use prefix search to look for matching key index and page - prefixBinarySearch(getRootPage(), key, context, true); - boolean exactMatch = context.isExactMatch(); - boolean found = false; - - // exact match - if (exactMatch) - { - Page selectedPage = context.getSelectedPage(); - - // if next requested, we just go down - if (!previous || !excluding && hasValues(selectedPage)) - found = getFirstLast(selectedPage, true, excluding, context); - - // else we need to search siblings or parents - else - found = getPreviousOrNext(context, previous); - } - - // partial match - else if (context.index >= 0) - { - Page selectedPage = context.getSelectedPage(); - - // need to compare reminder of prefix to know what direction to take - Object[] split = splitKey(context.page.getKey(context.index), context.matchLength); - Object prefixEnd = split[1]; - - if (context.keyRemaining > 0) - { - context.offset += context.matchLength; - comparePrefix(key, prefixEnd, context); - } - - // key is after prefix - if (context.compare > 0 && previous) - found = getFirstLast(selectedPage, false, false, context); - - // key is before prefix - else if ((context.keyRemaining == 0 || context.compare < 0) && !previous) - found = getFirstLast(selectedPage, true, false, context); - - else - found = getPreviousOrNext(context, previous); - } - - // else need to navigate the tree - else - { - int index = -context.index + (previous ? -1 : -2); - context.nodeStack.push(new SearchNode(context.page, index)); - found = getPreviousOrNext(context, previous); - } - - // return full key if something found - if (found) - return buildFullKey(context); - else - return null; - } - - - /* - * Navigate the tree to find next or previous key - */ - protected boolean getPreviousOrNext(SearchContext context, boolean previous) - { - SearchNode node = context.nodeStack.peek(); - if (node == null) - return false; - - Page p = node.page; - int nextIndex = previous ? node.childIndex-1 : node.childIndex+1; - - if (nextIndex >= 0 && nextIndex < p.getKeyCount()) - { - node.childIndex = nextIndex; - Page childPage = p.getChildPage(nextIndex); - return getFirstLast(childPage, !previous, false, context); - } - else if (previous && nextIndex == -1 && hasValues(p)) - { - // select this node since it has values - context.nodeStack.pop(); - return true; - } - else - { - if (!getPreviousOrNextPageUp(context, previous)) - return false; - return getPreviousOrNext(context, previous); - } - } - - - /* - * Navigate up the tree until we find a branch going in the right direction. - */ - protected boolean getPreviousOrNextPageUp(SearchContext context, boolean previous) - { - while (!context.nodeStack.isEmpty()) - { - SearchNode node = context.nodeStack.peek(); - Page p = node.page; - int nextIndex = previous ? node.childIndex-1 : node.childIndex+1; - - if (nextIndex >= 0 && nextIndex < p.getKeyCount()) - return true; - else if (previous && nextIndex == -1 && hasValues(p)) - return true; - - context.nodeStack.pop(); - } - - return false; - } - - - @Override - @SuppressWarnings("unchecked") - public synchronized V put(K key, V value) - { - DataUtils.checkArgument(value != null, "The value may not be null"); - beforeWrite(); - RootReference rootReference = flushAndGetRoot(); - long v = rootReference.version; - Page p = rootReference.root.copy(); - Object result = put(p, v, key, value, new SearchContext()); - rootReference.updateRootPage(p, 1); - return (V) result; - } - - - protected Object put(Page p, long writeVersion, Object key, Object value, SearchContext context) - { - checkKeyNotNull(key); - - int index = prefixBinarySearchInPage(p, key, context); - Page childPage; - - // no prefix match, need to create a new node - if (index < 0) - { - index = -index - 1; - childPage = createEmptyNode(writeVersion); - addValue(childPage, value); - - Object keyPart = tailKey(key, context.offset); - if (keyPart == null) - return null; - - p.insertNode(index, keyPart, childPage); - return null; - } - - // else found prefix match - else - { - // if entire node key was matched - if (context.fullNodeMatch) - { - // copy selected page because it's going to be modified - childPage = p.getChildPage(index).copy(); - - // if exact match, add value to this page - // else if end of key still has to be processed, keep going down - if (context.keyRemaining == 0) - { - addValue(childPage, value); - p.setChild(index, childPage); - return null; - } - else - { - // increase offset for next prefix comparison - context.offset = context.offset + getPrefixLength(p.getKey(index)); - Object val = put(childPage, writeVersion, key, value, context); - p.setChild(index, childPage); // do this here to properly update total count - return val; - } - } - - // else need to split node key - else - { - Page oldPage = p.getChildPage(index); - Object[] split = splitKey(p.getKey(index), context.matchLength); - Object prefixBegin = split[0]; - Object prefixEnd = split[1]; - - // create new page to insert - childPage = createEmptyNode(writeVersion); - childPage.insertNode(0, prefixEnd, oldPage); - - // insert at selected index with shorter key - p.setChild(index, childPage); - p.setKey(index, prefixBegin); - - return put(p, writeVersion, key, value, context); - } - } - } - - - protected void addValue(Page p, Object value) - { - int valuesNodeIndex = p.getKeyCount(); // it's always the last child page - long valueCount = p.getCounts(valuesNodeIndex); - Page valuePage; - - // create the leaf page if needed - if (valueCount == 0) - { - valuePage = Page.createLeaf( - this, - new Object[] {getValueKey(value)}, - new Object[] {value}, - 0); - - p.setChild(valuesNodeIndex, valuePage); - } - else - { - valuePage = p.getChildPage(valuesNodeIndex).copy(); - - // split if value page is still a leaf page and reached memory threshold - // we need this because super.put doesn't handle splitting a top level leaf page - if (valuePage.isLeaf() && valuePage.getMemory() > store.getPageSplitSize() && valuePage.getKeyCount() > 1) - { - long numValues = valuePage.getTotalCount(); - Page c1 = valuePage; - - int at = c1.getKeyCount() / 2; - Object k = c1.getKey(at); - Page c2 = c1.split(at); - valuePage = Page.createNode( - this, - new Object[] {k}, - new PageReference[] { - new PageReference(c1), - new PageReference(c2) - }, - numValues, 0); - } - - var valueKey = getValueKey(value); - int index = valuePage.binarySearch(valueKey); - valuePage.insertLeaf(-index - 1, valueKey, value); - - p.setChild(valuesNodeIndex, valuePage); // need to do that to properly update counts - } - } - - - @Override - @SuppressWarnings("unchecked") - public V remove(final Object key) - { - return (V) remove(key, null, false); - } - - - public boolean remove(final Object key, final boolean removeSubKeys) - { - return (remove(key, null, removeSubKeys) != null); - } - - - @Override - public synchronized boolean remove(final Object key, final Object value) - { - return (remove(key, value, false) != null); - } - - - protected synchronized Object remove(final Object key, final Object value, final boolean removeSubKeys) - { - checkKeyNotNull(key); - - // use prefix search to look for matching key index and page - SearchContext context = new SearchContext(); - prefixBinarySearch(getRootPage(), key, context, true); - - // continue only if exact match found - if (context.isExactMatch()) - { - beforeWrite(); - RootReference rootReference = flushAndGetRoot(); - long v = rootReference.version; - - synchronized (this) - { - Page p = context.getSelectedPage(); - Page newPage = null; - Object oldValue = null; - - // remove only value if specified - if (value != null && hasValues(p)) - { - newPage = p.copy(); - Page newValuePage = getValuePage(newPage).copy(); - int index = newValuePage.binarySearch(key); - oldValue = newValuePage.getValue(index); - newValuePage.remove(index); - newPage.setChild(newPage.getKeyCount(), newValuePage); - } - - // or remove key and all subkeys - else if (removeSubKeys) - { - oldValue = Boolean.TRUE; - p.removeAllRecursive(v); - } - - // otherwise remove all values for the selected key - else if (p.getKeyCount() > 0) - { - Page valuePage = getValuePage(p); - if (valuePage != null) - { - oldValue = getFirstValue(valuePage); - newPage = removeAllValues(p, v); - } - } - - // copy and update parent pages all the way to root - Page newChild = newPage; - Page parent = null; - for (SearchNode node: context.nodeStack) - { - parent = node.page.copy(); - if (newChild != null) - parent.setChild(node.childIndex, newChild); - else - parent.remove(node.childIndex); - newChild = parent; - } - - rootReference.updateRootPage(parent, 1); - return oldValue; - } - } - - return null; - } - - - protected Page removeAllValues(Page p, long version) - { - p = p.copy(); - - Page valuePage = getValuePage(p); - if (valuePage != null) - { - p.setChild(p.getKeyCount(), null); - valuePage.removeAllRecursive(version); - } - - return p; - } - - - /*@Override - public Cursor cursor(K from) - { - throw new UnsupportedOperationException("Use prefixCursor() or entryCursor() instead"); - }*/ - - - /** - * Get a cursor that iterates through all keys starting at the - * given key or key prefix - * @param startPrefix - * @return the cursor object to iterate through keys - */ - public RadixTreeCursor prefixCursor(K startPrefix) - { - return new RadixTreeCursor<>(this, getRootPage(), startPrefix); - } - - - /** - * Get a cursor that iterates through all entries associated to keys - * starting at the given key or key prefix (a key can be reported multiple - * times if several values are attached to it) - * @param startPrefix - * @return the cursor object to iterate through keys - */ - public RadixTreeCursor entryCursor(K startPrefix) - { - return new RadixTreeCursor<>(this, getRootPage(), startPrefix, true); - } - - - /*@Override - public Set> entrySet() - { - return new AbstractSet>() - { - @Override - public Iterator> iterator() - { - final RadixTreeCursor cursor = entryCursor(firstKey()); - return new Iterator>() - { - @Override - public boolean hasNext() - { - return cursor.hasNext(); - } - - @Override - public Entry next() - { - K k = cursor.next(); - return new SimpleEntry<>(k, cursor.getValue()); - } - - @Override - public void remove() - { - cursor.remove(); - } - }; - } - - @Override - public int size() - { - return MVRadixTreeMap.this.size(); - } - - @Override - public boolean contains(Object o) - { - return MVRadixTreeMap.this.containsKey(o); - } - }; - }*/ - - - @Override - public Set keySet() - { - return new AbstractSet() - { - @Override - public Iterator iterator() - { - return prefixCursor(firstKey()); - } - - @Override - public int size() - { - return MVRadixTreeMap.this.size(); - } - - @Override - public boolean contains(Object o) - { - return MVRadixTreeMap.this.containsKey(o); - } - }; - } - - - protected Object[] splitKey(Object prefix, int splitIndex) - { - return ((RadixKeyDataType) getKeyType()).splitKey(prefix, splitIndex); - } - - - protected Object tailKey(Object key, int offset) - { - return ((RadixKeyDataType) getKeyType()).tailKey(key, offset); - } - - - protected int getPrefixLength(Object prefix) - { - return ((RadixKeyDataType)getKeyType()).getPrefixLength(prefix); - } - - - protected void comparePrefix(Object fullKey, Object prefix, SearchContext context) - { - ((RadixKeyDataType)getKeyType()).comparePrefix(fullKey, prefix, context); - } - - - @SuppressWarnings("unchecked") - protected K buildFullKey(SearchContext context) - { - return (K) ((RadixKeyDataType)getKeyType()).buildFullKey(context); - } - - - protected Object getValueKey(Object value) - { - return ((RadixKeyDataType)getKeyType()).getValueKey(value); - } - - - protected Page createEmptyNode(long version) - { - return Page.createEmptyNode(this); - } - - - protected final void checkKeyNotNull(Object k) - { - Asserts.checkNotNull(k, "Key"); - } - - - protected String keyAsString(Object key) - { - if (key instanceof String) - return (String)key; - else if (key instanceof byte[]) - return new String((byte[])key); - else - return key.toString(); - } - - - public String asString() - { - StringBuilder buf = new StringBuilder(); - toString(getRootPage(), buf, 0); - return buf.toString(); - } - - - protected void toString(Page p, StringBuilder buf, int indent) - { - for (int i=0; i the key type - * @param the value type - */ - public static class Builder implements MapBuilder, K, V> - { - protected RadixKeyDataType keyType; - protected DataType valueType; - - - public Builder keyType(RadixKeyDataType keyType) - { - this.keyType = keyType; - return this; - } - - public Builder valueType(DataType valueType) - { - this.valueType = valueType; - return this; - } - - @Override - public DataType getKeyType() - { - return keyType; - } - - @Override - public DataType getValueType() - { - return valueType; - } - - @Override - public void setKeyType(DataType dataType) - { - this.keyType = (RadixKeyDataType)dataType; - } - - @Override - public void setValueType(DataType dataType) - { - this.valueType = dataType; - } - - @Override - public MVRadixTreeMap create(MVStore store, Map config) - { - Asserts.checkNotNull(keyType, RadixKeyDataType.class); - - if (valueType == null) - valueType = new ObjectDataType(); - - config.put("store", store); - config.put("key", keyType); - config.put("val", valueType); - - return new MVRadixTreeMap<>(config); - } - } - -} diff --git a/sensorhub-datastore-h2/src/main/java/org/h2/mvstore/MVVarLongDataType.java b/sensorhub-datastore-h2/src/main/java/org/h2/mvstore/MVVarLongDataType.java index 110443558..0a307a70e 100644 --- a/sensorhub-datastore-h2/src/main/java/org/h2/mvstore/MVVarLongDataType.java +++ b/sensorhub-datastore-h2/src/main/java/org/h2/mvstore/MVVarLongDataType.java @@ -15,7 +15,7 @@ package org.h2.mvstore; import java.nio.ByteBuffer; -import org.h2.mvstore.type.DataType; +import org.h2.mvstore.type.BasicDataType; /** @@ -26,7 +26,7 @@ * @author Alex Robin * @date Oct 26, 2018 */ -public class MVVarLongDataType implements DataType +public class MVVarLongDataType extends BasicDataType implements ObjectArrayDataType { @@ -51,14 +51,6 @@ public final void write(WriteBuffer wbuf, Object obj) } - @Override - public void write(WriteBuffer wbuf, Object[] obj, int len, boolean key) - { - for (Object o: obj) - write(wbuf, o); - } - - @Override public final Object read(ByteBuffer buf) { @@ -66,10 +58,4 @@ public final Object read(ByteBuffer buf) } - @Override - public void read(ByteBuffer buf, Object[] obj, int len, boolean key) - { - for (int i=0; i +{ + @Override + default Object[] createStorage(int size) + { + return new Object[size]; + } +} diff --git a/sensorhub-datastore-h2/src/main/java/org/h2/mvstore/RTreeEntryCursor.java b/sensorhub-datastore-h2/src/main/java/org/h2/mvstore/RTreeEntryCursor.java index 742c73876..4d150654c 100644 --- a/sensorhub-datastore-h2/src/main/java/org/h2/mvstore/RTreeEntryCursor.java +++ b/sensorhub-datastore-h2/src/main/java/org/h2/mvstore/RTreeEntryCursor.java @@ -17,7 +17,7 @@ import java.util.Iterator; import java.util.Map.Entry; import java.util.NoSuchElementException; -import org.h2.mvstore.rtree.SpatialKey; +import org.h2.mvstore.rtree.Spatial; /** @@ -31,16 +31,16 @@ * @author Alex Robin * @since Jan 23, 2022 */ -public class RTreeEntryCursor implements Iterator> { +public class RTreeEntryCursor implements Iterator> { - private final SpatialKey filter; - private CursorPos pos; - private SpatialKey currentKey, lastKey; + private final Spatial filter; + private CursorPos pos; + private Spatial currentKey, lastKey; private V currentVal, lastVal; - private final Page root; + private final Page root; private boolean initialized; - protected RTreeEntryCursor(Page root, SpatialKey filter) { + protected RTreeEntryCursor(Page root, Spatial filter) { this.root = root; this.filter = filter; } @@ -49,7 +49,7 @@ protected RTreeEntryCursor(Page root, SpatialKey filter) { public boolean hasNext() { if (!initialized) { // init - pos = new CursorPos(root, 0, null); + pos = new CursorPos<>(root, 0, null); fetchNext(); initialized = true; } @@ -69,7 +69,7 @@ public void skip(long n) { } @Override - public Entry next() { + public Entry next() { if (!hasNext()) { throw new NoSuchElementException(); } @@ -91,14 +91,14 @@ public void remove() { @SuppressWarnings("unchecked") protected void fetchNext() { while (pos != null) { - Page p = pos.page; + Page p = pos.page; if (p.isLeaf()) { while (pos.index < p.getKeyCount()) { int index = pos.index++; - SpatialKey c = (SpatialKey) p.getKey(index); + Spatial c = p.getKey(index); if (filter == null || check(true, c, filter)) { currentKey = c; - currentVal = (V)p.getValue(index); + currentVal = p.getValue(index); return; } } @@ -106,10 +106,10 @@ protected void fetchNext() { boolean found = false; while (pos.index < p.getKeyCount()) { int index = pos.index++; - SpatialKey c = (SpatialKey) p.getKey(index); + Spatial c = p.getKey(index); if (filter == null || check(false, c, filter)) { - Page child = pos.page.getChildPage(index); - pos = new CursorPos(child, 0, pos); + Page child = pos.page.getChildPage(index); + pos = new CursorPos<>(child, 0, pos); found = true; break; } @@ -133,7 +133,7 @@ protected void fetchNext() { * @return true if there is a match */ @SuppressWarnings("unused") - protected boolean check(boolean leaf, SpatialKey key, SpatialKey test) { + protected boolean check(boolean leaf, Spatial key, Spatial test) { return true; } diff --git a/sensorhub-datastore-h2/src/main/java/org/h2/mvstore/RadixKeyBytesDataType.java b/sensorhub-datastore-h2/src/main/java/org/h2/mvstore/RadixKeyBytesDataType.java deleted file mode 100644 index 432950d74..000000000 --- a/sensorhub-datastore-h2/src/main/java/org/h2/mvstore/RadixKeyBytesDataType.java +++ /dev/null @@ -1,188 +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.nio.ByteBuffer; -import java.util.Iterator; -import org.h2.mvstore.MVRadixTreeMap.SearchContext; -import org.h2.mvstore.MVRadixTreeMap.SearchNode; -import com.google.common.primitives.UnsignedBytes; - - -/** - *

- * Base implementation for byte array prefix keys. - *

- * - * @author Alex Robin - * @date Oct 8, 2018 - */ -public abstract class RadixKeyBytesDataType implements RadixKeyDataType -{ - //Comparator comparator = UnsignedBytes.lexicographicalComparator(); - - - @Override - public int compare(Object key1, Object key2) - { - byte[] k1 = (byte[])key1; - byte[] k2 = (byte[])key2; - - int minLength = Math.min(k1.length, k2.length); - for (int i = 0; i < minLength; i++) - { - int result = UnsignedBytes.compare(k1[i], k2[i]); - if (result != 0) - return result; - } - - return k1.length - k2.length; - } - - - @Override - public void comparePrefix(Object fullKey, Object nodeKey, SearchContext context) - { - byte[] k = (byte[])fullKey; // full key to test - byte[] kp = (byte[])nodeKey; // key part contained in tree node - - // compare key with prefix lexicographically starting at offset - int i = context.offset, j = 0; - for (; j < kp.length && i < k.length && k[i] == kp[j]; i++, j++); - - if (k.length == 0) - context.compare = -1; - else if (kp.length == 0) - context.compare = 1; - else if (j == 0) - context.compare = k[i] < kp[0] ? -1 : 1; - else - context.compare = 0; - - context.matchLength = j; - context.keyRemaining = k.length - i; - context.fullNodeMatch = (j == kp.length); - } - - - @Override - public Object[] splitKey(Object nodeKey, int splitIndex) - { - byte[] key = (byte[])nodeKey; - if (splitIndex >= key.length) - throw new IllegalArgumentException("Split index out of bounds"); - - byte[] begin = new byte[splitIndex]; - System.arraycopy(key, 0, begin, 0, begin.length); - - byte[] end = new byte[key.length - splitIndex]; - System.arraycopy(key, splitIndex, end, 0, end.length); - - return new Object[] {begin, end}; - } - - - @Override - public Object tailKey(Object fullKey, int offset) - { - byte[] key = (byte[])fullKey; - if (key.length <= offset) - return null; - byte[] keyPart = new byte[key.length-offset]; - System.arraycopy(key, offset, keyPart, 0, keyPart.length); - return keyPart; - } - - - @Override - public int getPrefixLength(Object keyPrefix) - { - return ((byte[])keyPrefix).length; - } - - - @Override - public Object buildFullKey(SearchContext context) - { - int keySize = 0; - for (SearchNode node: context.nodeStack) - { - if (node.childIndex >= 0) - keySize += ((byte[])node.page.getKey(node.childIndex)).length; - } - - int destPos = 0; - byte[] fullKey = new byte[keySize]; - Iterator it = context.nodeStack.descendingIterator(); - while (it.hasNext()) - { - SearchNode node = it.next(); - if (node.childIndex >= 0) - { - byte[] prefixB = (byte[])node.page.getKey(node.childIndex); - System.arraycopy(prefixB, 0, fullKey, destPos, prefixB.length); - destPos += prefixB.length; - } - } - - return fullKey; - } - - - @Override - public int getMemory(Object obj) - { - return ((byte[])obj).length + 2; - } - - - @Override - public void write(WriteBuffer wbuf, Object obj) - { - byte[] key = (byte[])obj; - - // write array length, then byte[] - wbuf.putVarInt(key.length); - wbuf.put(key); - } - - - @Override - public void write(WriteBuffer wbuf, Object[] obj, int len, boolean key) - { - for (int i=0; i - * Base interface for Radix-tree Key DataTypes - *

- * - * @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/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(); } 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 9663a3ad1..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 @@ -14,14 +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.SpatialKey; +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; @@ -37,15 +44,37 @@ 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"; + 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(); public static final Range ALL_TIMES_RANGE = Range.closed(Instant.MIN, Instant.MAX); public static final TemporalFilter ALL_TIMES_FILTER = new TemporalFilter.Builder() .withRange(Instant.MIN, Instant.MAX) .build(); + private static final class SpatialFactory extends SpatialDataType + { + private SpatialFactory() + { + super(3); + } + + @Override + public Spatial create(long id, float... minMax) + { + return super.create(id, minMax); + } + } + + static class Holder { public T value; @@ -63,6 +92,106 @@ 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 DatabaseMigrationRequiredException("Unsupported H2 file format: " + storagePath + + " (written by an older H2 version). " + migrationHint(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 (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 + "). " + + 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; + } public static MVDataStoreInfo getDataStoreInfo(MVStore mvStore, String dataStoreName) @@ -219,7 +348,7 @@ else if (lower == null) } - public static SpatialKey getBoundingRectangle(long id, AbstractGeometry geom) + public static Spatial getBoundingRectangle(long id, AbstractGeometry geom) { float[] minMaxCoords = null; int numDims = -1; @@ -261,21 +390,21 @@ else if (geom instanceof Polygon) } if (minMaxCoords != null) - return new SpatialKey(id, minMaxCoords); + return SPATIAL_FACTORY.create(id, minMaxCoords); else return null; } - public static SpatialKey getBoundingRectangle(long id, Geometry geom) + public static Spatial getBoundingRectangle(long id, Geometry geom) { return getBoundingRectangle(id, geom.getEnvelopeInternal()); } - public static SpatialKey getBoundingRectangle(long id, Envelope env) + public static Spatial getBoundingRectangle(long id, Envelope env) { - return new SpatialKey(id, + return SPATIAL_FACTORY.create(id, Math.nextDown((float)env.getMinX()), Math.nextUp((float)env.getMaxX()), Math.nextDown((float)env.getMinY()), diff --git a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVBaseFeatureStoreImpl.java b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVBaseFeatureStoreImpl.java index 5313b9c7c..64db46e3c 100644 --- a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVBaseFeatureStoreImpl.java +++ b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVBaseFeatureStoreImpl.java @@ -33,7 +33,7 @@ import org.h2.mvstore.MVMap.Decision; import org.h2.mvstore.MVStore; import org.h2.mvstore.RangeCursor; -import org.h2.mvstore.rtree.SpatialKey; +import org.h2.mvstore.rtree.Spatial; import org.h2.mvstore.type.DataType; import org.sensorhub.api.common.BigId; import org.sensorhub.api.datastore.DataStoreException; @@ -178,7 +178,7 @@ protected MVBaseFeatureStoreImpl init(MVStore mvStore, int idScope, Id mapName = dataStoreInfo.getName() + ":" + FEATURE_SPATIAL_INDEX_MAP_NAME; this.spatialIndex = new SpatialIndex<>(mvStore, mapName, new MVFeatureParentKeyDataType(idScope, true)) { @Override - protected SpatialKey getSpatialKey(MVFeatureParentKey key, IFeature f) + protected Spatial getSpatialKey(MVFeatureParentKey key, IFeature f) { if (f.getGeometry() == null) return null; @@ -196,7 +196,7 @@ protected SpatialKey getSpatialKey(MVFeatureParentKey key, IFeature f) } - protected DataType getFeatureDataType(MVMap kryoClassMap) + protected DataType getFeatureDataType(MVMap kryoClassMap) { return new FeatureDataType(kryoClassMap); } diff --git a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVBaseResourceStoreImpl.java b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVBaseResourceStoreImpl.java index 1787e29fd..8e315dc7d 100644 --- a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVBaseResourceStoreImpl.java +++ b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVBaseResourceStoreImpl.java @@ -106,9 +106,9 @@ protected MVBaseResourceStoreImpl init(MVStore mvStore, int idScope } - protected abstract DataType getResourceKeyDataType(int idScope); + protected abstract DataType getResourceKeyDataType(int idScope); - protected abstract DataType getResourceDataType(MVMap kryoClassMap, int idScope); + protected abstract DataType getResourceDataType(MVMap kryoClassMap, int idScope); protected abstract K generateKey(V f); diff --git a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVCommandSeriesKeyByCommandStreamDataType.java b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVCommandSeriesKeyByCommandStreamDataType.java index 17900a935..0694cbbba 100644 --- a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVCommandSeriesKeyByCommandStreamDataType.java +++ b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVCommandSeriesKeyByCommandStreamDataType.java @@ -15,9 +15,10 @@ package org.sensorhub.impl.datastore.h2; import java.nio.ByteBuffer; +import org.h2.mvstore.ObjectArrayDataType; import org.h2.mvstore.DataUtils; import org.h2.mvstore.WriteBuffer; -import org.h2.mvstore.type.DataType; +import org.h2.mvstore.type.BasicDataType; /** @@ -29,7 +30,7 @@ * @author Alex Robin * @date Mar 25, 2021 */ -class MVCommandSeriesKeyByCommandStreamDataType implements DataType +class MVCommandSeriesKeyByCommandStreamDataType extends BasicDataType implements ObjectArrayDataType { private static final int MEM_SIZE = 8+8; // long ID, long receiver ID @@ -66,14 +67,6 @@ public void write(WriteBuffer wbuf, Object obj) } - @Override - public void write(WriteBuffer wbuf, Object[] obj, int len, boolean key) - { - for (int i=0; i implements ObjectArrayDataType { int idScope; @@ -88,14 +89,6 @@ public static void encode(WriteBuffer wbuf, MVCommandStatusKey key) } - @Override - public void write(WriteBuffer wbuf, Object[] obj, int len, boolean key) - { - for (int i=0; i implements ObjectArrayDataType { final int idScope; @@ -69,14 +70,6 @@ public void write(WriteBuffer wbuf, Object obj) } - @Override - public void write(WriteBuffer wbuf, Object[] obj, int len, boolean key) - { - for (int i=0; i implements ObjectArrayDataType { final int idScope; @@ -68,14 +69,6 @@ public void write(WriteBuffer wbuf, Object obj) } - @Override - public void write(WriteBuffer wbuf, Object[] obj, int len, boolean key) - { - for (int i=0; i kryoClassMap) + protected DataType getFeatureDataType(MVMap kryoClassMap) { return new SensorMLDataType(kryoClassMap); } 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..c372126dc 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 @@ -14,6 +14,8 @@ package org.sensorhub.impl.datastore.h2; +import java.nio.file.Paths; + import java.util.concurrent.Callable; import org.h2.mvstore.MVStore; import org.sensorhub.api.common.SensorHubException; @@ -21,6 +23,7 @@ import org.sensorhub.api.datastore.DataStoreException; import org.sensorhub.impl.module.AbstractModule; import org.sensorhub.utils.FileUtils; +import org.sensorhub.impl.datastore.h2.migrate.Migrator; /** @@ -34,7 +37,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; @@ -49,7 +52,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 +66,10 @@ protected void doStart() throws SensorHubException if (config.useCompression) builder.compress(); - mvStore = builder.open(); - mvStore.setAutoCommitDelay(config.autoCommitPeriod*1000); + if (config.autoMigrate && !config.readOnly) + Migrator.migrateIfNeeded(Paths.get(config.storagePath), config.backupOnMigrate, msg -> getLogger().info(msg)); + + mvStore = H2Utils.openMVStore(config.storagePath, builder, config.autoCommitPeriod*1000); mvStore.setVersionsToKeep(0); // open feature store @@ -73,6 +78,12 @@ protected void doStart() throws SensorHubException .withName(FEATURE_STORE_NAME) .build()); } + catch (DatabaseMigrationRequiredException e) + { + // one plain line for the operator, in addition to the module error state + getLogger().error("Cannot open database file: {}", e.getMessage()); + throw e; + } catch (Exception e) { throw new DataStoreException("Error while starting MVStore", e); diff --git a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVFeatureKeyDataType.java b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVFeatureKeyDataType.java index a2cf87573..20adbb700 100644 --- a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVFeatureKeyDataType.java +++ b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVFeatureKeyDataType.java @@ -17,9 +17,10 @@ import java.nio.ByteBuffer; import java.time.Instant; import java.util.Comparator; +import org.h2.mvstore.ObjectArrayDataType; import org.h2.mvstore.DataUtils; import org.h2.mvstore.WriteBuffer; -import org.h2.mvstore.type.DataType; +import org.h2.mvstore.type.BasicDataType; import org.sensorhub.api.datastore.feature.FeatureKey; @@ -31,7 +32,7 @@ * @author Alex Robin * @date Apr 7, 2018 */ -class MVFeatureKeyDataType implements DataType +class MVFeatureKeyDataType extends BasicDataType implements ObjectArrayDataType { Comparator timeCompare = Comparator.nullsFirst(Comparator.naturalOrder()); int idScope; @@ -77,14 +78,6 @@ public void write(WriteBuffer wbuf, Object obj) } - @Override - public void write(WriteBuffer wbuf, Object[] obj, int len, boolean key) - { - for (int i=0; i implements ObjectArrayDataType { final int idScope; final boolean storeValidTime; @@ -87,14 +88,6 @@ public void write(WriteBuffer wbuf, Object obj) } - @Override - public void write(WriteBuffer wbuf, Object[] obj, int len, boolean key) - { - for (int i=0; i implements ObjectArrayDataType { private static final int MIN_MEM_SIZE = 8+12+12; @@ -60,14 +61,6 @@ public void write(WriteBuffer wbuf, Object obj) } - @Override - public void write(WriteBuffer wbuf, Object[] obj, int len, boolean key) - { - for (int i=0; i implements ObjectArrayDataType { private static final int MEM_SIZE = 8+12+8; // long ID + instant + long ID @@ -73,14 +74,6 @@ public void write(WriteBuffer wbuf, Object obj) } - @Override - public void write(WriteBuffer wbuf, Object[] obj, int len, boolean key) - { - for (int i=0; i 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; @@ -82,7 +84,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 +104,10 @@ protected void doStart() throws SensorHubException if (config.useCompression) builder.compress(); - mvStore = builder.open(); - mvStore.setAutoCommitDelay(config.autoCommitPeriod*1000); + if (config.autoMigrate && !config.readOnly) + Migrator.migrateIfNeeded(Paths.get(config.storagePath), config.backupOnMigrate, msg -> getLogger().info(msg)); + + mvStore = H2Utils.openMVStore(config.storagePath, builder, config.autoCommitPeriod*1000); mvStore.setVersionsToKeep(0); var idScope = getDatabaseNum() != null ? getDatabaseNum() : 0; @@ -151,6 +155,12 @@ protected void doStart() throws SensorHubException obsStore.getDataStreams().linkTo(sysStore); cmdStore.getCommandStreams().linkTo(sysStore); } + catch (DatabaseMigrationRequiredException e) + { + // one plain line for the operator, in addition to the module error state + getLogger().error("Cannot open database file: {}", e.getMessage()); + throw e; + } catch (Exception e) { throw new DataStoreException("Error while starting MVStore", e); @@ -177,14 +187,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/main/java/org/sensorhub/impl/datastore/h2/MVProcedureStoreImpl.java b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVProcedureStoreImpl.java index af952e37e..9bf2cd352 100644 --- a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVProcedureStoreImpl.java +++ b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVProcedureStoreImpl.java @@ -83,7 +83,7 @@ public static MVProcedureStoreImpl open(MVStore mvStore, int idScope, IdProvider @Override - protected DataType getFeatureDataType(MVMap kryoClassMap) + protected DataType getFeatureDataType(MVMap kryoClassMap) { return new SensorMLDataType(kryoClassMap); } diff --git a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVPropertyKeyDataType.java b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVPropertyKeyDataType.java index 879b794a7..9a8fdc83c 100644 --- a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVPropertyKeyDataType.java +++ b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVPropertyKeyDataType.java @@ -15,9 +15,10 @@ package org.sensorhub.impl.datastore.h2; import java.nio.ByteBuffer; +import org.h2.mvstore.ObjectArrayDataType; import org.h2.mvstore.DataUtils; import org.h2.mvstore.WriteBuffer; -import org.h2.mvstore.type.DataType; +import org.h2.mvstore.type.BasicDataType; import org.sensorhub.api.common.BigId; import org.sensorhub.api.datastore.property.PropertyKey; @@ -30,7 +31,7 @@ * @author Alex Robin * @date Oct 8, 2023 */ -class MVPropertyKeyDataType implements DataType +class MVPropertyKeyDataType extends BasicDataType implements ObjectArrayDataType { final int idScope; @@ -69,14 +70,6 @@ public void write(WriteBuffer wbuf, Object obj) } - @Override - public void write(WriteBuffer wbuf, Object[] obj, int len, boolean key) - { - for (int i=0; i getResourceKeyDataType(int idScope) { return new MVPropertyKeyDataType(idScope); } @Override - protected DataType getResourceDataType(MVMap kryoClassMap, int idScope) + protected DataType getResourceDataType(MVMap kryoClassMap, int idScope) { return new PropertyDataType(kryoClassMap); } diff --git a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVSystemStoreImpl.java b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVSystemStoreImpl.java index 11d97f6e3..a3fea3b97 100644 --- a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVSystemStoreImpl.java +++ b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVSystemStoreImpl.java @@ -91,7 +91,7 @@ public static MVSystemStoreImpl open(MVStore mvStore, int idScope, IdProviderTyp @Override - protected DataType getFeatureDataType(MVMap kryoClassMap) + protected DataType getFeatureDataType(MVMap kryoClassMap) { return new SensorMLDataType(kryoClassMap); } diff --git a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVTimeSeriesInfoDataType.java b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVTimeSeriesInfoDataType.java index 89c0d9d5f..6e80af240 100644 --- a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVTimeSeriesInfoDataType.java +++ b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/MVTimeSeriesInfoDataType.java @@ -15,9 +15,10 @@ package org.sensorhub.impl.datastore.h2; import java.nio.ByteBuffer; +import org.h2.mvstore.ObjectArrayDataType; import org.h2.mvstore.DataUtils; import org.h2.mvstore.WriteBuffer; -import org.h2.mvstore.type.DataType; +import org.h2.mvstore.type.BasicDataType; /** @@ -28,7 +29,7 @@ * @author Alex Robin * @date Sep 13, 2019 */ -public class MVTimeSeriesInfoDataType implements DataType +public class MVTimeSeriesInfoDataType extends BasicDataType implements ObjectArrayDataType { private static final int MEM_SIZE = 8; // long ID @@ -56,14 +57,6 @@ public void write(WriteBuffer wbuf, Object obj) } - @Override - public void write(WriteBuffer wbuf, Object[] obj, int len, boolean key) - { - for (int i=0; i implements ObjectArrayDataType { final int idScope; @@ -88,14 +89,6 @@ public static void encode(WriteBuffer wbuf, MVTimeSeriesRecordKey key) } - @Override - public void write(WriteBuffer wbuf, Object[] obj, int len, boolean key) - { - for (int i=0; i implements ObjectArrayDataType { private static final int MIN_MEM_SIZE = 8+8+4; @@ -74,14 +75,6 @@ public void write(WriteBuffer wbuf, Object obj) } - @Override - public void write(WriteBuffer wbuf, Object[] obj, int len, boolean key) - { - for (int i=0; i implements ObjectArrayDataType { private final static Object VALUE = new Object(); @@ -53,26 +54,15 @@ public void write(WriteBuffer wbuf, Object obj) { } - - @Override - public void write(WriteBuffer wbuf, Object[] obj, int len, boolean key) - { - } @Override public Object read(ByteBuffer buf) { // always return a value even though nothing is actually written to file - // this is needed because MVMap.contains() method checks that value is not null + // this is needed because MVMap.containsKey() checks that value is not null return VALUE; } - - @Override - public void read(ByteBuffer buf, Object[] obj, int len, boolean key) - { - for (int i=0; i> { 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/main/java/org/sensorhub/impl/datastore/h2/index/SpatialIndex.java b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/index/SpatialIndex.java index 92e03a946..ff83dd4dd 100644 --- a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/index/SpatialIndex.java +++ b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/index/SpatialIndex.java @@ -22,7 +22,7 @@ import org.h2.mvstore.Page; import org.h2.mvstore.RTreeEntryCursor; import org.h2.mvstore.rtree.MVRTreeMap; -import org.h2.mvstore.rtree.SpatialKey; +import org.h2.mvstore.rtree.Spatial; import org.h2.mvstore.rtree.SpatialDataType; import org.h2.mvstore.type.DataType; import org.sensorhub.api.datastore.SpatialFilter; @@ -46,10 +46,10 @@ public abstract class SpatialIndex> protected MVRTreeMap rTreeMap; - protected abstract SpatialKey getSpatialKey(K key, T resource); + protected abstract Spatial getSpatialKey(K key, T resource); - public SpatialIndex(MVStore mvStore, String mapName, DataType valueType) + public SpatialIndex(MVStore mvStore, String mapName, DataType valueType) { this.rTreeMap = mvStore.openMap(mapName, new MVRTreeMap.Builder() .dimensions(3) @@ -59,7 +59,7 @@ public SpatialIndex(MVStore mvStore, String mapName, DataType valueType) public void add(K key, T res) { - SpatialKey spatialKey = getSpatialKey(key, res); + Spatial spatialKey = getSpatialKey(key, res); if (spatialKey != null) rTreeMap.put(spatialKey, key); } @@ -74,7 +74,7 @@ public void update(K key, T old, T new_) public void remove(K key, T res) { - SpatialKey spatialKey = getSpatialKey(key, res); + Spatial spatialKey = getSpatialKey(key, res); if (spatialKey != null) rTreeMap.remove(spatialKey); } @@ -82,7 +82,7 @@ public void remove(K key, T res) public Stream selectKeys(SpatialFilter filter) { - SpatialKey bbox = H2Utils.getBoundingRectangle(0, filter.getRoi()); + Spatial bbox = H2Utils.getBoundingRectangle(0, filter.getRoi()); /*final RTreeCursor geoCursor = rTreeMap.findIntersectingKeys(bbox); return StreamSupport.stream(Spliterators.spliteratorUnknownSize(geoCursor, Spliterator.DISTINCT), true) @@ -91,7 +91,7 @@ public Stream selectKeys(SpatialFilter filter) var rTreeKeyType = ((SpatialDataType)rTreeMap.getKeyType()); var geoCursor = new RTreeEntryCursor(rTreeMap.getRoot().root, bbox) { @Override - protected boolean check(boolean leaf, SpatialKey key, SpatialKey test) + protected boolean check(boolean leaf, Spatial key, Spatial test) { return rTreeKeyType.isOverlap(key, test); } @@ -109,10 +109,10 @@ public Bbox getFullExtent() { Bbox extent = new Bbox(); - Page root = rTreeMap.getRoot().root; + Page root = rTreeMap.getRoot().root; for (int i = 0; i < root.getKeyCount(); i++) { - SpatialKey key = (SpatialKey)root.getKey(i); + Spatial key = root.getKey(i); extent.add(new Bbox(key.min(0), key.min(1), key.min(2), key.max(0), key.max(1), key.max(2))); } diff --git a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/kryo/KryoDataType.java b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/kryo/KryoDataType.java index 44ebbfe40..0cae906ba 100644 --- a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/kryo/KryoDataType.java +++ b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/kryo/KryoDataType.java @@ -19,8 +19,9 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; import java.util.function.Supplier; +import org.h2.mvstore.ObjectArrayDataType; import org.h2.mvstore.WriteBuffer; -import org.h2.mvstore.type.DataType; +import org.h2.mvstore.type.BasicDataType; import org.objenesis.strategy.StdInstantiatorStrategy; import org.sensorhub.impl.datastore.h2.H2Utils; import org.sensorhub.impl.serialization.kryo.VersionedSerializer; @@ -36,7 +37,7 @@ import com.google.common.collect.ImmutableMap; -public class KryoDataType implements DataType +public class KryoDataType extends BasicDataType implements ObjectArrayDataType { static Logger log = LoggerFactory.getLogger(KryoDataType.class); @@ -176,8 +177,9 @@ public Object read(ByteBuffer buff) @Override - public void read(ByteBuffer buff, Object[] obj, int len, boolean key) + public void read(ByteBuffer buff, Object storage, int len) { + Object[] obj = cast(storage); KryoInstance kryoI = getReadKryo(); for (int i = 0; i < len; i++) obj[i] = read(buff, kryoI); @@ -190,9 +192,11 @@ protected Object read(ByteBuffer buff, KryoInstance kryoI) Kryo kryo = kryoI.kryo; Input input = kryoI.input; - input.setBuffer(buff.array(), buff.position(), buff.remaining()); + // H2 2.x passes slices of an unsaved chunk's buffer, so the page may not start at array index 0 + int offset = buff.arrayOffset(); + input.setBuffer(buff.array(), offset + buff.position(), buff.remaining()); Object obj = kryo.readClassAndObject(input); - buff.position(input.position()); + buff.position(input.position() - offset); return obj; } @@ -207,8 +211,9 @@ public void write(WriteBuffer buff, Object obj) @Override - public void write(WriteBuffer buff, Object[] objects, int len, boolean key) + public void write(WriteBuffer buff, Object storage, int len) { + Object[] objects = cast(storage); for (int i = 0; i < len; i++) { var obj = objects[i]; 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. + *
+ * h2migrate <db-file> [--type obs|feature] [--backup-dir DIR] [--legacy-libs DIR]
+ *                     [--keep-interim] [--dry-run] [--quiet]
+ * 
+ */ +public final class H2Migrate +{ + static final String USAGE = String.join("\n", + "Usage: h2migrate [options]", + "", + "Migrates an OpenSensorHub H2 MVStore database written by H2 1.4.200 (file format 1),", + "or an unmarked H2 2.x file, to the H2 2.5.250 format with a version marker.", + "", + "Steps: preflight, optional backup copy, interim copy, byte comparison, decode check, then the", + "original is renamed to .old and the migrated file takes its place.", + "", + "Options:", + " --type obs|feature Database layout (default: detected from map names)", + " --backup Also keep a verified backup copy (.h2v1-backup-);", + " the original is kept unmodified as .old either way", + " --backup-dir DIR Where to write that copy (default: next to the database)", + " --legacy-libs DIR Directory holding h2-mvstore-1.4.200.jar and the legacy adapters", + " (default: -Dh2migrate.legacyLibs or /legacy-libs)", + " --keep-interim Keep the .migrating file if migration fails", + " --dry-run Do everything except retiring the original and moving the migrated file in", + " --quiet Only print the final report or the error", + " -h, --help Show this help", + "", + "Exit codes: 0 success, 1 migration failed (original untouched), 2 usage error"); + + + private H2Migrate() + { + } + + + public static void main(String[] args) + { + System.exit(run(args)); + } + + + static int run(String[] args) + { + Migrator.Options opts = new Migrator.Options(); + boolean quiet = false; + try + { + for (int i = 0; i < args.length; i++) + { + String a = args[i]; + switch (a) + { + case "-h", "--help" -> { + System.out.println(USAGE); + return 0; + } + case "--type" -> opts.type = parseType(next(args, ++i, a)); + case "--backup" -> opts.backup = true; + case "--backup-dir" -> opts.backupDir = Paths.get(next(args, ++i, a)); + case "--legacy-libs" -> opts.legacyLibs = Paths.get(next(args, ++i, a)); + case "--keep-interim" -> opts.keepInterim = true; + case "--dry-run" -> opts.dryRun = true; + case "--quiet" -> quiet = true; + default -> { + if (a.startsWith("-") || opts.dbFile != null) + throw new IllegalArgumentException("Unexpected argument: " + a); + opts.dbFile = Paths.get(a); + } + } + } + if (opts.dbFile == null) + throw new IllegalArgumentException("Missing "); + } + catch (IllegalArgumentException e) + { + System.err.println("Error: " + e.getMessage()); + System.err.println(); + System.err.println(USAGE); + return 2; + } + + if (!quiet) + opts.log = System.out::println; + + try + { + Migrator.Report report = Migrator.run(opts); + System.out.println(); + System.out.print(Migrator.describe(report)); + return 0; + } + catch (MigrationException e) + { + System.err.println("Migration failed: " + e.getMessage()); + if (e.getCause() != null) + System.err.println("Cause: " + e.getCause()); + System.err.println("The original database file was not modified."); + return 1; + } + catch (Exception e) + { + System.err.println("Migration failed with an unexpected error. The original database file was not modified."); + e.printStackTrace(System.err); + return 1; + } + } + + + private static String next(String[] args, int i, String option) + { + if (i >= args.length) + throw new IllegalArgumentException("Option " + option + " requires a value"); + return args[i]; + } + + + private static StoreLayout parseType(String s) + { + switch (s.toLowerCase(Locale.ROOT)) + { + case "obs", "obs_system", "obs-system": + return StoreLayout.OBS_SYSTEM; + case "feature", "features": + return StoreLayout.FEATURE; + default: + throw new IllegalArgumentException("Unknown database type: " + s + " (expected obs or feature)"); + } + } +} diff --git a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/LegacyLoader.java b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/LegacyLoader.java new file mode 100644 index 000000000..3baeb982c --- /dev/null +++ b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/LegacyLoader.java @@ -0,0 +1,242 @@ +/***************************** 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.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import org.sensorhub.impl.datastore.h2.migrate.spi.LegacyStore; + + +/** + * Child-first classloader that isolates the H2 1.4 jar and the legacy + * adapters from the H2 2.5 classes on the tool's own classpath. Everything + * else, including the spi interfaces and the OSH datatypes, is delegated to + * the parent so objects can be shared across the boundary. + */ +public final class LegacyLoader extends URLClassLoader +{ + public static final String LIBS_PROPERTY = "h2migrate.legacyLibs"; + /** Resource directory holding the embedded H2 1.4 jar and adapters, listed in index.txt */ + static final String EMBEDDED_DIR = "META-INF/h2migrate/"; + static final String LEGACY_PACKAGE = "org.sensorhub.impl.datastore.h2.migrate.legacy."; + static final String H2_PACKAGE = "org.h2."; + + + private LegacyLoader(URL[] urls, ClassLoader parent) + { + super("h2migrate-legacy", urls, parent); + } + + + /** Directory name looked up next to the directory holding this module's jar */ + public static final String LIBS_DIR_NAME = "legacy-libs"; + + + /** + * Creates the loader from the default location, in this order: an explicit + * directory, the {@value #LIBS_PROPERTY} system property, a {@value #LIBS_DIR_NAME} + * directory next to the directory holding this module's jar (a node's lib/), + * then the jars embedded in this module under {@value #EMBEDDED_DIR} when the + * module was built with them. + */ + public static LegacyLoader createDefault(Path explicit) throws MigrationException + { + return create(resolveLibsDir(explicit)); + } + + + public static Path resolveLibsDir(Path explicit) throws MigrationException + { + List candidates = new ArrayList<>(); + if (explicit != null) + candidates.add(explicit); + String prop = System.getProperty(LIBS_PROPERTY); + if (prop != null && !prop.isBlank()) + candidates.add(Paths.get(prop)); + Path installed = installedLibsDir(); + if (installed != null) + candidates.add(installed); + + for (Path dir: candidates) + { + if (Files.isDirectory(dir) && hasLegacyH2Jar(dir)) + return dir; + } + if (explicit != null || (prop != null && !prop.isBlank())) + throw new MigrationException("No h2-mvstore-1.4.x.jar found in " + candidates); + if (hasEmbedded()) + return extractEmbedded(); + throw new MigrationException("The H2 1.4 migration libraries were not found. Looked in " + candidates + + " and this build of the datastore module does not embed them. Put h2-mvstore-1.4.200.jar and " + + "the sensorhub-datastore-h2 h2migrate-legacy jar in a '" + LIBS_DIR_NAME + "' directory next to the " + + "node's lib/ directory (a source checkout builds it with ./gradlew :sensorhub-datastore-h2:h2migrateLegacyLibs), " + + "or pass --legacy-libs / -D" + LIBS_PROPERTY); + } + + + private static Path installedLibsDir() + { + try + { + var src = LegacyLoader.class.getProtectionDomain().getCodeSource(); + if (src == null) + return null; + Path location = Paths.get(src.getLocation().toURI()); + Path libDir = Files.isDirectory(location) ? location : location.getParent(); + return libDir == null || libDir.getParent() == null ? null : libDir.getParent().resolve(LIBS_DIR_NAME); + } + catch (Exception e) + { + return null; + } + } + + + static boolean hasEmbedded() + { + return LegacyLoader.class.getClassLoader().getResource(EMBEDDED_DIR + "index.txt") != null; + } + + + /** + * Copies the embedded legacy jars to a temporary directory that is removed on exit. + */ + static Path extractEmbedded() throws MigrationException + { + ClassLoader cl = LegacyLoader.class.getClassLoader(); + try (var index = cl.getResourceAsStream(EMBEDDED_DIR + "index.txt")) + { + if (index == null) + throw new MigrationException("This build does not embed the H2 1.4 migration libraries (" + + EMBEDDED_DIR + "index.txt missing); use --legacy-libs or -D" + LIBS_PROPERTY); + Path dir = Files.createTempDirectory("h2migrate-legacy-"); + dir.toFile().deleteOnExit(); + for (String name: new String(index.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8).split("\\R")) + { + if (name.isBlank()) + continue; + try (var in = cl.getResourceAsStream(EMBEDDED_DIR + name)) + { + if (in == null) + throw new MigrationException("Embedded legacy library missing: " + EMBEDDED_DIR + name); + Path target = dir.resolve(name); + Files.copy(in, target); + target.toFile().deleteOnExit(); + } + } + if (!hasLegacyH2Jar(dir)) + throw new MigrationException("Embedded legacy libraries do not include h2-mvstore-1.4.x.jar"); + return dir; + } + catch (IOException e) + { + throw new MigrationException("Cannot extract embedded legacy libraries", e); + } + } + + + private static boolean hasLegacyH2Jar(Path dir) + { + try (Stream files = Files.list(dir)) + { + return files.anyMatch(f -> f.getFileName().toString().matches("h2-mvstore-1\\.4\\.\\d+\\.jar")); + } + catch (IOException e) + { + return false; + } + } + + + public static LegacyLoader create(Path libsDir) throws MigrationException + { + try (Stream files = Files.list(libsDir)) + { + List urls = new ArrayList<>(); + for (Path f: files.sorted().toList()) + { + if (f.getFileName().toString().endsWith(".jar")) + urls.add(f.toUri().toURL()); + } + if (urls.isEmpty()) + throw new MigrationException("No jars found in " + libsDir); + return new LegacyLoader(urls.toArray(new URL[0]), LegacyLoader.class.getClassLoader()); + } + catch (IOException e) + { + throw new MigrationException("Cannot list legacy libs in " + libsDir, e); + } + } + + + public LegacyStore.Factory factory() throws MigrationException + { + try + { + Class impl = Class.forName(LegacyStore.Factory.IMPL_CLASS, true, this); + return (LegacyStore.Factory)impl.getDeclaredConstructor().newInstance(); + } + catch (ReflectiveOperationException | ClassCastException e) + { + throw new MigrationException("Cannot load legacy adapters from " + List.of(getURLs()), e); + } + } + + + static boolean isIsolated(String className) + { + return className.startsWith(H2_PACKAGE) || className.startsWith(LEGACY_PACKAGE); + } + + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException + { + synchronized (getClassLoadingLock(name)) + { + Class c = findLoadedClass(name); + if (c == null) + { + if (!isIsolated(name)) + return super.loadClass(name, resolve); + c = findClass(name); // never fall back to the parent's H2 2.5 classes + } + if (resolve) + resolveClass(c); + return c; + } + } + + + @Override + public URL getResource(String name) + { + // resources under org/h2 must also come from the 1.4 jar + if (name.startsWith("org/h2/")) + { + URL url = findResource(name); + if (url != null) + return url; + } + return super.getResource(name); + } +} diff --git a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/MigrationException.java b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/MigrationException.java new file mode 100644 index 000000000..b14d2ef96 --- /dev/null +++ b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/MigrationException.java @@ -0,0 +1,37 @@ +/***************************** 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; + + +/** + * Raised when the migration cannot proceed or a verification step fails. + * The original database file is untouched whenever this is thrown. + */ +public class MigrationException extends Exception +{ + private static final long serialVersionUID = 1L; + + + public MigrationException(String message) + { + super(message); + } + + + public MigrationException(String message, Throwable cause) + { + super(message, cause); + } +} diff --git a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/Migrator.java b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/Migrator.java new file mode 100644 index 000000000..f3649ba67 --- /dev/null +++ b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/Migrator.java @@ -0,0 +1,448 @@ +/***************************** 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 org.sensorhub.impl.datastore.h2.MVObsSystemDatabase; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.Consumer; +import org.h2.mvstore.MVBTreeMap; +import org.h2.mvstore.MVMap; +import org.h2.mvstore.MVStore; +import org.h2.mvstore.rtree.MVRTreeMap; +import org.h2.mvstore.rtree.Spatial; +import org.h2.mvstore.rtree.SpatialDataType; +import org.sensorhub.impl.datastore.h2.H2Utils; + + +/** + * The migration pipeline: preflight, verified backup, interim copy, + * byte-level and semantic verification, instatement. + * The original file is never written to: it is only read, copied for the + * backup, and finally renamed to .old when the migrated file takes its + * path. Neither the backup nor the retired original is ever deleted here. + */ +public final class Migrator +{ + /** Suffix under which the untouched original is kept after instatement */ + public static final String RETIRED_SUFFIX = ".old"; + + /** Commit the interim store whenever this much unsaved memory accumulates */ + private static final int COMMIT_THRESHOLD_BYTES = 32 * 1024 * 1024; + + private static final Set NEVER_COPIED = Set.of(H2Utils.DATABASE_MAP_NAME); + + + public static final class Options + { + public Path dbFile; + public StoreLayout type; // null = detect from map names + public boolean backup; // also keep a verified copy of the original (it survives as .old anyway) + public Path backupDir; // null = next to the database file + public Path legacyLibs; // null = system property, then the jars embedded in this module + public boolean keepInterim; + public boolean dryRun; + public Consumer log = s -> {}; + } + + + public static final class Report + { + public Path dbFile; + public Path backup; + public Path interim; + /** the untouched original after instatement (.old), null for a dry run */ + public Path retiredOriginal; + public int sourceFormat; + public String sourceDescription; + public StoreLayout type; + public Map copied = new LinkedHashMap<>(); + /** byte-identical duplicate keys found in the source and stored once, per map */ + public Map duplicatesCollapsed = new LinkedHashMap<>(); + public boolean instated; + public Duration elapsed; + } + + + private static final class SpatialFactory extends SpatialDataType + { + SpatialFactory(int dimensions) + { + super(dimensions); + } + + @Override + public Spatial create(long id, float... minMax) + { + return super.create(id, minMax); + } + } + + + private Migrator() + { + } + + + public static Report run(Options options) throws Exception + { + Instant t0 = Instant.now(); + Consumer log = options.log; + Path dbFile = options.dbFile.toAbsolutePath(); + Report report = new Report(); + report.dbFile = dbFile; + + // 1. preflight + if (!Files.isRegularFile(dbFile) || Files.size(dbFile) == 0) + throw new MigrationException("Database file is missing or empty: " + dbFile); + FileOps.checkNotLocked(dbFile); + int format = FileOps.readH2Format(dbFile); + report.sourceFormat = format; + log.accept("Source file: " + dbFile + " (H2 file format " + format + ")"); + + Path interim = dbFile.resolveSibling(dbFile.getFileName() + ".migrating"); + Path scratch = dbFile.resolveSibling(dbFile.getFileName() + ".migrating-types"); + if (Files.exists(interim)) + throw new MigrationException("Interim file already exists, remove it first: " + interim); + report.interim = interim; + Path retired = dbFile.resolveSibling(dbFile.getFileName() + RETIRED_SUFFIX); + if (!options.dryRun && Files.exists(retired)) + throw new MigrationException("A retired original already exists, move it away first: " + retired); + + LegacyLoader loader = null; + if (format == 1) + { + Path libs = LegacyLoader.resolveLibsDir(options.legacyLibs); + log.accept("Legacy H2 libraries: " + libs); + loader = LegacyLoader.create(libs); + } + + boolean success = false; + try (SourceReader source = openSource(format, loader, dbFile)) + { + report.sourceDescription = source.describe(); + log.accept("Reading with " + source.describe()); + + if (source instanceof SourceReader.Native native_) + { + String marker = native_.getVersionMarker(); + if (marker != null) + throw new MigrationException("File already carries version marker " + marker + + "; nothing to migrate: " + dbFile); + } + + StoreLayout type = options.type != null ? options.type : StoreLayout.detect(source.getMapNames()); + report.type = type; + log.accept("Database type: " + type); + + // 2. harvest datatypes on a scratch store seeded with the class map, and make sure + // the layout knows every source map before anything is written next to the original + try (StoreLayout.Types types = StoreLayout.Types.harvest(source, type, scratch)) + { + List unknown = new ArrayList<>(); + for (String name: source.getMapNames()) + { + if (!NEVER_COPIED.contains(name) && types.get(name) == null) + unknown.add(name); + } + if (!unknown.isEmpty()) + throw new MigrationException("Source contains maps unknown to the " + type + " layout: " + unknown + + ". This tool cannot migrate them; a layout extension is required."); + + // 3. optional backup copy; the original itself is retired as .old at the end + if (options.backup) + { + report.backup = FileOps.backup(dbFile, options.backupDir, format == 1 ? "h2v1" : "h2v2"); + log.accept("Backup verified: " + report.backup); + } + else + log.accept("No backup copy requested; the original will be kept unmodified as " + retired); + + // 4. copy every map into the interim file, class map first + MVStore target = H2Utils.openMVStore(interim.toString(), new MVStore.Builder(), 0); + try + { + for (String name: copyOrder(source.getMapNames())) + { + var result = copyMap(source, target, types.get(name), log); + report.copied.put(name, result.stored); + if (result.duplicates > 0) + report.duplicatesCollapsed.put(name, result.duplicates); + target.commit(); + } + target.commit(); + } + finally + { + target.close(); + } + log.accept("Interim file written: " + interim); + + // 7. pass one: compare bytes + log.accept("Verifying bytes against source..."); + try (SourceReader migrated = new SourceReader.Native(interim, 3)) + { + Verifier.compareBytes(source, migrated, types.getSpecs(), NEVER_COPIED, log); + } + } + + // 8. pass two: decode through the store implementations + log.accept("Verifying decoding through store implementations..."); + Map decoded = Verifier.decodeAll(interim, type, log); + for (var e: report.copied.entrySet()) + { + Long n = decoded.get(e.getKey()); + if (n == null) + throw new MigrationException("Map " + e.getKey() + " was not opened by the store implementations"); + // the class map may legitimately grow while the stores read pages, and opening the stores + // registers every store the file predates in @datastores; data maps may not change + boolean mayGrow = e.getKey().equals(MVObsSystemDatabase.KRYO_CLASS_MAP_NAME) || e.getKey().equals(H2Utils.DATASTORES_MAP_NAME); + boolean ok = mayGrow ? n >= e.getValue() : n.equals(e.getValue()); + if (!ok) + throw new MigrationException("Decoded count for " + e.getKey() + " is " + n + ", expected " + e.getValue()); + } + + // 9. instate + if (options.dryRun) + { + log.accept("Dry run: original left in place, interim kept at " + interim); + } + else + { + FileOps.instate(interim, dbFile, retired); + report.instated = true; + report.retiredOriginal = retired; + log.accept("Original kept unmodified as " + retired); + log.accept("Migrated database instated at " + dbFile); + } + success = true; + } + catch (DuplicateConflictException e) + { + throw new MigrationException(e.getMessage(), e); + } + finally + { + if (loader != null) + loader.close(); + FileOps.deleteQuietly(scratch); + if (!success && !options.keepInterim) + FileOps.deleteQuietly(interim); + if (success && report.instated) + FileOps.deleteQuietly(interim); // already moved; no-op unless move left a copy + } + + report.elapsed = Duration.between(t0, Instant.now()); + return report; + } + + + /** + * Tells whether a database file must be migrated before the datastore can open it: + * an H2 1.4 (format 1) file, or an H2 2.x file without the version marker. + * Missing or empty files are new databases and return false. + */ + public static boolean needsMigration(Path dbFile) throws Exception + { + if (!Files.isRegularFile(dbFile) || Files.size(dbFile) == 0) + return false; + int format = FileOps.readH2Format(dbFile); + if (format == 1) + return true; + try (SourceReader.Native reader = new SourceReader.Native(dbFile, format)) + { + return reader.getVersionMarker() == null; + } + } + + + /** + * Startup hook for the database modules: migrates the file in place (with + * verification; the original is kept as .old) when it needs it, + * otherwise does nothing. + * @param backup also keep a verified backup copy of the original + * @return the report, or null if no migration was needed + */ + public static Report migrateIfNeeded(Path dbFile, boolean backup, Consumer log) throws Exception + { + if (!needsMigration(dbFile)) + return null; + log.accept("Database file " + dbFile + " uses a legacy H2 format; migrating it (autoMigrate is enabled)"); + Options options = new Options(); + options.dbFile = dbFile; + options.backup = backup; + options.log = log; + Report report = run(options); + log.accept(describe(report)); + return report; + } + + + private static SourceReader openSource(int format, LegacyLoader loader, Path dbFile) throws Exception + { + if (format == 1) + return new SourceReader.Legacy(loader.factory(), dbFile); + return new SourceReader.Native(dbFile, format); + } + + + /** Class map first, datastore registry second, then everything else sorted */ + static List copyOrder(Set names) + { + List order = new ArrayList<>(); + if (names.contains(MVObsSystemDatabase.KRYO_CLASS_MAP_NAME)) + order.add(MVObsSystemDatabase.KRYO_CLASS_MAP_NAME); + if (names.contains(H2Utils.DATASTORES_MAP_NAME)) + order.add(H2Utils.DATASTORES_MAP_NAME); + for (String n: new TreeSet<>(names)) + { + if (!order.contains(n) && !NEVER_COPIED.contains(n)) + order.add(n); + } + return order; + } + + + record CopyResult(long stored, long duplicates) + { + } + + + private static CopyResult copyMap(SourceReader source, MVStore target, StoreLayout.MapSpec spec, Consumer log) throws Exception + { + long n; + long duplicates = 0; + if (spec.rtree()) + { + if (!source.isRTree(spec.name())) + log.accept(" note: " + spec.name() + " has no R-tree marker in the source metadata " + + "(dropped by H2 1.4 compaction); it is read as an R-tree and the marker is restored"); + var factory = new SpatialFactory(spec.dimensions()); + MVRTreeMap map = target.openMap(spec.name(), new MVRTreeMap.Builder() + .dimensions(spec.dimensions()) + .valueType(new RawDataType(spec.valueType()))); + n = source.readRTreeMap(spec.name(), spec.dimensions(), spec.valueType(), (id, minMax, v, dv) -> { + map.put(factory.create(id, minMax), new RawDataType.Entry(v)); + commitIfNeeded(target); + }); + } + else + { + MVMap map = target.openMap(spec.name(), new MVBTreeMap.Builder() + .keyType(new RawDataType(spec.keyType())) + .valueType(new RawDataType(spec.valueType()))); + var dedupe = new DuplicateFilter(spec.name()); + source.readMap(spec.name(), spec.keyType(), spec.valueType(), (k, dk, v, dv) -> { + if (dedupe.isDuplicate(k, v)) + return; + map.put(new RawDataType.Entry(k, dk), new RawDataType.Entry(v)); + commitIfNeeded(target); + }); + n = dedupe.kept; + duplicates = dedupe.duplicates; + } + log.accept(" copied " + spec.name() + ": " + n + " entries" + (spec.rtree() ? " (rtree)" : "") + + (duplicates > 0 ? ", collapsed " + duplicates + " byte-identical duplicate key(s)" : "")); + return new CopyResult(n, duplicates); + } + + + private static void commitIfNeeded(MVStore target) + { + if (target.getUnsavedMemory() > COMMIT_THRESHOLD_BYTES) + target.commit(); + } + + + /** + * Legacy B-trees can hold consecutive entries whose key bytes are identical. + * The datastore compares some keys on fields it never persists (for example the + * full-text index compares feature valid time but stores only the feature id), + * so two versions of one feature produced two on-disk entries that decode to the + * same key. They collapse to one entry here; identical keys with different values + * are refused because that would silently drop data. + */ + /** Unchecked so it can cross the sink callbacks; converted to MigrationException in run() */ + static final class DuplicateConflictException extends RuntimeException + { + private static final long serialVersionUID = 1L; + + DuplicateConflictException(String message) + { + super(message); + } + } + + + static final class DuplicateFilter + { + private final String mapName; + private byte[] lastKey; + private byte[] lastValue; + long kept; + long duplicates; + + DuplicateFilter(String mapName) + { + this.mapName = mapName; + } + + boolean isDuplicate(byte[] key, byte[] value) + { + if (lastKey != null && java.util.Arrays.equals(lastKey, key)) + { + if (!java.util.Arrays.equals(lastValue, value)) + throw new DuplicateConflictException("Map " + mapName + " holds byte-identical duplicate keys with " + + "different values; refusing to choose one (key " + java.util.HexFormat.of().formatHex(key) + ")"); + duplicates++; + return true; + } + lastKey = key; + lastValue = value; + kept++; + return false; + } + } + + + static String describe(Report r) + { + StringBuilder sb = new StringBuilder(); + sb.append("Database: ").append(r.dbFile).append('\n'); + sb.append("Source: ").append(r.sourceDescription).append('\n'); + sb.append("Type: ").append(r.type).append('\n'); + sb.append("Backup: ").append(r.backup != null ? r.backup : "none requested").append('\n'); + sb.append("Result: ").append(r.instated ? "instated at " + r.dbFile : "dry run, interim at " + r.interim).append('\n'); + if (r.retiredOriginal != null) + sb.append("Original: kept unmodified as ").append(r.retiredOriginal).append('\n'); + sb.append("Elapsed: ").append(r.elapsed).append('\n'); + sb.append("Maps:\n"); + for (var e: r.copied.entrySet()) + { + Long dup = r.duplicatesCollapsed.get(e.getKey()); + sb.append(String.format(" %-40s %10d%s%n", e.getKey(), e.getValue(), + dup == null ? "" : " (+" + dup + " byte-identical duplicate key(s) collapsed)")); + } + return sb.toString(); + } +} diff --git a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/RawDataType.java b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/RawDataType.java new file mode 100644 index 000000000..d204ca555 --- /dev/null +++ b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/RawDataType.java @@ -0,0 +1,126 @@ +/***************************** 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.ByteBuffer; +import org.h2.mvstore.WriteBuffer; +import org.h2.mvstore.type.BasicDataType; +import org.h2.mvstore.type.DataType; + + +/** + * H2 2.5 datatype that moves entries as exact bytes. Reads delegate to the + * real OSH datatype to find the entry boundary and keep the decoded object; + * writes emit the recorded bytes verbatim; key comparison decodes lazily and + * uses the real datatype's ordering, so the resulting B-tree is identical to + * one built through the store implementations. + */ +public final class RawDataType extends BasicDataType +{ + public static final class Entry + { + private final byte[] bytes; + private Object decoded; + private boolean isDecoded; + + + public Entry(byte[] bytes) + { + this.bytes = bytes; + } + + + public Entry(byte[] bytes, Object decoded) + { + this.bytes = bytes; + this.decoded = decoded; + this.isDecoded = true; + } + + + public byte[] bytes() + { + return bytes; + } + + + public Object decoded(DataType real) + { + if (!isDecoded) + { + decoded = real.read(ByteBuffer.wrap(bytes)); + isDecoded = true; + } + return decoded; + } + } + + + private final DataType real; + + + @SuppressWarnings("unchecked") + public RawDataType(DataType real) + { + this.real = (DataType)real; + } + + + public DataType getRealType() + { + return real; + } + + + @Override + public int compare(Object a, Object b) + { + return real.compare(((Entry)a).decoded(real), ((Entry)b).decoded(real)); + } + + + @Override + public int getMemory(Object obj) + { + return ((Entry)obj).bytes.length + 32; + } + + + @Override + public void write(WriteBuffer buff, Object obj) + { + buff.put(((Entry)obj).bytes); + } + + + @Override + public Object read(ByteBuffer buff) + { + int start = buff.position(); + Object decoded = real.read(buff); + int end = buff.position(); + byte[] bytes = new byte[end - start]; + buff.position(start); + buff.get(bytes); + return new Entry(bytes, decoded); + } + + + @Override + public Object[] createStorage(int size) + { + return new Object[size]; + } +} diff --git a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/SourceReader.java b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/SourceReader.java new file mode 100644 index 000000000..0fee49421 --- /dev/null +++ b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/SourceReader.java @@ -0,0 +1,269 @@ +/***************************** 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.Path; +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.rtree.MVRTreeMap; +import org.h2.mvstore.rtree.Spatial; +import org.h2.mvstore.type.DataType; +import org.h2.mvstore.type.StringDataType; +import org.sensorhub.impl.datastore.h2.H2Utils; +import org.sensorhub.impl.datastore.h2.migrate.spi.LegacyStore; + + +/** + * Uniform raw access to a database file regardless of the H2 version that + * wrote it. Entries are delivered as the exact bytes stored in the file plus + * the objects decoded with the supplied real datatypes. + */ +public interface SourceReader extends AutoCloseable +{ + /** @return human readable description, e.g. "H2 1.4.200 file (format 1)" */ + String describe(); + + Set getMapNames(); + + boolean isRTree(String mapName); + + long readMap(String mapName, DataType keyType, DataType valueType, LegacyStore.RawEntrySink sink) throws Exception; + + long readRTreeMap(String mapName, int dimensions, DataType valueType, LegacyStore.SpatialEntrySink sink) throws Exception; + + @Override + void close(); + + + /** + * Reads an H2 1.4 file through the isolated legacy classloader. The real + * H2 2.5 datatypes do the decoding; only byte buffers and JDK objects cross + * the classloader boundary. + */ + public static final class Legacy implements SourceReader + { + private final LegacyStore store; + + + public Legacy(LegacyStore.Factory factory, Path file) throws MigrationException + { + this.store = factory.openReadOnly(file.toString()); + String version = store.getH2Version(); + if (!version.startsWith("1.")) + { + store.close(); + throw new MigrationException("Legacy classloader loaded H2 " + version + " instead of a 1.x release"); + } + } + + + @Override + public String describe() + { + return "H2 " + store.getH2Version() + " file (format 1)"; + } + + + @Override + public Set getMapNames() + { + return store.getMapNames(); + } + + + @Override + public boolean isRTree(String mapName) + { + return store.isRTree(mapName); + } + + + @Override + public long readMap(String mapName, DataType keyType, DataType valueType, LegacyStore.RawEntrySink sink) + { + return store.readMap(mapName, decoder(keyType), decoder(valueType), sink); + } + + + @Override + public long readRTreeMap(String mapName, int dimensions, DataType valueType, LegacyStore.SpatialEntrySink sink) + { + return store.readRTreeMap(mapName, dimensions, decoder(valueType), sink); + } + + + @SuppressWarnings("unchecked") + private static LegacyStore.EntryDecoder decoder(DataType type) + { + var t = (DataType)type; + return t::read; + } + + + @Override + public void close() + { + store.close(); + } + } + + + /** + * Reads a file the embedded H2 2.5 library can open natively. Used for + * unmarked 2.x sources and to read the interim file back during verification. + */ + public static final class Native implements SourceReader + { + private final MVStore store; + private final int format; + + + public Native(Path file, int format) + { + this.format = format; + this.store = new MVStore.Builder() + .fileName(file.toString()) + .readOnly() + .open(); + } + + + @Override + public String describe() + { + return "H2 " + Constants.VERSION + " readable file (format " + format + ")"; + } + + + @Override + public Set getMapNames() + { + return Collections.unmodifiableSet(new TreeSet<>(store.getMapNames())); + } + + + 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")); + } + + + /** + * Reads the version marker if present. + * @return marker value or null if the map or key is missing + */ + public String getVersionMarker() + { + if (!store.hasMap(H2Utils.DATABASE_MAP_NAME)) + return null; + MVMap map = store.openMap(H2Utils.DATABASE_MAP_NAME, new MVMap.Builder() + .keyType(StringDataType.INSTANCE) + .valueType(StringDataType.INSTANCE)); + return map.get(H2Utils.H2_VERSION_KEY); + } + + + @Override + public long readMap(String mapName, DataType keyType, DataType valueType, 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 RawDataType(keyType)) + .valueType(new RawDataType(valueType))); + + long count = 0; + Cursor cursor = map.cursor(null); + while (cursor.hasNext()) + { + var key = (RawDataType.Entry)cursor.next(); + var value = (RawDataType.Entry)cursor.getValue(); + sink.entry(key.bytes(), key.decoded(map.getKeyType()), value.bytes(), value.decoded(map.getValueType())); + count++; + } + return count; + } + + + @Override + public long readRTreeMap(String mapName, int dimensions, DataType valueType, LegacyStore.SpatialEntrySink sink) + { + // no metadata check: the store layout decides the type, see LegacyStore.readRTreeMap + MVRTreeMap map = store.openMap(mapName, new MVRTreeMap.Builder() + .dimensions(dimensions) + .valueType(new RawDataType(valueType))); + + return walk(map.getRootPage(), map.getValueType(), dimensions, sink); + } + + + private static long walk(Page page, DataType valueType, int dimensions, LegacyStore.SpatialEntrySink sink) + { + long count = 0; + if (page.isLeaf()) + { + for (int i = 0; i < page.getKeyCount(); i++) + { + Spatial key = page.getKey(i); + var value = (RawDataType.Entry)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(valueType)); + 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), valueType, dimensions, sink); + } + return count; + } + + + @Override + public void close() + { + store.closeImmediately(); + } + } +} diff --git a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/StoreLayout.java b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/StoreLayout.java new file mode 100644 index 000000000..0bad01490 --- /dev/null +++ b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/StoreLayout.java @@ -0,0 +1,226 @@ +/***************************** 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.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import org.h2.mvstore.DataUtils; +import org.h2.mvstore.MVBTreeMap; +import org.h2.mvstore.MVMap; +import org.h2.mvstore.MVStore; +import org.h2.mvstore.rtree.MVRTreeMap; +import org.h2.mvstore.type.DataType; +import org.h2.mvstore.type.ObjectDataType; +import org.sensorhub.impl.datastore.h2.H2Utils; +import org.sensorhub.impl.datastore.h2.MVCommandStoreImpl; +import org.sensorhub.impl.datastore.h2.MVDataStoreInfo; +import org.sensorhub.impl.datastore.h2.MVDatabaseConfig.IdProviderType; +import org.sensorhub.impl.datastore.h2.MVDeploymentStoreImpl; +import org.sensorhub.impl.datastore.h2.MVFeatureDatabase; +import org.sensorhub.impl.datastore.h2.MVFeatureStoreImpl; +import org.sensorhub.impl.datastore.h2.MVFoiStoreImpl; +import org.sensorhub.impl.datastore.h2.MVObsStoreImpl; +import org.sensorhub.impl.datastore.h2.MVObsSystemDatabase; +import org.sensorhub.impl.datastore.h2.MVProcedureStoreImpl; +import org.sensorhub.impl.datastore.h2.MVPropertyStoreImpl; +import org.sensorhub.impl.datastore.h2.MVSystemStoreImpl; + + +/** + * The two database layouts the datastore module produces, and how the tool + * learns the real datatype of every map: opening the stores on an empty + * scratch MVStore creates each map exactly as the database modules do. + */ +public enum StoreLayout +{ + OBS_SYSTEM(List.of(MVObsSystemDatabase.SYSTEM_STORE_NAME, MVObsSystemDatabase.DEPL_STORE_NAME, MVObsSystemDatabase.FOI_STORE_NAME, + MVObsSystemDatabase.OBS_STORE_NAME, MVObsSystemDatabase.CMD_STORE_NAME, MVObsSystemDatabase.PROC_STORE_NAME, MVObsSystemDatabase.PROP_STORE_NAME)), + FEATURE(List.of(MVFeatureDatabase.FEATURE_STORE_NAME)); + + // ID scope is only applied when decoding IDs, never written, so 0 is safe here + private static final int ID_SCOPE = 0; + + private final List storeNames; + + + private StoreLayout(List storeNames) + { + this.storeNames = storeNames; + } + + + public List getStoreNames() + { + return storeNames; + } + + + /** + * Opens (and creates if needed) every store of this layout, exactly as the + * database modules do at startup, minus the cross-store links. + */ + public void openStores(MVStore store) + { + var idProvider = IdProviderType.SEQUENTIAL; + switch (this) + { + case OBS_SYSTEM: + MVSystemStoreImpl.open(store, ID_SCOPE, idProvider, info(MVObsSystemDatabase.SYSTEM_STORE_NAME)); + MVDeploymentStoreImpl.open(store, ID_SCOPE, idProvider, info(MVObsSystemDatabase.DEPL_STORE_NAME)); + MVFoiStoreImpl.open(store, ID_SCOPE, idProvider, info(MVObsSystemDatabase.FOI_STORE_NAME)); + MVObsStoreImpl.open(store, ID_SCOPE, idProvider, info(MVObsSystemDatabase.OBS_STORE_NAME)); + MVCommandStoreImpl.open(store, ID_SCOPE, idProvider, info(MVObsSystemDatabase.CMD_STORE_NAME)); + MVProcedureStoreImpl.open(store, ID_SCOPE, idProvider, info(MVObsSystemDatabase.PROC_STORE_NAME)); + MVPropertyStoreImpl.open(store, ID_SCOPE, idProvider, info(MVObsSystemDatabase.PROP_STORE_NAME)); + break; + + case FEATURE: + MVFeatureStoreImpl.open(store, ID_SCOPE, idProvider, info(MVFeatureDatabase.FEATURE_STORE_NAME)); + break; + } + } + + + private static MVDataStoreInfo info(String name) + { + return MVDataStoreInfo.builder().withName(name).build(); + } + + + /** + * Picks the layout from the map names found in a source file. + * @throws MigrationException if the names match neither or both layouts + */ + public static StoreLayout detect(Set mapNames) throws MigrationException + { + boolean feature = mapNames.stream().anyMatch(n -> n.startsWith(MVFeatureDatabase.FEATURE_STORE_NAME + ":")); + boolean obs = mapNames.stream().anyMatch(n -> n.startsWith(MVObsSystemDatabase.SYSTEM_STORE_NAME + ":") || n.startsWith(MVObsSystemDatabase.OBS_STORE_NAME + ":")); + if (feature && !obs) + return FEATURE; + if (obs && !feature) + return OBS_SYSTEM; + throw new MigrationException("Cannot detect database type from map names " + mapNames + + "; use --type obs or --type feature"); + } + + + /** Dimensions used by every OSH spatial index (see SpatialIndex) */ + public static final int SPATIAL_DIMENSIONS = 3; + + + /** + * The real H2 2.5 datatypes an OSH store uses for one map, learned by letting + * the store implementations open the map themselves. + */ + public record MapSpec(String name, DataType keyType, DataType valueType, boolean rtree, int dimensions) + { + } + + + /** + * Learns the real datatype of every map by letting the store implementations + * open an empty scratch database. The scratch store first receives the source + * database's Kryo class map, because the Kryo datatypes resolve class ids + * through the class map of the store they were opened on. The scratch store + * stays open for as long as the harvested datatypes are in use. + */ + public static final class Types implements AutoCloseable + { + private final MVStore scratch; + private final Path scratchPath; + private final Map specs; + + + private Types(MVStore scratch, Path scratchPath, Map specs) + { + this.scratch = scratch; + this.scratchPath = scratchPath; + this.specs = Collections.unmodifiableMap(specs); + } + + + public static Types harvest(SourceReader source, StoreLayout layout, Path scratchPath) throws Exception + { + Files.deleteIfExists(scratchPath); + MVStore scratch = H2Utils.openMVStore(scratchPath.toString(), new MVStore.Builder(), 0); + try + { + if (source.getMapNames().contains(MVObsSystemDatabase.KRYO_CLASS_MAP_NAME)) + { + // typed copy: String -> Integer through H2's default datatype, whose + // encoding is identical in 1.4 and 2.5 + MVMap classMap = scratch.openMap(MVObsSystemDatabase.KRYO_CLASS_MAP_NAME, + new MVBTreeMap.Builder()); + var od = new ObjectDataType(); + source.readMap(MVObsSystemDatabase.KRYO_CLASS_MAP_NAME, od, od, + (k, dk, v, dv) -> classMap.put((String)dk, (Integer)dv)); + scratch.commit(); + } + + layout.openStores(scratch); + scratch.commit(); + + Map specs = new TreeMap<>(); + for (String name: scratch.getMapNames()) + { + String id = scratch.getMetaMap().get(DataUtils.META_NAME + name); + MVMap map = scratch.getMap(DataUtils.parseHexInt(id)); + if (map == null) + throw new MigrationException("Store implementations did not open map " + name); + + boolean rtree = map instanceof MVRTreeMap; + @SuppressWarnings("unchecked") + var keyType = (DataType)map.getKeyType(); + @SuppressWarnings("unchecked") + var valueType = (DataType)map.getValueType(); + specs.put(name, new MapSpec(name, keyType, valueType, rtree, rtree ? SPATIAL_DIMENSIONS : 0)); + } + return new Types(scratch, scratchPath, specs); + } + catch (Exception | Error e) + { + scratch.closeImmediately(); + Files.deleteIfExists(scratchPath); + throw e; + } + } + + + public Map getSpecs() + { + return specs; + } + + + public MapSpec get(String mapName) + { + return specs.get(mapName); + } + + + @Override + public void close() throws IOException + { + scratch.closeImmediately(); + Files.deleteIfExists(scratchPath); + } + } +} diff --git a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/Verifier.java b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/Verifier.java new file mode 100644 index 000000000..7a3f680db --- /dev/null +++ b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/Verifier.java @@ -0,0 +1,270 @@ +/***************************** 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 org.sensorhub.impl.datastore.h2.MVObsSystemDatabase; +import java.nio.ByteBuffer; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.function.Consumer; +import org.h2.mvstore.Cursor; +import org.h2.mvstore.DataUtils; +import org.h2.mvstore.MVBTreeMap; +import org.h2.mvstore.MVMap; +import org.h2.mvstore.MVStore; +import org.h2.mvstore.Page; +import org.h2.mvstore.rtree.MVRTreeMap; +import org.h2.mvstore.rtree.Spatial; +import org.sensorhub.impl.datastore.h2.H2Utils; + + +/** + * Two independent checks of the migrated file. + * Pass one compares the source and the interim file entry by entry as bytes: + * map name sets, per-map counts and a SHA-256 over every key and value + * (ordered for B-trees, order-independent for R-trees). + * Pass two opens the interim file through the real store implementations and + * decodes every entry with the real datatypes. + */ +final class Verifier +{ + private Verifier() + { + } + + + /** Per-map streaming digest of length-prefixed key and value bytes */ + private static final class MapDigest + { + final MessageDigest md = newDigest(); + final List unordered = new ArrayList<>(); + long count; + + void add(byte[] key, byte[] value, boolean ordered) + { + count++; + if (ordered) + { + update(md, key); + update(md, value); + } + else + { + MessageDigest one = newDigest(); + update(one, key); + update(one, value); + unordered.add(one.digest()); + } + } + + byte[] result() + { + if (!unordered.isEmpty()) + { + unordered.sort(Arrays::compare); + for (byte[] d: unordered) + md.update(d); + } + return md.digest(); + } + + static void update(MessageDigest md, byte[] bytes) + { + md.update(ByteBuffer.allocate(4).putInt(bytes.length).array()); + md.update(bytes); + } + } + + + private static MessageDigest newDigest() + { + try + { + return MessageDigest.getInstance("SHA-256"); + } + catch (NoSuchAlgorithmException e) + { + throw new IllegalStateException(e); + } + } + + + private static byte[] spatialKeyBytes(long id, float[] minMax) + { + ByteBuffer b = ByteBuffer.allocate(8 + 4 * minMax.length); + b.putLong(id); + for (float f: minMax) + b.putFloat(f); + return b.array(); + } + + + /** + * Pass one. + * @param skip source maps that are intentionally not copied + * @return per-map entry counts as seen in the interim file + */ + static Map compareBytes(SourceReader source, SourceReader target, Map specs, + Set skip, Consumer log) throws Exception + { + Set expected = new TreeSet<>(source.getMapNames()); + expected.removeAll(skip); + expected.remove(H2Utils.DATABASE_MAP_NAME); + Set actual = new TreeSet<>(target.getMapNames()); + actual.remove(H2Utils.DATABASE_MAP_NAME); + if (!expected.equals(actual)) + throw new MigrationException("Map sets differ: source " + expected + " vs migrated " + actual); + + Map counts = new TreeMap<>(); + for (String name: expected) + { + StoreLayout.MapSpec spec = specs.get(name); + if (spec == null) + throw new MigrationException("No datatype known for map " + name); + // the store layout decides the map type; metadata may only disagree in one + // direction (marker dropped by H2 1.4 compaction), never claim an R-tree for a B-tree + if (!spec.rtree() && (source.isRTree(name) || target.isRTree(name))) + throw new MigrationException("Map " + name + " is marked as an R-tree in the file but the store layout uses a B-tree"); + if (spec.rtree() && !target.isRTree(name)) + throw new MigrationException("Migrated map " + name + " lost its R-tree marker"); + + MapDigest src = new MapDigest(); + MapDigest dst = new MapDigest(); + if (spec.rtree()) + { + source.readRTreeMap(name, spec.dimensions(), spec.valueType(), + (id, mm, v, dv) -> src.add(spatialKeyBytes(id, mm), v, false)); + target.readRTreeMap(name, spec.dimensions(), spec.valueType(), + (id, mm, v, dv) -> dst.add(spatialKeyBytes(id, mm), v, false)); + } + else + { + // the source may hold byte-identical duplicate keys that the copy stored once + var dedupe = new Migrator.DuplicateFilter(name); + source.readMap(name, spec.keyType(), spec.valueType(), (k, dk, v, dv) -> { + if (!dedupe.isDuplicate(k, v)) + src.add(k, v, true); + }); + target.readMap(name, spec.keyType(), spec.valueType(), (k, dk, v, dv) -> dst.add(k, v, true)); + if (dedupe.duplicates > 0) + log.accept(" note: " + name + " has " + dedupe.duplicates + " byte-identical duplicate key(s) in the source"); + } + + if (src.count != dst.count) + throw new MigrationException("Entry count differs for " + name + ": source " + src.count + + " vs migrated " + dst.count); + if (!Arrays.equals(src.result(), dst.result())) + throw new MigrationException("Entry bytes differ for " + name + " (" + src.count + " entries)"); + counts.put(name, dst.count); + log.accept(" verified " + name + ": " + dst.count + " entries, bytes identical"); + } + return counts; + } + + + /** + * Pass two: open through the store implementations and decode everything. + * @return per-map entry counts decoded with the real datatypes + */ + static Map decodeAll(Path file, StoreLayout type, Consumer log) throws Exception + { + Map counts = new TreeMap<>(); + MVStore store = H2Utils.openMVStore(file.toString(), new MVStore.Builder().readOnly(), 0); + try + { + // The datastore's Kryo datatypes may register additional class mappings while + // reading pages (getMemory serializes values). Record what the open adds so the + // caller can tell it apart from a copy defect. + Set classesBefore = new TreeSet<>(); + if (store.hasMap(MVObsSystemDatabase.KRYO_CLASS_MAP_NAME)) + classesBefore.addAll(store.openMap(MVObsSystemDatabase.KRYO_CLASS_MAP_NAME, new MVBTreeMap.Builder()).keySet()); + + type.openStores(store); + for (String name: new TreeSet<>(store.getMapNames())) + { + String id = store.getMetaMap().get(DataUtils.META_NAME + name); + MVMap map = store.getMap(DataUtils.parseHexInt(id)); + if (map == null) + throw new MigrationException("Store implementations did not open map " + name); + + long n; + if (map instanceof MVRTreeMap) + { + @SuppressWarnings("unchecked") + var rtree = (MVRTreeMap)(MVMap)map; + n = countLeaves(rtree.getRootPage()); + } + else + { + n = 0; + Cursor cursor = map.cursor(null); + while (cursor.hasNext()) + { + cursor.next(); + cursor.getValue(); + n++; + } + } + counts.put(name, n); + log.accept(" decoded " + name + ": " + n + " entries"); + } + + if (store.hasMap(MVObsSystemDatabase.KRYO_CLASS_MAP_NAME)) + { + Set added = new TreeSet<>(store.openMap(MVObsSystemDatabase.KRYO_CLASS_MAP_NAME, + new MVBTreeMap.Builder()).keySet()); + added.removeAll(classesBefore); + if (!added.isEmpty()) + log.accept(" note: opening the stores registered " + added.size() + " additional Kryo class mapping(s): " + + added + " (datastore behavior on first read, not a migration defect)"); + } + } + finally + { + store.closeImmediately(); + } + return counts; + } + + + private static long countLeaves(Page page) + { + long n = 0; + if (page.isLeaf()) + { + for (int i = 0; i < page.getKeyCount(); i++) + { + page.getKey(i); + page.getValue(i); + n++; + } + } + 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++) + n += countLeaves(page.getChildPage(i)); + } + return n; + } +} diff --git a/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/spi/LegacyStore.java b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/spi/LegacyStore.java new file mode 100644 index 000000000..ca20100ee --- /dev/null +++ b/sensorhub-datastore-h2/src/main/java/org/sensorhub/impl/datastore/h2/migrate/spi/LegacyStore.java @@ -0,0 +1,157 @@ +/***************************** 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.spi; + +import java.nio.ByteBuffer; +import java.util.Map; +import java.util.Set; + + +/** + * Contract between the H2 2.5 side of the migration and the H2 1.4 adapters that + * run in an isolated classloader. Everything in here uses JDK types only, so the + * two sides can share these interfaces while sharing no H2 class. The adapters + * implement {@link Factory}, which the tool loads by name ({@link Factory#IMPL_CLASS}). + */ +public interface LegacyStore extends AutoCloseable +{ + String getFileName(); + + /** @return H2 library version used to open the file (e.g. "1.4.200") */ + String getH2Version(); + + Set getMapNames(); + + /** @return the parsed "map.<id>" metadata for the named map */ + Map getMapMeta(String mapName); + + /** @return true when the map metadata carries the R-tree marker (type:rtree) */ + boolean isRTree(String mapName); + + /** + * Iterates a B-tree map in key order. + * @return number of entries delivered to the sink + */ + long readMap(String mapName, EntryDecoder keyDecoder, EntryDecoder valueDecoder, RawEntrySink sink); + + /** + * Iterates every leaf entry of an R-tree map by walking its pages. The map is + * opened as an R-tree whether or not its metadata still says so: H2 1.4's + * compaction rewrites maps generically and drops the type:rtree marker, while + * the datastore keeps opening such maps as R-trees. + * @return number of entries delivered to the sink + */ + long readRTreeMap(String mapName, int dimensions, EntryDecoder valueDecoder, SpatialEntrySink sink); + + @Override + void close(); + + + /** + * Entry point implemented inside the legacy classloader. The tool locates the + * implementation by name ({@link #IMPL_CLASS}) and casts it to this interface. + */ + interface Factory + { + String IMPL_CLASS = "org.sensorhub.impl.datastore.h2.migrate.legacy.LegacyStoreFactoryImpl"; + + LegacyStore openReadOnly(String fileName); + + Writer openForWrite(String fileName); + + /** + * Compacts a file with H2 1.4's own MVStoreTool, exactly as the pre-upgrade + * datastore's compactOnClose did. Test fixture support: 1.4 compaction drops + * the R-tree type marker from map metadata. + */ + void compact(String fileName); + } + + + /** + * Writes an H2 1.4 MVStore file from raw entries. This exists to build + * genuine format-1 test fixtures from the current datatypes; it is not part + * of the forward migration path. + */ + interface Writer extends AutoCloseable + { + interface MapWriter + { + void put(byte[] key, Object decodedKey, byte[] value); + } + + interface RTreeMapWriter + { + void put(long id, float[] minMax, byte[] value); + } + + MapWriter openMap(String mapName, EntryComparator keyComparator); + + RTreeMapWriter openRTreeMap(String mapName, int dimensions); + + void commit(); + + @Override + void close(); + } + + + /** + * Decodes one key or value from its position in a buffer, advancing the + * buffer position past exactly the bytes that belong to that entry. + * Implemented on the H2 2.5 side by delegating to the real OSH datatype. + * Only JDK types cross the classloader boundary. + */ + interface EntryDecoder + { + Object decode(ByteBuffer buff); + } + + + /** + * Orders two raw keys. Implemented on the H2 2.5 side with the real + * datatype's compare method. Used only when writing legacy files. + */ + interface EntryComparator + { + int compare(byte[] keyA, Object decodedA, byte[] keyB, Object decodedB); + } + + + /** + * Receives B-tree entries as the exact bytes stored in the source file, + * together with the decoded objects produced by the {@link EntryDecoder}s. + */ + interface RawEntrySink + { + void entry(byte[] key, Object decodedKey, byte[] value, Object decodedValue); + } + + + /** + * Receives R-tree leaf entries. The spatial key is transferred structurally + * (id plus min/max per dimension) and the value as exact bytes. + */ + interface SpatialEntrySink + { + /** + * @param id spatial key id + * @param minMax bounds as min0, max0, min1, max1, ... + * @param value exact value bytes + * @param decodedValue value decoded by the supplied {@link EntryDecoder} + */ + void entry(long id, float[] minMax, byte[] value, Object decodedValue); + } +} 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); diff --git a/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/TestKryoDataType.java b/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/TestKryoDataType.java new file mode 100644 index 000000000..c6ef1b97d --- /dev/null +++ b/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/TestKryoDataType.java @@ -0,0 +1,52 @@ +/***************************** 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.nio.ByteBuffer; +import java.util.Arrays; +import org.h2.mvstore.WriteBuffer; +import org.junit.Test; +import org.sensorhub.impl.datastore.h2.kryo.KryoDataType; + + +public class TestKryoDataType +{ + + /* + * H2 2.x reads pages of a chunk that is serialized but not yet saved from a + * slice of the chunk buffer, so the page does not start at array index 0 + */ + @Test + public void testReadFromSlicedBuffer() + { + var dataType = new KryoDataType(); + var wbuf = new WriteBuffer(); + dataType.write(wbuf, "first"); + dataType.write(wbuf, "second"); + var page = wbuf.getBuffer(); + page.flip(); + + int offset = 4096; + var chunk = new byte[offset + page.remaining()]; + Arrays.fill(chunk, 0, offset, (byte)0x7f); + page.get(chunk, offset, page.remaining()); + var buff = ByteBuffer.wrap(chunk).position(offset).slice(); + + assertEquals("first", dataType.read(buff)); + assertEquals("second", dataType.read(buff)); + assertFalse(buff.hasRemaining()); + } +} 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() diff --git a/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/migrate/TestAutoMigrate.java b/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/migrate/TestAutoMigrate.java new file mode 100644 index 000000000..6fde63100 --- /dev/null +++ b/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/migrate/TestAutoMigrate.java @@ -0,0 +1,175 @@ +/***************************** 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 static org.junit.Assert.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Stream; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.sensorhub.impl.datastore.h2.MVFeatureDatabase; +import org.sensorhub.impl.datastore.h2.MVFeatureDatabaseConfig; +import org.sensorhub.impl.datastore.h2.MVObsSystemDatabase; +import org.sensorhub.impl.datastore.h2.MVObsSystemDatabaseConfig; + + +/** + * The database module migrates a legacy file at startup only when autoMigrate is set; + * otherwise it refuses it with a message that tells the operator what to do. + */ +public class TestAutoMigrate +{ + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + + private MVObsSystemDatabase database(Path file, boolean autoMigrate) throws Exception + { + var db = new MVObsSystemDatabase(); + var config = new MVObsSystemDatabaseConfig(); + config.storagePath = file.toString(); + config.databaseNum = TestSupport.DATABASE_NUM; + config.autoMigrate = autoMigrate; + db.setConfiguration(config); + db.init(); + db.start(); + return db; + } + + + private MVFeatureDatabase featureDatabase(Path file, boolean autoMigrate) throws Exception + { + var db = new MVFeatureDatabase(); + var config = new MVFeatureDatabaseConfig(); + config.storagePath = file.toString(); + config.databaseNum = TestSupport.DATABASE_NUM; + config.autoMigrate = autoMigrate; + db.setConfiguration(config); + db.init(); + db.start(); + return db; + } + + + /** All messages in the cause chain; the migration hint sits on the wrapping exception */ + private static String chainMessages(Throwable error) + { + var sb = new StringBuilder(); + for (Throwable t = error; t != null; t = t.getCause()) + sb.append(t.getMessage()).append(" | "); + return sb.toString(); + } + + + @Test + public void testLegacyFileRefusedUnlessAutoMigrate() throws Exception + { + Path original = tmp.getRoot().toPath().resolve("obs-25.dat"); + Path legacy = tmp.getRoot().toPath().resolve("obs.dat"); + var exp = TestSupport.populateObsDatabase(original, 4, 100, 3); + TestSupport.exportToLegacy(original, legacy, StoreLayout.OBS_SYSTEM); + byte[] before = Files.readAllBytes(legacy); + + // default: refused, file untouched, message names the config flag and the command + var refused = database(legacy, false); + try + { + assertFalse(refused.isStarted()); + String msg = chainMessages(refused.getCurrentError()); + assertTrue(msg, msg.contains("autoMigrate")); + assertTrue(msg, msg.contains(H2Migrate.class.getName())); + } + finally + { + refused.stop(); + } + assertArrayEquals(before, Files.readAllBytes(legacy)); + assertEquals(1, FileOps.readH2Format(legacy)); + + // autoMigrate: migrated in place, original retired as .old, then started normally + var db = database(legacy, true); + try + { + assertTrue(String.valueOf(db.getCurrentError()), db.isStarted()); + assertEquals(exp.numObs, db.getObservationStore().getNumRecords()); + assertEquals(exp.numFois, db.getFoiStore().getNumRecords()); + assertEquals(exp.spatialMatches, TestSupport.spatialMatches(db)); + } + finally + { + db.stop(); + } + assertEquals(3, FileOps.readH2Format(legacy)); + assertArrayEquals("original must survive untouched as .old", before, + Files.readAllBytes(legacy.resolveSibling("obs.dat" + Migrator.RETIRED_SUFFIX))); + try (Stream files = Files.list(legacy.getParent())) + { + assertEquals("no backup copy unless backupOnMigrate is set", 0, + files.filter(p -> p.getFileName().toString().contains("-backup-")).count()); + } + + // second start: nothing to migrate, still opens + db = database(legacy, true); + try + { + assertTrue(db.isStarted()); + assertEquals(exp.numObs, db.getObservationStore().getNumRecords()); + } + finally + { + db.stop(); + } + } + + + @Test + public void testFeatureDatabaseAutoMigrate() throws Exception + { + Path original = tmp.getRoot().toPath().resolve("feat-25.dat"); + Path legacy = tmp.getRoot().toPath().resolve("feat.dat"); + var exp = TestSupport.populateFeatureDatabase(original, 50); + TestSupport.exportToLegacy(original, legacy, StoreLayout.FEATURE); + byte[] before = Files.readAllBytes(legacy); + + var refused = featureDatabase(legacy, false); + try + { + assertFalse(refused.isStarted()); + String msg = chainMessages(refused.getCurrentError()); + assertTrue(msg, msg.contains("autoMigrate")); + } + finally + { + refused.stop(); + } + assertArrayEquals(before, Files.readAllBytes(legacy)); + + var db = featureDatabase(legacy, true); + try + { + assertTrue(String.valueOf(db.getCurrentError()), db.isStarted()); + assertEquals(exp.numFois, db.getFeatureStore().getNumRecords()); + assertEquals(exp.spatialMatches, TestSupport.spatialMatches(db)); + assertEquals(exp.keywordMatches, TestSupport.keywordMatches(db)); + } + finally + { + db.stop(); + } + assertEquals(3, FileOps.readH2Format(legacy)); + } +} diff --git a/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/migrate/TestLegacyLoader.java b/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/migrate/TestLegacyLoader.java new file mode 100644 index 000000000..052fdfe6e --- /dev/null +++ b/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/migrate/TestLegacyLoader.java @@ -0,0 +1,134 @@ +/***************************** 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 static org.junit.Assert.*; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Random; +import java.util.TreeSet; +import org.h2.mvstore.MVStore; +import org.h2.mvstore.type.DataType; +import org.h2.mvstore.type.ObjectDataType; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.sensorhub.impl.datastore.h2.migrate.spi.LegacyStore; + + +/** + * Spikes for the central mechanism: an H2 1.4 store running in the isolated + * classloader while H2 2.5 is on the parent classpath, with entry boundaries + * found by parent-side datatypes. + */ +public class TestLegacyLoader +{ + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + + @Test + public void testClassIsolation() throws Exception + { + try (LegacyLoader loader = TestSupport.legacyLoader()) + { + Class legacyStore = Class.forName("org.h2.mvstore.MVStore", true, loader); + assertNotSame("legacy loader must define its own org.h2 classes", MVStore.class, legacyStore); + assertSame(loader, legacyStore.getClassLoader()); + + Class spi = Class.forName(LegacyStore.Factory.class.getName(), true, loader); + assertSame("spi interfaces must be shared with the parent", LegacyStore.Factory.class, spi); + + Class impl = Class.forName(LegacyStore.Factory.IMPL_CLASS, true, loader); + assertSame(loader, impl.getClassLoader()); + assertTrue(LegacyStore.Factory.class.isAssignableFrom(impl)); + } + } + + + @Test + public void testWriteAndReadFormat1File() throws Exception + { + Path file = tmp.getRoot().toPath().resolve("legacy.dat"); + var od = new ObjectDataType(); + @SuppressWarnings("unchecked") + DataType type = (DataType)(DataType)od; + + List ids = new ArrayList<>(); + for (int i = 0; i < 2000; i++) + ids.add(i); + Collections.shuffle(ids, new Random(42)); + + try (LegacyLoader loader = TestSupport.legacyLoader()) + { + var writer = loader.factory().openForWrite(file.toString()); + try + { + var m = writer.openMap("plain", (ka, da, kb, db) -> od.compare(da, db)); + for (int i: ids) + { + String key = String.format("key-%05d", i); + m.put(TestSupport.encode(type, key), key, TestSupport.encode(type, i)); + } + var r = writer.openRTreeMap("spatial", 3); + for (int i = 0; i < 100; i++) + r.put(i, new float[] {i, i + 1, -i, -i + 1, 0, 0}, TestSupport.encode(type, i * 10)); + writer.commit(); + } + finally + { + writer.close(); + } + } + + assertEquals("H2 1.4 must write file format 1", 1, FileOps.readH2Format(file)); + + try (LegacyLoader loader = TestSupport.legacyLoader(); + SourceReader.Legacy reader = new SourceReader.Legacy(loader.factory(), file)) + { + assertTrue(reader.describe(), reader.describe().contains("1.4.200")); + assertEquals(new TreeSet<>(List.of("plain", "spatial")), new TreeSet<>(reader.getMapNames())); + assertFalse(reader.isRTree("plain")); + assertTrue(reader.isRTree("spatial")); + + List keys = new ArrayList<>(); + long n = reader.readMap("plain", od, od, (k, dk, v, dv) -> { + String key = (String)dk; + keys.add(key); + assertArrayEquals("key bytes must be exactly what was written", TestSupport.encode(type, key), k); + int expected = Integer.parseInt(key.substring(4)); + assertEquals(expected, dv); + assertArrayEquals(TestSupport.encode(type, expected), v); + }); + assertEquals(2000, n); + List sorted = new ArrayList<>(keys); + Collections.sort(sorted); + assertEquals("cursor must yield keys in order", sorted, keys); + + TreeSet seen = new TreeSet<>(); + long rn = reader.readRTreeMap("spatial", 3, od, (id, mm, v, dv) -> { + seen.add(id); + assertEquals(6, mm.length); + assertEquals((float)id, mm[0], 0f); + assertEquals((float)(id + 1), mm[1], 0f); + assertEquals((int)(id * 10), dv); + }); + assertEquals(100, rn); + assertEquals(100, seen.size()); + } + } +} diff --git a/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/migrate/TestMigrateFailures.java b/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/migrate/TestMigrateFailures.java new file mode 100644 index 000000000..f122fc7c9 --- /dev/null +++ b/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/migrate/TestMigrateFailures.java @@ -0,0 +1,274 @@ +/***************************** 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 static org.junit.Assert.*; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.stream.Stream; +import org.h2.mvstore.MVStore; +import org.h2.mvstore.type.ObjectDataType; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.sensorhub.impl.datastore.h2.H2Utils; + + +/** + * Every failure must leave the original file untouched. + */ +public class TestMigrateFailures +{ + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + + private Path legacyFixture(String name) throws Exception + { + Path original = tmp.getRoot().toPath().resolve(name + "-25.dat"); + Path legacy = tmp.getRoot().toPath().resolve(name + ".dat"); + TestSupport.populateObsDatabase(original, 3, 50, 2); + TestSupport.exportToLegacy(original, legacy, StoreLayout.OBS_SYSTEM); + return legacy; + } + + + private long backupCount(Path file) throws Exception + { + try (Stream s = Files.list(file.getParent())) + { + return s.filter(p -> p.getFileName().toString().contains("-backup-")).count(); + } + } + + + @Test + public void testLockedFileIsRefusedBeforeBackup() throws Exception + { + Path legacy = legacyFixture("locked"); + byte[] before = Files.readAllBytes(legacy); + try (FileChannel ch = FileChannel.open(legacy, StandardOpenOption.READ, StandardOpenOption.WRITE)) + { + ch.lock(); + try + { + Migrator.run(TestSupport.options(legacy)); + fail(); + } + catch (MigrationException e) + { + assertTrue(e.getMessage(), e.getMessage().contains("locked")); + } + } + assertArrayEquals(before, Files.readAllBytes(legacy)); + assertEquals(0, backupCount(legacy)); + } + + + @Test + public void testNonH2FileIsRefused() throws Exception + { + Path bogus = tmp.getRoot().toPath().resolve("bogus.dat"); + Files.writeString(bogus, "this is not a database", StandardCharsets.UTF_8); + try + { + Migrator.run(TestSupport.options(bogus)); + fail(); + } + catch (MigrationException e) + { + assertTrue(e.getMessage(), e.getMessage().contains("Not an H2 MVStore file")); + } + assertEquals(0, backupCount(bogus)); + } + + + @Test + public void testAlreadyMarkedFileIsRefused() throws Exception + { + Path marked = tmp.getRoot().toPath().resolve("marked.dat"); + TestSupport.populateObsDatabase(marked, 2, 10, 1); + byte[] before = Files.readAllBytes(marked); + try + { + Migrator.run(TestSupport.options(marked)); + fail(); + } + catch (MigrationException e) + { + assertTrue(e.getMessage(), e.getMessage().contains("already carries version marker")); + } + assertArrayEquals(before, Files.readAllBytes(marked)); + assertEquals(0, backupCount(marked)); + } + + + @Test + public void testDryRunLeavesOriginalAndKeepsInterim() throws Exception + { + Path legacy = legacyFixture("dryrun"); + byte[] before = Files.readAllBytes(legacy); + var opts = TestSupport.options(legacy); + opts.dryRun = true; + var report = Migrator.run(opts); + + assertFalse(report.instated); + assertArrayEquals(before, Files.readAllBytes(legacy)); + assertEquals(1, FileOps.readH2Format(legacy)); + assertTrue(Files.exists(report.interim)); + assertEquals(3, FileOps.readH2Format(report.interim)); + assertEquals(FileOps.sha256(legacy), FileOps.sha256(report.backup)); + + // the interim file is a complete, marked database + try (MVStore s = H2Utils.openMVStore(report.interim.toString(), new MVStore.Builder().readOnly(), 0)) + { + assertTrue(s.hasMap("obs_store:obs_records")); + } + } + + + @Test + public void testUnknownMapAbortsAndCleansUp() throws Exception + { + Path legacy = legacyFixture("unknown"); + + // add a map the layout does not know, using the legacy writer on the same file + try (LegacyLoader loader = TestSupport.legacyLoader()) + { + var w = loader.factory().openForWrite(legacy.toString()); + try + { + var od = new ObjectDataType(); + @SuppressWarnings("unchecked") + var type = (org.h2.mvstore.type.DataType)(org.h2.mvstore.type.DataType)od; + var m = w.openMap("addon_store:custom", (ka, da, kb, db) -> od.compare(da, db)); + m.put(TestSupport.encode(type, "k"), "k", TestSupport.encode(type, 1)); + w.commit(); + } + finally + { + w.close(); + } + } + byte[] before = Files.readAllBytes(legacy); + + try + { + Migrator.run(TestSupport.options(legacy)); + fail(); + } + catch (MigrationException e) + { + assertTrue(e.getMessage(), e.getMessage().contains("addon_store:custom")); + } + assertArrayEquals(before, Files.readAllBytes(legacy)); + assertFalse(Files.exists(legacy.resolveSibling(legacy.getFileName() + ".migrating"))); + assertFalse(Files.exists(legacy.resolveSibling(legacy.getFileName() + ".migrating-types"))); + assertEquals("layout is checked before the backup is written", 0, backupCount(legacy)); + } + + + @Test + public void testBackupDirAndKeepInterim() throws Exception + { + Path legacy = legacyFixture("options"); + Path backupDir = tmp.getRoot().toPath().resolve("backups/nested"); + byte[] before = Files.readAllBytes(legacy); + + // --backup-dir: the verified backup lands in the requested directory + var opts = TestSupport.options(legacy); + opts.dryRun = true; + opts.backupDir = backupDir; + var report = Migrator.run(opts); + assertEquals(backupDir, report.backup.getParent()); + assertArrayEquals(before, Files.readAllBytes(report.backup)); + assertEquals("no backup next to the database", 0, backupCount(legacy)); + Files.delete(report.interim); + + // --keep-interim: a failure after the interim was written leaves it for inspection + Path interim = legacy.resolveSibling(legacy.getFileName() + ".migrating"); + Files.writeString(interim, "stale", StandardCharsets.UTF_8); + try + { + Migrator.run(TestSupport.options(legacy)); + fail("a pre-existing interim file must be refused"); + } + catch (MigrationException e) + { + assertTrue(e.getMessage(), e.getMessage().contains("Interim file already exists")); + } + assertTrue("existing interim file must not be deleted", Files.exists(interim)); + Files.delete(interim); + + opts = TestSupport.options(legacy); + opts.keepInterim = true; + opts.type = StoreLayout.FEATURE; // wrong layout: fails at the unknown-map check, before any interim + try + { + Migrator.run(opts); + fail(); + } + catch (MigrationException e) + { + assertTrue(e.getMessage(), e.getMessage().contains("unknown to the FEATURE layout")); + } + assertArrayEquals(before, Files.readAllBytes(legacy)); + } + + + @Test + public void testExistingRetiredOriginalIsRefusedBeforeBackup() throws Exception + { + Path legacy = legacyFixture("retired"); + Path retired = legacy.resolveSibling(legacy.getFileName() + Migrator.RETIRED_SUFFIX); + Files.writeString(retired, "an earlier original", StandardCharsets.UTF_8); + byte[] before = Files.readAllBytes(legacy); + try + { + Migrator.run(TestSupport.options(legacy)); + fail(); + } + catch (MigrationException e) + { + assertTrue(e.getMessage(), e.getMessage().contains("retired original already exists")); + } + assertArrayEquals(before, Files.readAllBytes(legacy)); + assertEquals("an earlier original", Files.readString(retired, StandardCharsets.UTF_8)); + assertEquals(0, backupCount(legacy)); + + // a dry run does not retire anything, so it still works + Files.delete(retired); + var opts = TestSupport.options(legacy); + opts.dryRun = true; + var report = Migrator.run(opts); + assertNull(report.retiredOriginal); + assertArrayEquals(before, Files.readAllBytes(legacy)); + } + + + @Test + public void testCliUsageErrors() + { + assertEquals(2, H2Migrate.run(new String[0])); + assertEquals(2, H2Migrate.run(new String[] {"--type"})); + assertEquals(2, H2Migrate.run(new String[] {"--type", "nope", "x.dat"})); + assertEquals(2, H2Migrate.run(new String[] {"--backup", "--backup-dir"})); + assertEquals(0, H2Migrate.run(new String[] {"--help"})); + assertEquals(1, H2Migrate.run(new String[] {"--quiet", tmp.getRoot().toPath().resolve("missing.dat").toString()})); + } +} diff --git a/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/migrate/TestMigrateNative.java b/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/migrate/TestMigrateNative.java new file mode 100644 index 000000000..3c9549951 --- /dev/null +++ b/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/migrate/TestMigrateNative.java @@ -0,0 +1,120 @@ +/***************************** 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 static org.junit.Assert.*; +import java.nio.file.Path; +import org.h2.mvstore.MVStore; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + + +/** + * An H2 2.5 file written before the version marker existed goes through the + * same pipeline in-process and comes out marked. + */ +public class TestMigrateNative +{ + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + + @Test + public void testUnmarkedFileGetsMigratedAndMarked() throws Exception + { + Path marked = tmp.getRoot().toPath().resolve("marked.dat"); + Path unmarked = tmp.getRoot().toPath().resolve("unmarked.dat"); + var exp = TestSupport.populateObsDatabase(marked, 5, 200, 5); + + // strip the marker: copy every map except @database into a plain 2.5 store + try (SourceReader.Native src = new SourceReader.Native(marked, 3); + StoreLayout.Types types = StoreLayout.Types.harvest(src, StoreLayout.OBS_SYSTEM, + tmp.getRoot().toPath().resolve("types.dat"))) + { + MVStore dst = new MVStore.Builder().fileName(unmarked.toString()).open(); + try + { + for (String name: Migrator.copyOrder(src.getMapNames())) + { + StoreLayout.MapSpec spec = types.get(name); + if (spec.rtree()) + { + var m = dst.openMap(name, new org.h2.mvstore.rtree.MVRTreeMap.Builder() + .dimensions(spec.dimensions()).valueType(new RawDataType(spec.valueType()))); + var f = new org.h2.mvstore.rtree.SpatialDataType(spec.dimensions()) { + public org.h2.mvstore.rtree.Spatial mk(long id, float[] mm) { return create(id, mm); } + }; + src.readRTreeMap(name, spec.dimensions(), spec.valueType(), + (id, mm, v, dv) -> m.put(f.mk(id, mm), new RawDataType.Entry(v, dv))); + } + else + { + var m = dst.openMap(name, new org.h2.mvstore.MVBTreeMap.Builder() + .keyType(new RawDataType(spec.keyType())).valueType(new RawDataType(spec.valueType()))); + src.readMap(name, spec.keyType(), spec.valueType(), + (k, dk, v, dv) -> m.put(new RawDataType.Entry(k, dk), new RawDataType.Entry(v, dv))); + } + } + dst.commit(); + } + finally + { + dst.close(); + } + } + assertEquals(3, FileOps.readH2Format(unmarked)); + try (SourceReader.Native r = new SourceReader.Native(unmarked, 3)) + { + assertNull(r.getVersionMarker()); + } + + // the datastore refuses it (the module records the error instead of throwing) + var rejected = TestSupport.openObsDatabase(unmarked); + try + { + assertFalse(rejected.isStarted()); + Throwable error = rejected.getCurrentError(); + assertNotNull(error); + while (error.getCause() != null) + error = error.getCause(); + assertTrue(error.getMessage(), error.getMessage().contains("migration")); + } + finally + { + rejected.stop(); + } + + var report = Migrator.run(TestSupport.options(unmarked)); + assertTrue(report.instated); + assertEquals(3, report.sourceFormat); + assertEquals((Long)(long)exp.numObs, report.copied.get("obs_store:obs_records")); + + TestMigrateRoundTrip.assertIdenticalEntries(marked, unmarked, StoreLayout.OBS_SYSTEM); + + var db = TestSupport.openObsDatabase(unmarked); + try + { + assertEquals(exp.numObs, db.getObservationStore().getNumRecords()); + assertEquals(exp.numFois, db.getFoiStore().getNumRecords()); + assertEquals(exp.spatialMatches, TestSupport.spatialMatches(db)); + assertEquals(exp.keywordMatches, TestSupport.keywordMatches(db)); + } + finally + { + db.stop(); + } + } +} diff --git a/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/migrate/TestMigrateRoundTrip.java b/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/migrate/TestMigrateRoundTrip.java new file mode 100644 index 000000000..eeb53e7fa --- /dev/null +++ b/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/migrate/TestMigrateRoundTrip.java @@ -0,0 +1,226 @@ +/***************************** 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 static org.junit.Assert.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Stream; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.sensorhub.api.datastore.obs.ObsFilter; + + +/** + * Populates a real database with H2 2.5, exports it to a genuine H2 1.4 file, + * migrates that file and checks the result through the store APIs and by + * comparing bytes with the original 2.5 database. + */ +public class TestMigrateRoundTrip +{ + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + + @Test + public void testObsSystemDatabase() throws Exception + { + Path original = tmp.getRoot().toPath().resolve("obsdb.dat"); + Path legacy = tmp.getRoot().toPath().resolve("obsdb-v1.dat"); + var exp = TestSupport.populateObsDatabase(original, 25, 3000, 40); + + TestSupport.exportToLegacy(original, legacy, StoreLayout.OBS_SYSTEM); + assertEquals(1, FileOps.readH2Format(legacy)); + byte[] legacyBytes = Files.readAllBytes(legacy); + + var report = Migrator.run(TestSupport.options(legacy)); + assertTrue(report.instated); + assertEquals(StoreLayout.OBS_SYSTEM, report.type); + assertEquals(1, report.sourceFormat); + assertEquals(3, FileOps.readH2Format(legacy)); + assertEquals((Long)(long)exp.numObs, report.copied.get("obs_store:obs_records")); + assertEquals((Long)(long)exp.numFois, report.copied.get("foi_store:feature_records")); + assertEquals((Long)(long)exp.numFois, report.copied.get("foi_store:feature_geom")); + assertEquals((Long)(long)exp.numCommands, report.copied.get("cmd_store:cmd_records")); + assertEquals((Long)(long)exp.numSystemVersions, report.copied.get("sys_store:feature_records")); + assertTrue("legacy full-text index duplicates must be reported", + report.duplicatesCollapsed.getOrDefault("sys_store:feature_text", 0L) > 0); + + // backup is byte-identical to the legacy file, and the original itself survives + // untouched under the .old name + assertNotNull(report.backup); + assertArrayEquals(legacyBytes, Files.readAllBytes(report.backup)); + assertEquals(legacy.resolveSibling("obsdb-v1.dat.old"), report.retiredOriginal); + assertArrayEquals(legacyBytes, Files.readAllBytes(report.retiredOriginal)); + assertFalse(Files.exists(report.interim)); + + // independent byte comparison of the migrated file against the original 2.5 file, + // before the module opens it (opening persists Kryo class mappings added on read) + assertIdenticalEntries(original, legacy, StoreLayout.OBS_SYSTEM); + + // migrated file opens through the module and holds the same data + var db = TestSupport.openObsDatabase(legacy); + try + { + assertEquals(exp.numSystemVersions, db.getSystemDescStore().getNumRecords()); + var sys0 = db.getSystemDescStore().getCurrentVersion(TestSupport.SYS_UID_PREFIX + 0); + assertNotNull(sys0); + assertTrue(sys0.getDescription(), sys0.getDescription().contains("updated")); + assertEquals(exp.numFois, db.getFoiStore().getNumRecords()); + assertEquals(exp.numDataStreams, db.getObservationStore().getDataStreams().getNumRecords()); + assertEquals(exp.numObs, db.getObservationStore().getNumRecords()); + assertEquals(exp.numCommandStreams, db.getCommandStore().getCommandStreams().getNumRecords()); + assertEquals(exp.numCommands, db.getCommandStore().getNumRecords()); + assertEquals(exp.numStatus, db.getCommandStore().getStatusReports().getNumRecords()); + + for (var e: exp.obsValues.entrySet()) + { + var obs = db.getObservationStore().get(e.getKey()); + assertNotNull("observation missing: " + e.getKey(), obs); + assertEquals(exp.dataStreamId, obs.getDataStreamID()); + assertEquals(exp.firstFoiId, obs.getFoiID()); + for (int s = 0; s < 5; s++) + assertEquals(e.getValue()[s], obs.getResult().getDoubleValue(s), 0.0); + } + + try (Stream s = db.getObservationStore().select(new ObsFilter.Builder() + .withDataStreams(exp.dataStreamId).build())) + { + assertEquals(exp.numObs, s.count()); + } + + // spatial and full-text indexes answer exactly as the original did + assertEquals(exp.spatialMatches, TestSupport.spatialMatches(db)); + assertEquals(exp.keywordMatches, TestSupport.keywordMatches(db)); + } + finally + { + db.stop(); + } + } + + + @Test + public void testFeatureDatabase() throws Exception + { + Path original = tmp.getRoot().toPath().resolve("featdb.dat"); + Path legacy = tmp.getRoot().toPath().resolve("featdb-v1.dat"); + var exp = TestSupport.populateFeatureDatabase(original, 300); + int numFeatures = exp.numFois; + + TestSupport.exportToLegacy(original, legacy, StoreLayout.FEATURE); + assertEquals(1, FileOps.readH2Format(legacy)); + byte[] legacyBytes = Files.readAllBytes(legacy); + + var opts = TestSupport.options(legacy); + opts.type = null; // must be detected + opts.backup = false; // default: no copy, the retired original is the safeguard + var report = Migrator.run(opts); + assertEquals(StoreLayout.FEATURE, report.type); + assertTrue(report.instated); + assertNull(report.backup); + assertArrayEquals(legacyBytes, Files.readAllBytes(report.retiredOriginal)); + try (Stream files = Files.list(legacy.getParent())) + { + assertEquals("no backup copy without --backup", 0, + files.filter(p -> p.getFileName().toString().contains("-backup-")).count()); + } + assertEquals((Long)(long)numFeatures, report.copied.get("feature_store:feature_records")); + assertEquals((Long)(long)numFeatures, report.copied.get("feature_store:feature_geom")); + + assertIdenticalEntries(original, legacy, StoreLayout.FEATURE); + + var db = TestSupport.openFeatureDatabase(legacy); + try + { + assertEquals(numFeatures, db.getFeatureStore().getNumRecords()); + assertEquals(exp.spatialMatches, TestSupport.spatialMatches(db)); + assertEquals(exp.keywordMatches, TestSupport.keywordMatches(db)); + assertNotNull(db.getFeatureStore().getCurrentVersion(TestSupport.FOI_UID_PREFIX + 42)); + } + finally + { + db.stop(); + } + } + + + static void assertIdenticalEntries(Path a, Path b, StoreLayout type) throws Exception + { + Path scratch = a.resolveSibling(a.getFileName() + ".cmp-types"); + try (SourceReader.Native ra = new SourceReader.Native(a, 3); + SourceReader.Native rb = new SourceReader.Native(b, 3); + StoreLayout.Types types = StoreLayout.Types.harvest(ra, type, scratch)) + { + var counts = Verifier.compareBytes(ra, rb, types.getSpecs(), java.util.Set.of(), s -> {}); + assertFalse(counts.isEmpty()); + } + } + + + /** + * H2 1.4's MVStoreTool.compact (the old build's compactOnClose) rewrites every map as a + * plain map and drops the type:rtree marker from the metadata. The old datastore never + * looked at it; the migration must not either. + */ + @Test + public void testLegacyFileCompactedByH2v14LosesRTreeMarker() throws Exception + { + Path original = tmp.getRoot().toPath().resolve("compacted.dat"); + Path legacy = tmp.getRoot().toPath().resolve("compacted-v1.dat"); + var exp = TestSupport.populateObsDatabase(original, 40, 500, 5); + TestSupport.exportToLegacy(original, legacy, StoreLayout.OBS_SYSTEM); + + try (LegacyLoader loader = TestSupport.legacyLoader()) + { + var factory = loader.factory(); + try (var before = factory.openReadOnly(legacy.toString())) + { + assertTrue(before.isRTree("foi_store:feature_geom")); + } + factory.compact(legacy.toString()); + assertEquals(1, FileOps.readH2Format(legacy)); + try (var after = factory.openReadOnly(legacy.toString())) + { + assertFalse("1.4 compaction is expected to drop the marker", after.isRTree("foi_store:feature_geom")); + assertNull(after.getMapMeta("foi_store:feature_geom").get("type")); + } + } + + var report = Migrator.run(TestSupport.options(legacy)); + assertTrue(report.instated); + assertEquals((Long)(long)exp.numFois, report.copied.get("foi_store:feature_geom")); + assertEquals((Long)(long)exp.numObs, report.copied.get("obs_store:obs_records")); + + // the migrated file carries the marker again and the spatial index answers as before + try (SourceReader.Native migrated = new SourceReader.Native(legacy, 3)) + { + assertTrue(migrated.isRTree("foi_store:feature_geom")); + } + assertIdenticalEntries(original, legacy, StoreLayout.OBS_SYSTEM); + var db = TestSupport.openObsDatabase(legacy); + try + { + assertEquals(exp.numFois, db.getFoiStore().getNumRecords()); + assertEquals(exp.spatialMatches, TestSupport.spatialMatches(db)); + assertEquals(exp.keywordMatches, TestSupport.keywordMatches(db)); + } + finally + { + db.stop(); + } + } +} diff --git a/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/migrate/TestSupport.java b/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/migrate/TestSupport.java new file mode 100644 index 000000000..6df626b8b --- /dev/null +++ b/sensorhub-datastore-h2/src/test/java/org/sensorhub/impl/datastore/h2/migrate/TestSupport.java @@ -0,0 +1,404 @@ +/***************************** 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 static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Stream; +import javax.xml.namespace.QName; +import org.h2.mvstore.WriteBuffer; +import org.h2.mvstore.type.DataType; +import org.sensorhub.api.command.CommandData; +import org.sensorhub.api.command.CommandStatus; +import org.sensorhub.api.command.CommandStreamInfo; +import org.sensorhub.api.common.BigId; +import org.sensorhub.api.data.DataStreamInfo; +import org.sensorhub.api.data.ObsData; +import org.sensorhub.api.database.IObsSystemDatabase; +import org.sensorhub.api.datastore.feature.FeatureFilter; +import org.sensorhub.api.datastore.feature.FeatureFilterBase; +import org.sensorhub.api.datastore.feature.FoiFilter; +import org.sensorhub.api.datastore.feature.IFeatureStoreBase; +import org.sensorhub.api.feature.FeatureId; +import org.sensorhub.impl.datastore.h2.MVFeatureDatabase; +import org.sensorhub.impl.datastore.h2.MVFeatureDatabaseConfig; +import org.sensorhub.impl.datastore.h2.MVObsSystemDatabase; +import org.sensorhub.impl.datastore.h2.MVObsSystemDatabaseConfig; +import org.sensorhub.impl.system.wrapper.SystemWrapper; +import org.sensorhub.impl.datastore.h2.migrate.spi.LegacyStore; +import org.vast.data.DataBlockDouble; +import org.vast.data.TextEncodingImpl; +import org.vast.ogc.gml.GenericFeatureImpl; +import org.vast.sensorML.SMLHelper; +import org.vast.swe.SWEHelper; +import org.vast.util.Bbox; +import org.vast.util.TimeExtent; +import net.opengis.gml.v32.impl.GMLFactory; + + +/** + * Shared helpers: populate real databases through the store APIs, and export + * an H2 2.5 database to a genuine H2 1.4 (format 1) file through the legacy + * classloader so migration tests run against real legacy files. + */ +final class TestSupport +{ + static final int DATABASE_NUM = 1; + static final String SYS_UID_PREFIX = "urn:osh:test:sensor:"; + static final String FOI_UID_PREFIX = "urn:osh:test:foi:"; + static final Instant T0 = Instant.parse("2024-01-01T00:00:00Z"); + + + /** Everything the population step wrote, so tests can check it survived */ + static final class Expected + { + int numSystems, numSystemVersions, numFois, numDataStreams, numObs, numCommandStreams, numCommands, numStatus; + final Map obsValues = new LinkedHashMap<>(); + BigId dataStreamId; + BigId firstFoiId; + long spatialMatches; // features inside SPATIAL_QUERY, computed on the original database + long keywordMatches; // features matching KEYWORD_QUERY, computed on the original database + } + + static final Bbox SPATIAL_QUERY = new Bbox(-1, -1, 4.6, 2.3); + static final String KEYWORD_QUERY = "number"; + + + @SuppressWarnings({"rawtypes", "unchecked"}) + static long countFeatures(IFeatureStoreBase store, FeatureFilterBase filter) + { + try (Stream s = store.select(filter)) + { + return s.count(); + } + } + + + static long spatialMatches(IObsSystemDatabase db) + { + return countFeatures(db.getFoiStore(), new FoiFilter.Builder().withLocationWithin(SPATIAL_QUERY).build()); + } + + + static long keywordMatches(IObsSystemDatabase db) + { + return countFeatures(db.getFoiStore(), new FoiFilter.Builder().withKeywords(KEYWORD_QUERY).build()); + } + + + static long spatialMatches(MVFeatureDatabase db) + { + return countFeatures(db.getFeatureStore(), new FeatureFilter.Builder().withLocationWithin(SPATIAL_QUERY).build()); + } + + + static long keywordMatches(MVFeatureDatabase db) + { + return countFeatures(db.getFeatureStore(), new FeatureFilter.Builder().withKeywords(KEYWORD_QUERY).build()); + } + + + private TestSupport() + { + } + + + /** Legacy jars come from the module's embedded resources unless a directory is given */ + static LegacyLoader legacyLoader() throws MigrationException + { + return LegacyLoader.createDefault(null); + } + + + static MVObsSystemDatabase openObsDatabase(Path file) throws Exception + { + var db = new MVObsSystemDatabase(); + var config = new MVObsSystemDatabaseConfig(); + config.storagePath = file.toString(); + config.databaseNum = DATABASE_NUM; + db.setConfiguration(config); + db.init(); + db.start(); + return db; + } + + + static MVFeatureDatabase openFeatureDatabase(Path file) throws Exception + { + var db = new MVFeatureDatabase(); + var config = new MVFeatureDatabaseConfig(); + config.storagePath = file.toString(); + config.databaseNum = DATABASE_NUM; + db.setConfiguration(config); + db.init(); + db.start(); + return db; + } + + + /** + * Writes systems, geo-referenced FOIs, a datastream with observations, and a + * command stream with commands and status reports through the public APIs. + */ + static Expected populateObsDatabase(Path file, int numFois, int numObs, int numCommands) throws Exception + { + var exp = new Expected(); + var db = openObsDatabase(file); + try + { + var sml = new SMLHelper(); + BigId[] sysIds = new BigId[3]; + for (int i = 0; i < sysIds.length; i++) + { + var proc = sml.createPhysicalComponent() + .uniqueID(SYS_UID_PREFIX + i) + .name("System " + i) + .description("Test system number " + i) + .build(); + sysIds[i] = db.getSystemDescStore().add(new SystemWrapper(proc)).getInternalID(); + exp.numSystems++; + } + + exp.numSystemVersions = exp.numSystems; + + var gml = new GMLFactory(true); + QName fType = new QName("http://mydomain/features", "MyPointFeature"); + for (int i = 0; i < numFois; i++) + { + var f = new GenericFeatureImpl(fType); + var p = gml.newPoint(); + p.setPos(new double[] {i * 0.5, i * 0.25, 0.0}); + f.setGeometry(p); + f.setId("F" + i); + f.setUniqueIdentifier(FOI_UID_PREFIX + i); + f.setName("Feature " + i); + f.setDescription("Sampling location number " + i); + var key = db.getFoiStore().add(f); + if (i == 0) + exp.firstFoiId = key.getInternalID(); + exp.numFois++; + } + + var swe = new SWEHelper(); + var rec = swe.createRecord().name("out1"); + for (int i = 0; i < 5; i++) + rec.addField("comp" + i, swe.createQuantity().build()); + var dsInfo = new DataStreamInfo.Builder() + .withName("out1") + .withSystem(new FeatureId(sysIds[0], SYS_UID_PREFIX + 0)) + .withRecordDescription(rec.build()) + .withRecordEncoding(new TextEncodingImpl()) + .build(); + exp.dataStreamId = db.getObservationStore().getDataStreams().add(dsInfo).getInternalID(); + exp.numDataStreams++; + + for (int i = 0; i < numObs; i++) + { + var data = new DataBlockDouble(5); + double[] vals = new double[5]; + for (int s = 0; s < 5; s++) + { + vals[s] = i + s * 0.1; + data.setDoubleValue(s, vals[s]); + } + var obs = new ObsData.Builder() + .withDataStream(exp.dataStreamId) + .withFoi(exp.firstFoiId) + .withPhenomenonTime(T0.plusSeconds(i)) + .withResult(data) + .build(); + var key = db.getObservationStore().add(obs); + exp.obsValues.put(key, vals); + exp.numObs++; + } + + var cmdRec = swe.createRecord().name("cmd1"); + for (int i = 0; i < 3; i++) + cmdRec.addField("param" + i, swe.createQuantity().build()); + var csInfo = new CommandStreamInfo.Builder() + .withName("cmd1") + .withSystem(new FeatureId(sysIds[1], SYS_UID_PREFIX + 1)) + .withRecordDescription(cmdRec.build()) + .withRecordEncoding(new TextEncodingImpl()) + .build(); + var csId = db.getCommandStore().getCommandStreams().add(csInfo).getInternalID(); + exp.numCommandStreams++; + + for (int i = 0; i < numCommands; i++) + { + var params = new DataBlockDouble(3); + for (int s = 0; s < 3; s++) + params.setDoubleValue(s, i + s); + var ts = T0.plusSeconds(i); + var cmd = new CommandData.Builder() + .withCommandStream(csId) + .withIssueTime(ts) + .withParams(params) + .withSender("test" + i) + .build(); + var cmdKey = db.getCommandStore().add(cmd); + exp.numCommands++; + db.getCommandStore().getStatusReports().add(CommandStatus.completed(cmdKey, TimeExtent.instant(ts))); + exp.numStatus++; + } + + } + finally + { + db.stop(); + } + + // A second description version of system 0, written in a new session like a node + // restart would. The full-text index compares keys by valid time in memory but does + // not persist it, so the reloaded key compares unequal and a byte-identical duplicate + // entry lands on disk. Real legacy databases show exactly this. + db = openObsDatabase(file); + try + { + var sml = new SMLHelper(); + var proc0v2 = sml.createPhysicalComponent() + .uniqueID(SYS_UID_PREFIX + 0) + .name("System 0") + .description("Test system number 0, updated") + .validFrom(OffsetDateTime.of(2025, 6, 1, 0, 0, 0, 0, ZoneOffset.UTC)) + .build(); + db.getSystemDescStore().add(new SystemWrapper(proc0v2)); + exp.numSystemVersions++; + + exp.spatialMatches = spatialMatches(db); + exp.keywordMatches = keywordMatches(db); + assertTrue("spatial index must select something on the original", exp.spatialMatches > 0); + assertTrue("full-text index must select something on the original", exp.keywordMatches > 0); + } + finally + { + db.stop(); + } + return exp; + } + + + static Expected populateFeatureDatabase(Path file, int numFeatures) throws Exception + { + var exp = new Expected(); + var db = openFeatureDatabase(file); + try + { + var gml = new GMLFactory(true); + QName fType = new QName("http://mydomain/features", "MyPointFeature"); + for (int i = 0; i < numFeatures; i++) + { + var f = new GenericFeatureImpl(fType); + var p = gml.newPoint(); + p.setPos(new double[] {-i, i * 2.0, 0.0}); + f.setGeometry(p); + f.setId("F" + i); + f.setUniqueIdentifier(FOI_UID_PREFIX + i); + f.setName("Feature " + i); + f.setDescription("Generic feature number " + i); + db.getFeatureStore().add(f); + exp.numFois++; + } + exp.spatialMatches = spatialMatches(db); + exp.keywordMatches = keywordMatches(db); + assertTrue("spatial index must select something on the original", exp.spatialMatches > 0); + assertTrue("full-text index must select something on the original", exp.keywordMatches > 0); + } + finally + { + db.stop(); + } + return exp; + } + + + /** + * Reverse direction, test support only: writes every map of an H2 2.5 + * database into a genuine H2 1.4 file with identical entry bytes. + */ + static void exportToLegacy(Path source, Path dest, StoreLayout type) throws Exception + { + Files.deleteIfExists(dest); + LegacyLoader loader = legacyLoader(); + try (SourceReader.Native src = new SourceReader.Native(source, 3); + StoreLayout.Types types = StoreLayout.Types.harvest(src, type, dest.resolveSibling(dest.getFileName() + ".types"))) + { + LegacyStore.Writer writer = loader.factory().openForWrite(dest.toString()); + try + { + for (String name: Migrator.copyOrder(src.getMapNames())) + { + StoreLayout.MapSpec spec = types.get(name); + assertNotNull("no spec for " + name, spec); + if (spec.rtree()) + { + var mw = writer.openRTreeMap(name, spec.dimensions()); + src.readRTreeMap(name, spec.dimensions(), spec.valueType(), (id, mm, v, dv) -> mw.put(id, mm, v)); + } + else + { + // Preserve byte-identical duplicate keys exactly as the datastore leaves them on disk: + // a distinct entry that compares equal is ordered after the existing one instead of + // replacing it, so the legacy fixture carries the same duplicates a real node produced. + var mw = writer.openMap(name, (ka, da, kb, dB) -> { + int c = spec.keyType().compare(da, dB); + return c != 0 ? c : (ka == kb ? 0 : 1); + }); + src.readMap(name, spec.keyType(), spec.valueType(), (k, dk, v, dv) -> mw.put(k, dk, v)); + } + writer.commit(); + } + } + finally + { + writer.close(); + } + } + finally + { + loader.close(); + } + } + + + static byte[] encode(DataType type, Object value) + { + WriteBuffer b = new WriteBuffer(); + type.write(b, value); + ByteBuffer bb = b.getBuffer(); + bb.flip(); + byte[] out = new byte[bb.remaining()]; + bb.get(out); + return out; + } + + + static Migrator.Options options(Path dbFile) + { + var o = new Migrator.Options(); + o.dbFile = dbFile; + o.backup = true; + o.log = s -> System.out.println("[h2migrate] " + s); + return o; + } +}