} 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"));
+ }
+}