diff --git a/.gitignore b/.gitignore index ff726989fc8..85507e4e7da 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,5 @@ local.properties *.zip .DS_Store /graph-cache -package-lock.json \ No newline at end of file +package-lock.json +.worktrees/ diff --git a/core/src/main/java/com/graphhopper/GraphHopper.java b/core/src/main/java/com/graphhopper/GraphHopper.java index fdc0fbfc422..37a234612ed 100644 --- a/core/src/main/java/com/graphhopper/GraphHopper.java +++ b/core/src/main/java/com/graphhopper/GraphHopper.java @@ -623,10 +623,22 @@ private void printInfo() { */ public GraphHopper importOrLoad() { if (!load()) { + // ORS-GH MOD START stage marker distinguishing fresh build from load + logger.info("[ORS-BUILD-STAGE] stage=graph_fresh_build action=start osm_file={}", osmFile); + // ORS-GH MOD END printInfo(); process(false); + // ORS-GH MOD START + logger.info("[ORS-BUILD-STAGE] stage=graph_fresh_build action=end nodes={} edges={}", Helper.nf(ghStorage.getNodes()), Helper.nf(ghStorage.getEdges())); + // ORS-GH MOD END } else { + // ORS-GH MOD START + logger.info("[ORS-BUILD-STAGE] stage=graph_load_existing action=start location={}", ghLocation); + // ORS-GH MOD END printInfo(); + // ORS-GH MOD START + logger.info("[ORS-BUILD-STAGE] stage=graph_load_existing action=end nodes={} edges={}", Helper.nf(ghStorage.getNodes()), Helper.nf(ghStorage.getEdges())); + // ORS-GH MOD END } return this; } diff --git a/core/src/main/java/com/graphhopper/reader/osm/OSMNodeData.java b/core/src/main/java/com/graphhopper/reader/osm/OSMNodeData.java index 60ac89d37c0..9ed7032d041 100644 --- a/core/src/main/java/com/graphhopper/reader/osm/OSMNodeData.java +++ b/core/src/main/java/com/graphhopper/reader/osm/OSMNodeData.java @@ -22,22 +22,21 @@ import com.graphhopper.coll.LongIntMap; import com.graphhopper.reader.PillarInfo; import com.graphhopper.reader.ReaderNode; +import com.graphhopper.search.KVStorage; import com.graphhopper.storage.Directory; import com.graphhopper.util.PointAccess; import com.graphhopper.util.PointList; import com.graphhopper.util.shapes.GHPoint3D; -import java.util.ArrayList; import java.util.Collections; -import java.util.List; import java.util.Map; import java.util.function.IntUnaryOperator; -import static java.util.Collections.emptyMap; - /** - * This class stores OSM node data while reading an OSM file in {@link WaySegmentParser}. It is not trivial to do this + * This class stores OSM node data while reading an OSM file in + * {@link WaySegmentParser}. It is not trivial to do this * in a memory-efficient way. We use the following approach: + * *
  * - For each OSM node we store an integer id that points to the nodes coordinates. We use both positive and negative
  *   ids to make use of the full integer range (~4 billion nodes). We separate nodes into (potential) tower nodes and
@@ -52,6 +51,18 @@
  * - We store an additional mapping between OSM node Ids and tag indices that point into a list of node tags. We use
  *   a different mapping, because we store node tags for only a small fraction of all OSM nodes.
  * 
+ * + * ORS-GH BACKPORT (2026-05-29): Barrier node tag storage has been changed from + * {@code List>} to {@link com.graphhopper.search.KVStorage} + * to eliminate + * per-node HashMap overhead. KVStorage is backported from + * upstream graphhopper/graphhopper master and is not yet in our GH fork + * version. + * When upgrading the fork to a GH version that contains KVStorage natively, + * remove all + * {@code ORS-GH BACKPORT} markers in this file plus {@code KVStorage.java} and + * {@code Constants.VERSION_KV_STORAGE} — they will conflict with the upstream + * versions. */ // ORS-GH MOD START expose to ORS public class OSMNodeData { @@ -73,8 +84,13 @@ public class OSMNodeData { // yet and a value of -2 means there was an entry but it was removed again private final LongIntMap nodeTagIndicesByOsmNodeIds; - // stores node tags - private final List> nodeTags; + // ORS-GH BACKPORT START: KVStorage for barrier node tags (backported from graphhopper/graphhopper master, 2026-05-29) + // Replaces the previous List> which caused ~20x per-node heap overhead. + // See KVStorage.java header for full upgrade instructions. + // When upgrading to a GH version that includes KVStorage natively, remove this field + // and all ORS-GH BACKPORT markers in this file, then use the upstream OSMNodeData. + private final KVStorage nodeKVStorage; + // ORS-GH BACKPORT END private int nextTowerId = 0; private int nextPillarId = 0; @@ -90,7 +106,8 @@ public OSMNodeData(PointAccess nodeAccess, Directory directory) { pillarNodes = new PillarInfo(towerNodes.is3D(), directory); nodeTagIndicesByOsmNodeIds = new GHLongIntBTree(200); - nodeTags = new ArrayList<>(); + // ORS-GH BACKPORT: initialise KVStorage for efficient tag serialisation + nodeKVStorage = new KVStorage(directory, false).create(100); } public boolean is3D() { @@ -138,7 +155,8 @@ public long getNodeCount() { * @return the number of nodes for which we store tags */ public long getTaggedNodeCount() { - return nodeTags.size(); + // ORS-GH BACKPORT: nodeTagIndicesByOsmNodeIds tracks entries; nodeTags list is gone + return nodeTagIndicesByOsmNodeIds.getSize(); } /** @@ -241,32 +259,45 @@ public void addCoordinatesToPointList(int id, PointList pointList) { pointList.add(lat, lon, ele); } + // ORS-GH BACKPORT START: KVStorage tag storage public void setTags(ReaderNode node) { int tagIndex = nodeTagIndicesByOsmNodeIds.get(node.getId()); if (tagIndex == -2) throw new IllegalStateException("Cannot add tags after they were removed"); else if (tagIndex == -1) { - nodeTagIndicesByOsmNodeIds.put(node.getId(), nodeTags.size()); - nodeTags.add(node.getTags()); + long pointer = nodeKVStorage.addTags(node.getTags()); + long shiftedPointer = pointer >> KVStorage.ALIGNMENT_SHIFT; + if (shiftedPointer > Integer.MAX_VALUE) + throw new IllegalStateException("Node tag storage overflow, pointer=" + pointer); + nodeTagIndicesByOsmNodeIds.put(node.getId(), (int) shiftedPointer); } else { throw new IllegalStateException("Cannot add tags twice, duplicate node OSM ID: " + node.getId()); } } + // ORS-GH BACKPORT END + // ORS-GH BACKPORT START: KVStorage tag retrieval public Map getTags(long osmNodeId) { - int tagIndex = nodeTagIndicesByOsmNodeIds.get(osmNodeId); - if (tagIndex < 0) + int shiftedIndex = nodeTagIndicesByOsmNodeIds.get(osmNodeId); + if (shiftedIndex < 0) return Collections.emptyMap(); - return nodeTags.get(tagIndex); + long tagIndex = (long) shiftedIndex << KVStorage.ALIGNMENT_SHIFT; + return nodeKVStorage.getMap(tagIndex); } + // ORS-GH BACKPORT END + // ORS-GH BACKPORT START: KVStorage is append-only, mark removed index instead public void removeTags(long osmNodeId) { - int prev = nodeTagIndicesByOsmNodeIds.put(osmNodeId, -2); - nodeTags.set(prev, emptyMap()); + // KVStorage is append-only; the serialised entry stays in storage but will never be + // accessed again because getTags() checks shiftedIndex < 0 first. + nodeTagIndicesByOsmNodeIds.put(osmNodeId, -2); } + // ORS-GH BACKPORT END public void release() { pillarNodes.clear(); + // ORS-GH BACKPORT: free KVStorage backing DataAccess (transient, not persisted) + nodeKVStorage.clear(); } public int towerNodeToId(int towerId) { diff --git a/core/src/main/java/com/graphhopper/routing/lm/LMPreparationHandler.java b/core/src/main/java/com/graphhopper/routing/lm/LMPreparationHandler.java index 7aa7f6b81e1..5a1c6b46d9d 100644 --- a/core/src/main/java/com/graphhopper/routing/lm/LMPreparationHandler.java +++ b/core/src/main/java/com/graphhopper/routing/lm/LMPreparationHandler.java @@ -211,7 +211,15 @@ public boolean loadOrDoWork(List lmConfigs, GraphHopperStorage ghStora LOGGER.info(count + "/" + preparationsToPrepare.size() + " calling LM prepare.doWork for " + prepare.getLMConfig().getWeighting() + " ... (" + getMemInfo() + ")"); prepared.set(true); Thread.currentThread().setName(name); - prepare.doWork(); + // ORS-GH MOD START structured [ORS-LM-CRASH] diagnostic log before re-throw so + // Dagster can identify which profile crashed + try { + prepare.doWork(); + } catch (Exception e) { + LOGGER.error("[ORS-LM-CRASH] LM Preparation crashed for profile=" + name + " weighting=" + prepare.getLMConfig().getWeighting().getName() + " message=" + e.getMessage(), e); + throw e; + } + // ORS-GH MOD END if (closeEarly) prepare.close(); LOGGER.info("LM {} finished {}", name, getMemInfo()); diff --git a/core/src/main/java/com/graphhopper/search/KVStorage.java b/core/src/main/java/com/graphhopper/search/KVStorage.java new file mode 100644 index 00000000000..1d48d25fe24 --- /dev/null +++ b/core/src/main/java/com/graphhopper/search/KVStorage.java @@ -0,0 +1,666 @@ +/* + * Licensed to GraphHopper GmbH under one or more contributor + * license agreements. See the NOTICE file distributed with this work for + * additional information regarding copyright ownership. + * + * GraphHopper GmbH licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except in + * compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// ORS-GH BACKPORT START +// This class is backported from upstream graphhopper/graphhopper (master branch, 2026-05-29). +// It is used in OSMNodeData to replace heap-intensive List> for barrier +// node tag storage during OSM import. The upstream change paired KVStorage with a full +// int-to-long node ID migration; this backport uses only the KVStorage class itself, which +// is self-contained and does not require that migration. +// +// *** UPGRADE WARNING *** +// When upgrading the ORS GraphHopper fork to a GH version that already includes KVStorage +// natively (i.e., you see 'package com.graphhopper.search; class KVStorage' in upstream), +// you MUST delete this file — do NOT merge or overwrite. The upstream class should take +// precedence. Keeping both will cause a duplicate class conflict at compile time. +// Also delete Constants.VERSION_KV_STORAGE added alongside this backport. +// Affected files: KVStorage.java, Constants.java, OSMNodeData.java (marked with +// ORS-GH BACKPORT START/END). +// ORS-GH BACKPORT END +package com.graphhopper.search; + +import com.graphhopper.storage.DataAccess; +import com.graphhopper.storage.Directory; +import com.graphhopper.util.BitUtil; +import com.graphhopper.util.Constants; +import com.graphhopper.util.GHUtility; +import com.graphhopper.util.Helper; + +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * This class stores key-value pairs in an append-only manner. + * + * @author Peter Karich + */ +public class KVStorage { + + private static final long EMPTY_POINTER = 0; + // Align entries to 4-byte boundaries. This allows callers to store pointer >> 2 externally, + // giving 4x the addressable space when storing pointers as unsigned int (~16GB instead of ~4GB). + // Callers are expected to shift the pointer themselves: >> 2 when storing, << 2 when retrieving. + private static final int ALIGNMENT = 4; + /** + * The alignment shift for pointers returned by {@link #add}. Callers should use + * {@code pointer >> ALIGNMENT_SHIFT} when storing and {@code pointer << ALIGNMENT_SHIFT} when retrieving. + */ + public static final int ALIGNMENT_SHIFT = 2; + private static final long START_POINTER = ALIGNMENT; + // Store the key index in 2 bytes. Use first 2 bits for marking fwd+bwd existence. + static final int MAX_UNIQUE_KEYS = (1 << 14); + // Store string value as byte array and store the length into 1 byte + private static final int MAX_LENGTH = (1 << 8) - 1; + + private final Directory dir; + // It stores the mapping of "key to index" in the keys DataAccess. E.g. if your first key is "some" then we will + // store the mapping "1->some" there (the 0th index is skipped on purpose). As this map is 'small' the keys + // DataAccess is only used for long term storage, i.e. only in loadExisting and flush. For add and getAll we use + // keyToIndex, indexToClass and indexToClass. + private final DataAccess keys; + + // The storage layout in the vals DataAccess for one Map of key-value pairs. For example the map: + // map = new HashMap(); map.put("some", "value"); map.put("some2", "value2"); is added via the method add, then we store: + // 2 (the size of the Map, 1 byte) + // --- now the first key-value pair: + // 1 (the keys index for "some", 2 byte) + // 4 (the length of the bytes from "some") + // "some" (the bytes from "some") + // --- second key-value pair: + // 2 (the keys index for "some2") + // 5 (the length of the bytes from "some2") + // "some2" (the bytes from "some2") + + // So more generic: the values could be of dynamic length, fixed length like int or be duplicates: + // vals count (1 byte) + // --- 1. key-value pair (store String or byte[] with dynamic length) + // key_idx_0 (2 byte, of which the first 2bits are to know if this is valid for fwd and/or bwd direction) + // val_length_0 (1 byte) + // val_0 (x bytes) + // --- 2. key-value pair (store int with fixed length) + // key_idx_1 (2 byte) + // int (4 byte) + // + // Notes: + // 1. The key strings are limited MAX_UNIQUE_KEYS. A dynamic value has a maximum byte length of 255. + // 2. Every key can store values only of the same type + // 3. We need to loop through X entries to get the start val_x. + // 4. The key index (14 bits) is stored along with the availability (2 bits), i.e. whether they KeyValue is available in forward and/or backward directions + private final DataAccess vals; + private final Map keyToIndex = new HashMap<>(); + private final List> indexToClass = new ArrayList<>(); + private final List indexToKey = new ArrayList<>(); + private final BitUtil bitUtil = BitUtil.LITTLE; + private long bytePointer = START_POINTER; + private long lastEntryPointer = -1; + private Map lastEntries; + // separate last-entry tracking for addTags (plain Map) + private long lastTagEntryPointer = -1; + private Map lastTagEntries; + + /** + * Specify a larger cacheSize to reduce disk usage. Note that this increases the memory usage of this object. + */ + public KVStorage(Directory dir, boolean edge) { + this.dir = dir; + if (edge) { + this.keys = dir.create("edgekv_keys", 10 * 1024); + this.vals = dir.create("edgekv_vals"); + } else { + this.keys = dir.create("nodekv_keys", 10 * 1024); + this.vals = dir.create("nodekv_vals"); + } + } + + public KVStorage create(long initBytes) { + keys.create(initBytes); + vals.create(initBytes); + // add special empty case to have a reliable duplicate detection via negative keyIndex + keyToIndex.put("", 0); + indexToKey.add(""); + indexToClass.add(String.class); + return this; + } + + public boolean loadExisting() { + if (vals.loadExisting()) { + if (!keys.loadExisting()) + throw new IllegalStateException("Loaded values but cannot load keys"); + bytePointer = bitUtil.toLong(vals.getHeader(0), vals.getHeader(4)); + GHUtility.checkDAVersion(vals.getName(), Constants.VERSION_KV_STORAGE, vals.getHeader(8)); + GHUtility.checkDAVersion(keys.getName(), Constants.VERSION_KV_STORAGE, keys.getHeader(0)); + + // load keys into memory + int count = keys.getShort(0); + long keyBytePointer = 2; + for (int i = 0; i < count; i++) { + int keyLength = keys.getShort(keyBytePointer); + keyBytePointer += 2; + byte[] keyBytes = new byte[keyLength]; + keys.getBytes(keyBytePointer, keyBytes, keyLength); + String valueStr = new String(keyBytes, Helper.UTF_CS); + keyBytePointer += keyLength; + + keyToIndex.put(valueStr, keyToIndex.size()); + indexToKey.add(valueStr); + + int shortClassNameLength = 1; + byte[] classBytes = new byte[shortClassNameLength]; + keys.getBytes(keyBytePointer, classBytes, shortClassNameLength); + keyBytePointer += shortClassNameLength; + indexToClass.add(shortNameToClass(new String(classBytes, Helper.UTF_CS))); + } + return true; + } + + return false; + } + + Collection getKeys() { + return indexToKey; + } + + /** + * Writes the entries of a plain {@code Map} into the storage, starting after the + * count byte. All entries are stored as forward-and-backward-equal (no direction distinction). + * Null values and null keys are silently skipped. String values are automatically truncated via + * {@link #cutString(String)}. + *

+ * ORS-GH BACKPORT: introduced alongside {@link #addTags(Map)} to avoid per-node KValue allocation + * overhead when writing OSM barrier node tags. + */ + private long setTagsList(long currentPointer, final Map tags) { + if (currentPointer == EMPTY_POINTER) return currentPointer; + currentPointer += 1; // skip stored count + for (Map.Entry entry : tags.entrySet()) { + if (entry.getKey() == null || entry.getValue() == null) continue; + Object value = entry.getValue() instanceof String + ? cutString((String) entry.getValue()) + : entry.getValue(); + currentPointer = add(currentPointer, entry.getKey(), value, true, true); + } + return currentPointer; + } + + private long setKVList(long currentPointer, final Map entries) { + if (currentPointer == EMPTY_POINTER) return currentPointer; + currentPointer += 1; // skip stored count + for (Map.Entry entry : entries.entrySet()) { + if (entry.getValue().fwdBwdEqual) { + currentPointer = add(currentPointer, entry.getKey(), entry.getValue().fwdValue, true, true); + } else { + // potentially add two internal values + if (entry.getValue().fwdValue != null) + currentPointer = add(currentPointer, entry.getKey(), entry.getValue().fwdValue, true, false); + if (entry.getValue().bwdValue != null) + currentPointer = add(currentPointer, entry.getKey(), entry.getValue().bwdValue, false, true); + } + + } + return currentPointer; + } + + long add(long currentPointer, String key, Object value, boolean fwd, boolean bwd) { + if (key == null) throw new IllegalArgumentException("key cannot be null"); + if (value == null) + throw new IllegalArgumentException("value for key " + key + " cannot be null"); + + Integer keyIndex = keyToIndex.get(key); + Class clazz; + if (keyIndex == null) { + keyIndex = keyToIndex.size(); + if (keyIndex >= MAX_UNIQUE_KEYS) + throw new IllegalArgumentException("Cannot store more than " + MAX_UNIQUE_KEYS + " unique keys"); + keyToIndex.put(key, keyIndex); + indexToKey.add(key); + indexToClass.add(clazz = value.getClass()); + } else { + clazz = indexToClass.get(keyIndex); + if (clazz != value.getClass()) + throw new IllegalArgumentException("Class of value for key " + key + " must be " + + clazz.getSimpleName() + " but was " + value.getClass().getSimpleName()); + } + + boolean hasDynLength = hasDynLength(clazz); + if (hasDynLength) { + // optimization for empty string or empty byte array + if (clazz.equals(String.class) && ((String) value).isEmpty() + || clazz.equals(byte[].class) && ((byte[]) value).length == 0) { + vals.ensureCapacity(currentPointer + 3); + vals.setShort(currentPointer, keyIndex.shortValue()); + // ensure that also in case of MMap value is set to 0 + vals.setByte(currentPointer + 2, (byte) 0); + return currentPointer + 3; + } + } + + final byte[] valueBytes = getBytesForValue(clazz, value); + vals.ensureCapacity(currentPointer + 2 + 1 + valueBytes.length); + vals.setShort(currentPointer, (short) (keyIndex << 2 | (fwd ? 2 : 0) | (bwd ? 1 : 0))); + currentPointer += 2; + if (hasDynLength) { + vals.setByte(currentPointer, (byte) valueBytes.length); + currentPointer++; + } + vals.setBytes(currentPointer, valueBytes, valueBytes.length); + return currentPointer + valueBytes.length; + } + + /** + * This method writes the specified entryMap (key-value pairs) into the storage. Please note that null keys or null + * values are rejected. The Class of a value can be only: byte[], String, int, long, float or double + * (or more precisely, their wrapper equivalent). For all other types an exception is thrown. The first call of add + * assigns a Class to every key in the Map and future calls of add will throw an exception if this Class differs. + * + * @return entryPointer with which you can later fetch the entryMap via the get or getAll method + */ + public long add(final Map entries) { + if (entries == null) throw new IllegalArgumentException("specified List must not be null"); + if (entries.isEmpty()) return EMPTY_POINTER; + else if (entries.size() > 200) + throw new IllegalArgumentException("Cannot store more than 200 entries per entry"); + + // This is a very important "compression" mechanism because one OSM way is split into multiple edges and so we + // can often re-use the serialized key-value pairs of the previous edge. + if (entries.equals(lastEntries)) return lastEntryPointer; + + int entryCount = 0; + for (Map.Entry kv : entries.entrySet()) { + + if (kv.getValue().fwdBwdEqual) { + entryCount++; + } else { + // note, if fwd and bwd are different we create two internal entries! + if (kv.getValue().getFwd() != null) entryCount++; + if (kv.getValue().getBwd() != null) entryCount++; + } + + // If the Class of a value is unknown it should already fail here, before we modify internal data. (see #2597#discussion_r896469840) + if (keyToIndex.get(kv.getKey()) != null) { + if (kv.getValue().fwdValue != null) + getBytesForValue(indexToClass.get(keyToIndex.get(kv.getKey())), kv.getValue().fwdValue); + if (kv.getValue().bwdValue != null) + getBytesForValue(indexToClass.get(keyToIndex.get(kv.getKey())), kv.getValue().bwdValue); + } + } + + lastEntries = entries; + lastEntryPointer = bytePointer; + vals.ensureCapacity(bytePointer + 1); + vals.setByte(bytePointer, (byte) entryCount); + bytePointer = setKVList(bytePointer, entries); + if (bytePointer < 0) + throw new IllegalStateException("Negative bytePointer in KVStorage"); + // Pad to next alignment boundary + long remainder = bytePointer % ALIGNMENT; + if (remainder != 0) + bytePointer += ALIGNMENT - remainder; + return lastEntryPointer; + } + + /** + * Writes the specified plain tag map into the storage without requiring the caller to wrap each + * value in {@link KValue}. All values are stored forward-and-backward-equal. Null values are + * silently ignored. String values are automatically truncated via {@link #cutString(String)}. + *

+ * Duplicate detection is performed: if the supplied map is equal to the previous call's map the + * same pointer is returned without writing again, analogous to {@link #add(Map)}. + *

+ * ORS-GH BACKPORT: reduces per-barrier-node heap allocation in OSMNodeData by avoiding a + * temporary {@code Map}. + * + * @return entry pointer (aligned to {@link #ALIGNMENT}) to later retrieve data via + * {@link #getMap(long)} or {@link #getAll(long)} + */ + public long addTags(final Map tags) { + if (tags == null) throw new IllegalArgumentException("specified Map must not be null"); + if (tags.size() > 200) + throw new IllegalArgumentException("Cannot store more than 200 entries per entry"); + + // Duplicate detection — avoids re-serialising identical tag sets + if (tags.equals(lastTagEntries)) return lastTagEntryPointer; + + // Count non-null entries and pre-validate value types + int count = 0; + for (Map.Entry entry : tags.entrySet()) { + if (entry.getKey() == null || entry.getValue() == null) continue; + Object value = entry.getValue() instanceof String + ? cutString((String) entry.getValue()) + : entry.getValue(); + Integer ki = keyToIndex.get(entry.getKey()); + if (ki != null) + getBytesForValue(indexToClass.get(ki), value); + count++; + } + + if (count == 0) return EMPTY_POINTER; + + lastTagEntries = tags; + lastTagEntryPointer = bytePointer; + vals.ensureCapacity(bytePointer + 1); + vals.setByte(bytePointer, (byte) count); + bytePointer = setTagsList(bytePointer, tags); + if (bytePointer < 0) + throw new IllegalStateException("Negative bytePointer in KVStorage"); + // Pad to next alignment boundary + long remainder = bytePointer % ALIGNMENT; + if (remainder != 0) + bytePointer += ALIGNMENT - remainder; + return lastTagEntryPointer; + } + + public Map getAll(final long entryPointer) { + if (entryPointer < 0) + throw new IllegalStateException("Pointer to access KVStorage cannot be negative:" + entryPointer); + + if (entryPointer == EMPTY_POINTER) return Collections.emptyMap(); + + int keyCount = vals.getByte(entryPointer) & 0xFF; + if (keyCount == 0) return Collections.emptyMap(); + + Map map = new LinkedHashMap<>(); + long tmpPointer = entryPointer + 1; + AtomicInteger sizeOfObject = new AtomicInteger(); + for (int i = 0; i < keyCount; i++) { + int currentKeyIndexRaw = Short.toUnsignedInt(vals.getShort(tmpPointer)); + boolean bwd = (currentKeyIndexRaw & 1) == 1; + boolean fwd = (currentKeyIndexRaw & 2) == 2; + int currentKeyIndex = currentKeyIndexRaw >>> 2; + tmpPointer += 2; + + Object object = deserializeObj(sizeOfObject, tmpPointer, indexToClass.get(currentKeyIndex)); + tmpPointer += sizeOfObject.get(); + String key = indexToKey.get(currentKeyIndex); + KValue oldValue = map.get(key); + if (oldValue != null) + map.put(key, new KValue(fwd ? object : oldValue.fwdValue, bwd ? object : oldValue.bwdValue)); + else if (fwd && bwd) + map.put(key, new KValue(object)); + else + map.put(key, new KValue(fwd ? object : null, bwd ? object : null)); + } + + return map; + } + + /** + * Please note that this method ignores potentially different tags for forward and backward direction. To avoid this + * use {@link #getAll(long)} instead. + */ + public Map getMap(final long entryPointer) { + if (entryPointer < 0) + throw new IllegalStateException("Pointer to access KVStorage cannot be negative:" + entryPointer); + + if (entryPointer == EMPTY_POINTER) return Collections.emptyMap(); + + int keyCount = vals.getByte(entryPointer) & 0xFF; + if (keyCount == 0) return Collections.emptyMap(); + + HashMap map = new HashMap<>(keyCount); + long tmpPointer = entryPointer + 1; + AtomicInteger sizeOfObject = new AtomicInteger(); + for (int i = 0; i < keyCount; i++) { + int currentKeyIndexRaw = Short.toUnsignedInt(vals.getShort(tmpPointer)); + int currentKeyIndex = currentKeyIndexRaw >>> 2; + tmpPointer += 2; + + Object object = deserializeObj(sizeOfObject, tmpPointer, indexToClass.get(currentKeyIndex)); + tmpPointer += sizeOfObject.get(); + String key = indexToKey.get(currentKeyIndex); + map.put(key, object); + } + + return map; + } + + private boolean hasDynLength(Class clazz) { + return clazz.equals(String.class) || clazz.equals(byte[].class); + } + + private int getFixLength(Class clazz) { + if (clazz.equals(Integer.class) || clazz.equals(Float.class)) return 4; + else if (clazz.equals(Long.class) || clazz.equals(Double.class)) return 8; + else throw new IllegalArgumentException("unknown class " + clazz); + } + + private byte[] getBytesForValue(Class clazz, Object value) { + byte[] bytes; + if (clazz.equals(String.class)) { + bytes = ((String) value).getBytes(Helper.UTF_CS); + if (bytes.length > MAX_LENGTH) + throw new IllegalArgumentException("bytes.length cannot be > " + MAX_LENGTH + " but was " + bytes.length + ". String:" + value); + } else if (clazz.equals(byte[].class)) { + bytes = (byte[]) value; + if (bytes.length > MAX_LENGTH) + throw new IllegalArgumentException("bytes.length cannot be > " + MAX_LENGTH + " but was " + bytes.length); + } else if (clazz.equals(Integer.class)) { + return bitUtil.fromInt((int) value); + } else if (clazz.equals(Long.class)) { + return bitUtil.fromLong((long) value); + } else if (clazz.equals(Float.class)) { + return bitUtil.fromFloat((float) value); + } else if (clazz.equals(Double.class)) { + return bitUtil.fromDouble((double) value); + } else + throw new IllegalArgumentException("The Class of a value was " + clazz.getSimpleName() + ", currently supported: byte[], String, int, long, float and double"); + return bytes; + } + + private String classToShortName(Class clazz) { + if (clazz.equals(String.class)) return "S"; + else if (clazz.equals(Integer.class)) return "i"; + else if (clazz.equals(Long.class)) return "l"; + else if (clazz.equals(Float.class)) return "f"; + else if (clazz.equals(Double.class)) return "d"; + else if (clazz.equals(byte[].class)) return "["; + else throw new IllegalArgumentException("Cannot find short name. Unknown class " + clazz); + } + + private Class shortNameToClass(String name) { + if (name.equals("S")) return String.class; + else if (name.equals("i")) return Integer.class; + else if (name.equals("l")) return Long.class; + else if (name.equals("f")) return Float.class; + else if (name.equals("d")) return Double.class; + else if (name.equals("[")) return byte[].class; + else throw new IllegalArgumentException("Cannot find class. Unknown short name " + name); + } + + /** + * This method creates an Object (type Class) which is located at the specified pointer + */ + private Object deserializeObj(AtomicInteger sizeOfObject, long pointer, Class clazz) { + if (hasDynLength(clazz)) { + int valueLength = vals.getByte(pointer) & 0xFF; + pointer++; + byte[] valueBytes = new byte[valueLength]; + vals.getBytes(pointer, valueBytes, valueBytes.length); + if (sizeOfObject != null) + sizeOfObject.set(1 + valueLength); // For String and byte[] we store the length and the value + if (clazz.equals(String.class)) return new String(valueBytes, Helper.UTF_CS); + else if (clazz.equals(byte[].class)) return valueBytes; + throw new IllegalArgumentException(); + } else { + byte[] valueBytes = new byte[getFixLength(clazz)]; + vals.getBytes(pointer, valueBytes, valueBytes.length); + if (clazz.equals(Integer.class)) { + if (sizeOfObject != null) sizeOfObject.set(4); + return bitUtil.toInt(valueBytes, 0); + } else if (clazz.equals(Long.class)) { + if (sizeOfObject != null) sizeOfObject.set(8); + return bitUtil.toLong(valueBytes, 0); + } else if (clazz.equals(Float.class)) { + if (sizeOfObject != null) sizeOfObject.set(4); + return bitUtil.toFloat(valueBytes, 0); + } else if (clazz.equals(Double.class)) { + if (sizeOfObject != null) sizeOfObject.set(8); + return bitUtil.toDouble(valueBytes, 0); + } else { + throw new IllegalArgumentException("unknown class " + clazz); + } + } + } + + public Object get(final long entryPointer, String key, boolean reverse) { + if (entryPointer < 0) + throw new IllegalStateException("Pointer to access KVStorage cannot be negative:" + entryPointer); + + if (entryPointer == EMPTY_POINTER) return null; + + Integer keyIndex = keyToIndex.get(key); + if (keyIndex == null) return null; // key wasn't stored before + + int keyCount = vals.getByte(entryPointer) & 0xFF; + if (keyCount == 0) return null; // no entries + + long tmpPointer = entryPointer + 1; + for (int i = 0; i < keyCount; i++) { + int currentKeyIndexRaw = Short.toUnsignedInt(vals.getShort(tmpPointer)); + boolean bwd = (currentKeyIndexRaw & 1) == 1; + boolean fwd = (currentKeyIndexRaw & 2) == 2; + int currentKeyIndex = currentKeyIndexRaw >>> 2; + + assert currentKeyIndex < indexToKey.size() : "invalid key index " + currentKeyIndex + + ">=" + indexToKey.size() + ", entryPointer=" + entryPointer + ", max=" + bytePointer; + tmpPointer += 2; + if ((!reverse && fwd || reverse && bwd) && currentKeyIndex == keyIndex) { + return deserializeObj(null, tmpPointer, indexToClass.get(keyIndex)); + } + + // skip to next entry of same edge via skipping the real value + Class clazz = indexToClass.get(currentKeyIndex); + int valueLength = hasDynLength(clazz) ? 1 + vals.getByte(tmpPointer) & 0xFF : getFixLength(clazz); + tmpPointer += valueLength; + } + + // value for specified key does not exist for the specified pointer + return null; + } + + public void flush() { + keys.ensureCapacity(2); + keys.setShort(0, (short) keyToIndex.size()); + long keyBytePointer = 2; + for (int i = 0; i < indexToKey.size(); i++) { + String key = indexToKey.get(i); + byte[] keyBytes = getBytesForValue(String.class, key); + keys.ensureCapacity(keyBytePointer + 2 + keyBytes.length); + keys.setShort(keyBytePointer, (short) keyBytes.length); + keyBytePointer += 2; + keys.setBytes(keyBytePointer, keyBytes, keyBytes.length); + keyBytePointer += keyBytes.length; + + Class clazz = indexToClass.get(i); + byte[] clazzBytes = getBytesForValue(String.class, classToShortName(clazz)); + if (clazzBytes.length != 1) + throw new IllegalArgumentException("class name byte length must be 1 but was " + clazzBytes.length); + keys.ensureCapacity(keyBytePointer + 1); + keys.setBytes(keyBytePointer, clazzBytes, 1); + keyBytePointer += 1; + } + keys.setHeader(0, Constants.VERSION_KV_STORAGE); + keys.flush(); + + vals.setHeader(0, bitUtil.getIntLow(bytePointer)); + vals.setHeader(4, bitUtil.getIntHigh(bytePointer)); + vals.setHeader(8, Constants.VERSION_KV_STORAGE); + vals.flush(); + } + + public void clear() { + // ORS-GH BACKPORT: upstream uses dir.remove(String name) but our fork's Directory.remove() takes DataAccess + dir.remove(keys); + dir.remove(vals); + } + + public void close() { + keys.close(); + vals.close(); + } + + public boolean isClosed() { + return vals.isClosed() && keys.isClosed(); + } + + public long getCapacity() { + return vals.getCapacity() + keys.getCapacity(); + } + + public static class KValue { + private final Object fwdValue; + private final Object bwdValue; + final boolean fwdBwdEqual; + + public KValue(Object obj) { + if (obj == null) + throw new IllegalArgumentException("Object cannot be null if forward and backward is both true"); + fwdValue = bwdValue = obj; + fwdBwdEqual = true; + } + + public KValue(Object fwd, Object bwd) { + fwdValue = fwd; + bwdValue = bwd; + if (fwdValue != null && bwdValue != null && fwd.getClass() != bwd.getClass()) + throw new IllegalArgumentException("If both values are not null they have to be they same class but was: " + + fwdValue.getClass() + " vs " + bwdValue.getClass()); + if (fwdValue == null && bwdValue == null) + throw new IllegalArgumentException("If both values are null just do not store them"); + fwdBwdEqual = false; + } + + public Object getFwd() { + return fwdValue; + } + + public Object getBwd() { + return bwdValue; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + KValue value = (KValue) o; + // due to check in constructor we can assume that fwdValue and bwdValue are of same type. + // I.e. if one is a byte array the other is too. + if (fwdValue instanceof byte[] || bwdValue instanceof byte[]) + return fwdBwdEqual == value.fwdBwdEqual && (Arrays.equals((byte[]) fwdValue, (byte[]) value.fwdValue) || Arrays.equals((byte[]) bwdValue, (byte[]) value.bwdValue)); + + return fwdBwdEqual == value.fwdBwdEqual && Objects.equals(fwdValue, value.fwdValue) && Objects.equals(bwdValue, value.bwdValue); + } + + @Override + public int hashCode() { + return Objects.hash(fwdValue, bwdValue, fwdBwdEqual); + } + + @Override + public String toString() { + return fwdBwdEqual ? fwdValue.toString() : fwdValue + " | " + bwdValue; + } + } + + /** + * This method limits the specified String value to the length currently accepted for values in the KVStorage. + */ + public static String cutString(String value) { + byte[] bytes = value.getBytes(Helper.UTF_CS); + // See #2609 and test why we use a value < 255 + return bytes.length > 250 ? new String(bytes, 0, 250, Helper.UTF_CS) : value; + } +} diff --git a/core/src/main/java/com/graphhopper/util/Constants.java b/core/src/main/java/com/graphhopper/util/Constants.java index 2f32414bb91..3ac8275a301 100644 --- a/core/src/main/java/com/graphhopper/util/Constants.java +++ b/core/src/main/java/com/graphhopper/util/Constants.java @@ -73,6 +73,10 @@ public class Constants { public static final int VERSION_GEOMETRY = 6; public static final int VERSION_LOCATION_IDX = 5; public static final int VERSION_STRING_IDX = 6; + // ORS-GH BACKPORT START: KVStorage (from graphhopper/graphhopper master, 2026-05-29) + // Remove this constant when upgrading to a GH version that includes KVStorage natively. + public static final int VERSION_KV_STORAGE = 1; + // ORS-GH BACKPORT END /** * The version without the snapshot string */ diff --git a/core/src/test/java/com/graphhopper/search/KVStorageTest.java b/core/src/test/java/com/graphhopper/search/KVStorageTest.java new file mode 100644 index 00000000000..e5bd29fbff7 --- /dev/null +++ b/core/src/test/java/com/graphhopper/search/KVStorageTest.java @@ -0,0 +1,602 @@ +// ORS-GH BACKPORT START +// This test class is backported from upstream graphhopper/graphhopper (master branch, 2026-05-29) +// to accompany the backported KVStorage class in com.graphhopper.search. +// +// *** UPGRADE WARNING *** +// When upgrading the ORS GraphHopper fork to a GH version that includes KVStorage natively, +// delete this file along with KVStorage.java, Constants.VERSION_KV_STORAGE, and the +// ORS-GH BACKPORT markers in OSMNodeData.java. +// ORS-GH BACKPORT END +package com.graphhopper.search; + +import com.carrotsearch.hppc.LongArrayList; +import com.graphhopper.search.KVStorage.KValue; +import com.graphhopper.storage.DAType; +import com.graphhopper.storage.GHDirectory; +import com.graphhopper.util.Helper; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.*; + +import static com.graphhopper.search.KVStorage.MAX_UNIQUE_KEYS; +import static com.graphhopper.search.KVStorage.cutString; +import static com.graphhopper.util.Helper.UTF_CS; +import static org.junit.jupiter.api.Assertions.*; + +public class KVStorageTest { + + private final static String location = "./target/edge-kv-storage"; + + private KVStorage create() { + return new KVStorage(new GHDirectory("", DAType.RAM), true).create(1000); + } + + Map createMap(Object... keyValues) { + if (keyValues.length % 2 != 0) + throw new IllegalArgumentException("Cannot create list from " + Arrays.toString(keyValues)); + Map map = new LinkedHashMap<>(); + for (int i = 0; i < keyValues.length; i += 2) { + map.put((String) keyValues[i], new KValue(keyValues[i + 1])); + } + return map; + } + + @Test + public void putSame() { + KVStorage index = create(); + long aPointer = index.add(createMap("a", "same name", "b", "same name")); + + assertNull(index.get(aPointer, "", false)); + assertEquals("same name", index.get(aPointer, "a", false)); + assertEquals("same name", index.get(aPointer, "b", false)); + assertNull(index.get(aPointer, "c", false)); + + index = create(); + aPointer = index.add(createMap("a", "a name", "b", "same name")); + assertEquals("a name", index.get(aPointer, "a", false)); + } + + @Test + public void putAB() { + KVStorage index = create(); + long aPointer = index.add(createMap("a", "a name", "b", "b name")); + + assertNull(index.get(aPointer, "", false)); + assertEquals("a name", index.get(aPointer, "a", false)); + assertEquals("b name", index.get(aPointer, "b", false)); + } + + @Test + public void getForwardBackward() { + KVStorage index = create(); + Map map = new LinkedHashMap<>(); + map.put("keyA", new KValue("FORWARD", null)); + map.put("keyB", new KValue(null, "BACKWARD")); + map.put("keyC", new KValue("BOTH")); + map.put("keyD", new KValue("BOTH1", "BOTH2")); + long aPointer = index.add(map); + + assertNull(index.get(aPointer, "", false)); + Map deserializedList = index.getAll(aPointer); + assertEquals(map, deserializedList); + + assertEquals("FORWARD", index.get(aPointer, "keyA", false)); + assertNull(index.get(aPointer, "keyA", true)); + + assertNull(index.get(aPointer, "keyB", false)); + assertEquals("BACKWARD", index.get(aPointer, "keyB", true)); + + assertEquals("BOTH", index.get(aPointer, "keyC", false)); + assertEquals("BOTH", index.get(aPointer, "keyC", true)); + + assertEquals("BOTH1", index.get(aPointer, "keyD", false)); + assertEquals("BOTH2", index.get(aPointer, "keyD", true)); + } + + @Test + public void putEmpty() { + KVStorage index = create(); + long emptyKeyPointer = index.add(createMap("", "")); + // First pointer should be at START_POINTER (aligned to 4) + assertEquals(4, emptyKeyPointer); + // cannot store null (in its first version we accepted null once it was clear which type the value has, but this is inconsequential) + assertThrows(IllegalArgumentException.class, () -> index.add(createMap("", null))); + assertThrows(IllegalArgumentException.class, () -> index.add(createMap("blup", null))); + assertThrows(IllegalArgumentException.class, () -> index.add(createMap(null, null))); + + assertNull(index.get(0, "", false)); + + long elsePointer = index.add(createMap("else", "else")); + assertTrue(elsePointer > emptyKeyPointer, "second pointer should be larger than first"); + assertEquals("else", index.get(elsePointer, "else", false)); + } + + @Test + public void putMany() { + KVStorage index = create(); + long aPointer = 0, tmpPointer = 0; + + for (int i = 0; i < 10000; i++) { + aPointer = index.add(createMap("a", "a name " + i, "b", "b name " + i, "c", "c name " + i)); + if (i == 567) + tmpPointer = aPointer; + } + + assertEquals("b name 9999", index.get(aPointer, "b", false)); + assertEquals("c name 9999", index.get(aPointer, "c", false)); + + assertEquals("a name 567", index.get(tmpPointer, "a", false)); + assertEquals("b name 567", index.get(tmpPointer, "b", false)); + assertEquals("c name 567", index.get(tmpPointer, "c", false)); + } + + @Test + public void putManyKeys() { + KVStorage index = create(); + // one key is already stored => empty key + for (int i = 1; i < MAX_UNIQUE_KEYS; i++) { + index.add(createMap("a" + i, "a name")); + } + try { + index.add(createMap("new", "a name")); + fail(); + } catch (IllegalArgumentException ex) { + } + } + + @Test + public void testHighKeyIndicesUpToDesignLimit() { + // This test verifies that key indices >= 8192 work correctly. + // Previously there was a sign extension bug when reading shorts for key indices >= 8192 + // because (keyIndex << 2) exceeds 32767 and becomes negative when stored as a signed short. + KVStorage index = create(); + + // Create MAX_UNIQUE_KEYS - 1 unique keys (index 0 is reserved for empty key) + // This gives us key indices from 1 to MAX_UNIQUE_KEYS - 1 (i.e., 1 to 16383) + List pointers = new ArrayList<>(); + for (int i = 1; i < MAX_UNIQUE_KEYS; i++) { + long pointer = index.add(createMap("key" + i, "value" + i)); + pointers.add(pointer); + } + + // Verify we can read back entries that use high key indices (>= 8192) + // Key index 8192 is the first one that triggers the sign extension issue + for (int i = 8192; i < MAX_UNIQUE_KEYS; i++) { + long pointer = pointers.get(i - 1); // pointers list is 0-indexed, keys start at 1 + String expectedKey = "key" + i; + String expectedValue = "value" + i; + + // Test get() method + assertEquals(expectedValue, index.get(pointer, expectedKey, false), + "get() failed for key index " + i); + + // Test getMap() method + Map map = index.getMap(pointer); + assertEquals(expectedValue, map.get(expectedKey), + "getMap() failed for key index " + i); + + // Test getAll() method + Map allMap = index.getAll(pointer); + assertEquals(expectedValue, allMap.get(expectedKey).getFwd(), + "getAll() failed for key index " + i); + } + } + + @Test + public void testNoErrorOnLargeStringValue() { + KVStorage index = create(); + String str = ""; + for (int i = 0; i < 127; i++) { + str += "ß"; + } + assertEquals(254, str.getBytes(Helper.UTF_CS).length); + long result = index.add(createMap("", str)); + assertEquals(127, ((String) index.get(result, "", false)).length()); + } + + @Test + public void testTooLongStringValueError() { + KVStorage index = create(); + assertThrows(IllegalArgumentException.class, () -> index.add(createMap("", "Бухарестская улица (http://ru.wikipedia.org/wiki/" + + "%D0%91%D1%83%D1%85%D0%B0%D1%80%D0%B5%D1%81%D1%82%D1%81%D0%BA%D0%B0%D1%8F_%D1%83%D0%BB%D0%B8%D1%86%D0%B0_(%D0%A1%D0%B0%D0%BD%D0%BA%D1%82-%D0%9F%D0%B5%D1%82%D0%B5%D1%80%D0%B1%D1%83%D1%80%D0%B3))"))); + + String str = "sdfsdfds"; + for (int i = 0; i < 256 * 3; i++) { + str += "Б"; + } + final String finalStr = str; + assertThrows(IllegalArgumentException.class, () -> index.add(createMap("", finalStr))); + } + + @Test + public void testNoErrorOnLargestByteArray() { + KVStorage index = create(); + byte[] bytes = new byte[255]; + byte[] copy = new byte[255]; + for (int i = 0; i < bytes.length; i++) { + bytes[i] = (byte) (i % 255); + copy[i] = bytes[i]; + } + long result = index.add(Map.of("myval", new KValue(bytes))); + bytes = (byte[]) index.get(result, "myval", false); + assertArrayEquals(copy, bytes); + + final byte[] biggerByteArray = Arrays.copyOf(bytes, 256); + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> index.add(Map.of("myval2", new KValue(biggerByteArray)))); + assertTrue(e.getMessage().contains("bytes.length cannot be > 255")); + } + + @Test + public void testIntLongDoubleFloat() { + KVStorage index = create(); + long intres = index.add(Map.of("intres", new KValue(4))); + long doubleres = index.add(Map.of("doubleres", new KValue(4d))); + long floatres = index.add(Map.of("floatres", new KValue(4f))); + long longres = index.add(Map.of("longres", new KValue(4L))); + long after4Inserts = index.add(Map.of("somenext", new KValue(0))); + + // Pointers should be sequential and increasing, aligned to 4-byte boundaries + assertTrue(intres < doubleres); + assertTrue(doubleres < floatres); + assertTrue(floatres < longres); + assertTrue(longres < after4Inserts); + // Verify all pointers are 4-byte aligned + assertEquals(0, intres % 4); + assertEquals(0, doubleres % 4); + assertEquals(0, floatres % 4); + assertEquals(0, longres % 4); + assertEquals(0, after4Inserts % 4); + + assertEquals(4f, index.get(floatres, "floatres", false)); + assertEquals(4L, index.get(longres, "longres", false)); + assertEquals(4d, index.get(doubleres, "doubleres", false)); + assertEquals(4, index.get(intres, "intres", false)); + } + + @Test + public void testIntLongDoubleFloat2() { + KVStorage index = create(); + Map map = new LinkedHashMap<>(); + map.put("int", new KValue(4)); + map.put("long", new KValue(4L)); + map.put("double", new KValue(4d)); + map.put("float", new KValue(4f)); + long allInOne = index.add(map); + + long afterMapInsert = index.add(Map.of("somenext", new KValue(0))); + + // Pointer should increase after adding more data, and both should be aligned + assertTrue(afterMapInsert > allInOne); + assertEquals(0, allInOne % 4); + assertEquals(0, afterMapInsert % 4); + + Map resMap = index.getAll(allInOne); + assertEquals(4, resMap.get("int").getFwd()); + assertEquals(4L, resMap.get("long").getFwd()); + assertEquals(4d, resMap.get("double").getFwd()); + assertEquals(4f, resMap.get("float").getFwd()); + } + + @Test + public void testFlush() { + Helper.removeDir(new File(location)); + + KVStorage index = new KVStorage(new GHDirectory(location, DAType.RAM_STORE).create(), true); + long pointer = index.add(createMap("", "test")); + index.flush(); + index.close(); + + index = new KVStorage(new GHDirectory(location, DAType.RAM_STORE), true); + assertTrue(index.loadExisting()); + assertEquals("test", index.get(pointer, "", false)); + // make sure bytePointer is correctly set after loadExisting + long newPointer = index.add(createMap("", "testing")); + assertTrue(newPointer > pointer, "newPointer " + newPointer + " should be > pointer " + pointer); + assertEquals(0, newPointer % 4, "newPointer should be 4-byte aligned"); + assertEquals("testing", index.get(newPointer, "", false)); + index.close(); + + Helper.removeDir(new File(location)); + } + + @Test + public void testLoadKeys() { + Helper.removeDir(new File(location)); + + KVStorage index = new KVStorage(new GHDirectory(location, DAType.RAM_STORE).create(), true).create(1000); + long pointerA = index.add(createMap("c", "test value")); + assertEquals(2, index.getKeys().size()); + long pointerB = index.add(createMap("a", "value", "b", "another value")); + // empty string is always the first key + assertEquals("[, c, a, b]", index.getKeys().toString()); + index.flush(); + index.close(); + + index = new KVStorage(new GHDirectory(location, DAType.RAM_STORE), true); + assertTrue(index.loadExisting()); + assertEquals("[, c, a, b]", index.getKeys().toString()); + assertEquals("test value", index.get(pointerA, "c", false)); + assertNull(index.get(pointerA, "b", false)); + + assertNull(index.get(pointerB, "", false)); + assertEquals("value", index.get(pointerB, "a", false)); + assertEquals("another value", index.get(pointerB, "b", false)); + assertEquals("{a=value, b=another value}", index.getAll(pointerB).toString()); + index.close(); + + Helper.removeDir(new File(location)); + } + + @Test + public void testEmptyKey() { + KVStorage index = create(); + long pointerA = index.add(createMap("", "test value")); + long pointerB = index.add(createMap("a", "value", "b", "another value")); + + assertEquals("test value", index.get(pointerA, "", false)); + assertNull(index.get(pointerA, "a", false)); + + assertEquals("value", index.get(pointerB, "a", false)); + assertNull(index.get(pointerB, "", false)); + } + + @Test + public void testDifferentValuePerDirection() { + Map map = new LinkedHashMap<>(); + map.put("test", new KValue("forw", "back")); + + KVStorage index = create(); + long pointerA = index.add(map); + + assertEquals("forw", index.get(pointerA, "test", false)); + assertEquals("back", index.get(pointerA, "test", true)); + } + + @Test + public void testSameByteArray() { + KVStorage index = create(); + + long pointerA = index.add(createMap("mykey", new byte[]{1, 2, 3, 4})); + long pointerB = index.add(createMap("mykey", new byte[]{1, 2, 3, 4})); + assertEquals(pointerA, pointerB); + + byte[] sameRef = new byte[]{1, 2, 3, 4}; + pointerA = index.add(createMap("mykey", sameRef)); + pointerB = index.add(createMap("mykey", sameRef)); + assertEquals(pointerA, pointerB); + } + + @Test + public void testUnknownValueClass() { + KVStorage index = create(); + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> index.add(createMap("mykey", new Object()))); + assertTrue(ex.getMessage().contains("The Class of a value was Object, currently supported"), ex.getMessage()); + } + + @RepeatedTest(20) + public void testRandom() { + final long seed = new Random().nextLong(); + try { + KVStorage index = new KVStorage(new GHDirectory(location, DAType.RAM_STORE).create(), true).create(1000); + Random random = new Random(seed); + List keys = createRandomStringList(random, "_key", 100); + List values = createRandomMap(random, 500); + + int size = 10000; + LongArrayList pointers = new LongArrayList(size); + for (int i = 0; i < size; i++) { + Map list = createRandomMap(random, keys, values); + long pointer = index.add(list); + try { + assertEquals(list.size(), index.getAll(pointer).size(), "" + i); + } catch (Exception ex) { + throw new RuntimeException(i + " " + list + ", " + pointer, ex); + } + pointers.add(pointer); + } + + for (int i = 0; i < size; i++) { + Map map = index.getAll(pointers.get(i)); + assertFalse(map.isEmpty(), i + " " + map); + for (Map.Entry entry : map.entrySet()) { + Object value = index.get(pointers.get(i), entry.getKey(), false); + assertEquals(entry.getValue().getFwd(), value, i + " " + map); + } + } + index.flush(); + index.close(); + + index = new KVStorage(new GHDirectory(location, DAType.RAM_STORE).create(), true); + assertTrue(index.loadExisting()); + for (int i = 0; i < size; i++) { + Map map = index.getAll(pointers.get(i)); + assertFalse(map.isEmpty(), i + " " + map); + for (Map.Entry entry : map.entrySet()) { + Object value = index.get(pointers.get(i), entry.getKey(), false); + assertEquals(entry.getValue().getFwd(), value, i + " " + map); + } + } + index.close(); + } catch (Throwable t) { + throw new RuntimeException("KVStorageTest.testRandom seed:" + seed, t); + } + } + + private List createRandomMap(Random random, int size) { + List list = new ArrayList<>(); + for (int i = 0; i < size; i++) { + list.add(random.nextInt(size * 5)); + } + return list; + } + + private List createRandomStringList(Random random, String postfix, int size) { + List list = new ArrayList<>(); + for (int i = 0; i < size; i++) { + list.add(random.nextInt(size * 5) + postfix); + } + return list; + } + + private Map createRandomMap(Random random, List keys, List values) { + int count = random.nextInt(10) + 2; + Set avoidDuplicates = new HashSet<>(); // otherwise index.get returns potentially wrong value + Map list = new LinkedHashMap<>(); + for (int i = 0; i < count; i++) { + String key = keys.get(random.nextInt(keys.size())); + if (!avoidDuplicates.add(key)) + continue; + Object o = values.get(random.nextInt(values.size())); + list.put(key, new KValue(key.endsWith("_s") ? o + "_s" : o)); + } + return list; + } + + // @RepeatedTest(1000) + public void ignoreRandomString() { + String s = ""; + long seed = new Random().nextLong(); + Random rand = new Random(seed); + for (int i = 0; i < 255; i++) { + s += (char) rand.nextInt(); + } + + s = cutString(s); + assertTrue(s.getBytes(UTF_CS).length <= 255, s.getBytes(UTF_CS).length + " -> seed " + seed); + } + + @Test + public void testCutString() { + String s = cutString("Бухарестская улица (http://ru.wikipedia.org/wiki/" + + "%D0%91%D1%83%D1%85%D0%B0%D1%80%D0%B5%D1%81%D1%82%D1%81%D0%BA%D0%B0%D1%8F_%D1%83%D0%BB%D0%B8%D1%86%D0%B0_(%D0%A1%D0%B0%D0%BD%D0%BA%D1%82-%D0%9F%D0%B5%D1%82%D0%B5%D1%80%D0%B1%D1%83%D1%80%D0%B3))"); + assertEquals(250, s.getBytes(UTF_CS).length); + } + + @Test + public void testMax() { + long pointer = Integer.MAX_VALUE; + int storedPointer = (int) (pointer + 100); + assertTrue(storedPointer < 0); + assertEquals(pointer + 100, Integer.toUnsignedLong(storedPointer)); + } + + // ORS-GH BACKPORT: tests for addTags(Map) + @Test + void testAddTagsBasic() { + KVStorage index = create(); + Map tags = new LinkedHashMap<>(); + tags.put("barrier", "gate"); + tags.put("access", "private"); + + long pointer = index.addTags(tags); + assertTrue(pointer > 0); + + Map result = index.getMap(pointer); + assertEquals("gate", result.get("barrier")); + assertEquals("private", result.get("access")); + } + + @Test + void testAddTagsNullValueSkipped() { + KVStorage index = create(); + Map tags = new LinkedHashMap<>(); + tags.put("barrier", "bollard"); + tags.put("name", null); // null value should be silently ignored + + long pointer = index.addTags(tags); + Map result = index.getMap(pointer); + assertEquals("bollard", result.get("barrier")); + assertNull(result.get("name")); // not stored + assertEquals(1, result.size()); + } + + @Test + void testAddTagsEmptyMap() { + KVStorage index = create(); + long pointer = index.addTags(Collections.emptyMap()); + assertEquals(0, pointer); // EMPTY_POINTER + assertTrue(index.getMap(pointer).isEmpty()); + } + + @Test + void testAddTagsAllNullValues() { + KVStorage index = create(); + Map tags = new LinkedHashMap<>(); + tags.put("key1", null); + tags.put("key2", null); + long pointer = index.addTags(tags); + assertEquals(0, pointer); // all-null collapses to EMPTY_POINTER + } + + @Test + void testAddTagsDuplicateDetection() { + KVStorage index = create(); + Map tags = new LinkedHashMap<>(); + tags.put("barrier", "gate"); + long p1 = index.addTags(tags); + long p2 = index.addTags(tags); // same reference → same pointer + assertEquals(p1, p2); + + // equal content (different map instance) should also dedup + Map sameContent = new LinkedHashMap<>(); + sameContent.put("barrier", "gate"); + long p3 = index.addTags(sameContent); + assertEquals(p1, p3); + } + + @Test + void testAddTagsStringCutApplied() { + KVStorage index = create(); + // Build a string that is > 250 bytes in UTF-8 (use multi-byte chars) + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 200; i++) sb.append('Б'); // 2 bytes each → 400 bytes + String longStr = sb.toString(); + + Map tags = new LinkedHashMap<>(); + tags.put("name", longStr); + long pointer = index.addTags(tags); + + String stored = (String) index.getMap(pointer).get("name"); + assertTrue(stored.getBytes(Helper.UTF_CS).length <= 250, + "cutString should have truncated the value"); + } + + @Test + void testAddTagsForwardBackwardBothSet() { + // addTags stores all values as fwd+bwd equal; verify via getAll + KVStorage index = create(); + Map tags = new LinkedHashMap<>(); + tags.put("barrier", "gate"); + long pointer = index.addTags(tags); + + Map all = index.getAll(pointer); + KValue kv = all.get("barrier"); + assertNotNull(kv); + assertEquals("gate", kv.getFwd()); + assertEquals("gate", kv.getBwd()); + assertTrue(kv.fwdBwdEqual); + } + + @Test + void testAddTagsNullInputThrows() { + KVStorage index = create(); + assertThrows(IllegalArgumentException.class, () -> index.addTags(null)); + } + + @Test + void testAddTagsInterleaveWithAdd() { + // addTags and add(Map) must not corrupt each other + KVStorage index = create(); + long p1 = index.add(createMap("road_class", "primary")); + Map tags = new LinkedHashMap<>(); + tags.put("barrier", "gate"); + long p2 = index.addTags(tags); + long p3 = index.add(createMap("road_class", "secondary")); + + assertEquals("primary", index.getMap(p1).get("road_class")); + assertEquals("gate", index.getMap(p2).get("barrier")); + assertEquals("secondary", index.getMap(p3).get("road_class")); + } +}