From 4b90f8560bc60f57c2c9dfc431132169e994aa75 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 11:57:27 -0400 Subject: [PATCH 01/43] Add LightMap: entry-less flat open-addressing Map primitive LightMap is a flat, entry-less String-keyed map in the flat-collections family (alongside Hashtable / ConcurrentHashtable / FlatHashtable). Keys and values live in a single Object[] spine (keys in [0, numSlots), values in [numSlots, 2*numSlots)), power-of-2 sized, linear-probed with tombstone deletion. Following StringIndex, the object stands on its own but exposes a nested EmbeddingSupport class as an opt-in "drop a level" performance hatch: static functions over a caller-owned array, so callers can embed the backing array in their own fields and skip the wrapper. (This is the reverse of the *Hashtable family, where statics are primary for arity.) Ported from the FastMapBenchmarks experiment; no behavior change. Co-Authored-By: Claude Opus 4.8 --- .../java/datadog/trace/util/LightMap.java | 499 ++++++++++++++++++ 1 file changed, 499 insertions(+) create mode 100644 internal-api/src/main/java/datadog/trace/util/LightMap.java diff --git a/internal-api/src/main/java/datadog/trace/util/LightMap.java b/internal-api/src/main/java/datadog/trace/util/LightMap.java new file mode 100644 index 00000000000..2e61378ed6e --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/util/LightMap.java @@ -0,0 +1,499 @@ +package datadog.trace.util; + +import datadog.trace.api.function.TriConsumer; +import java.util.Arrays; +import java.util.function.BiConsumer; + +/** + * Implements a Map-like interface + * + *

Designed to be light weight and fast for tiny maps -- Especially when the keys are likely to + * be string literals + * + *

Map data is stored in a single flat array that can be embedded into another object via + * EmbeddingSupport as a further optimization. + */ +/* + * Keys are stored in the first half of the array and values in the second half. + * Key collisions are resolved via linear probing. + * + * This layout is intended to optimize scanning for available and matching slots -- + * especially in the common case where the keys are string literals. + * + * Key removal is handled by placing a poison key in the previously occupied slot. + * This approach was chosen so that linear probing can break out of the loop + * when an empty slot is encountered. + * + * Insertions after the a removal can fill the emptied slot. + * In the event of resizing, removal markers are discarded while assigning + * slots in the new data array. + */ +public final class LightMap { + public static final int DEFAULT_CAPACITY = 8; + + private final int initialCapacity; + private Object[] data = EmbeddingSupport.EMPTY_DATA; + + public LightMap(int capacity) { + this.initialCapacity = capacity; + } + + public void set(String literal, V value) { + this.data = EmbeddingSupport.set(this.initialCapacity, this.data, literal, value); + } + + public V get(String literal) { + return EmbeddingSupport.get(this.data, literal); + } + + public void remove(String literal) { + EmbeddingSupport.remove(this.data, literal); + } + + @SuppressWarnings("unchecked") + public void forEach(java.util.function.BiConsumer consumer) { + Object[] mapData = this.data; + if (mapData == null) return; + int numSlots = mapData.length >> 1; + for (int slot = 0; slot < numSlots; slot++) { + String key = (String) mapData[slot]; + if (key == null || EmbeddingSupport.isRemoved(key)) continue; + consumer.accept(key, (V) mapData[slot + numSlots]); + } + } + + public static final class EmbeddingSupport { + public static final int NOT_FOUND = Integer.MIN_VALUE; + static final int NO_SPACE = Integer.MIN_VALUE; + + // TODO: use of String constructor is deliberate, since this is + // an internal marker that we don't want intern-ed + static final String REMOVED = new String("\0D\0a\07\04\0\0d\00\0G"); + + public static final Object[] EMPTY_DATA = null; + + public static boolean isDefinitelyEmpty(Object[] mapData) { + return (mapData == null); + } + + public static int numSlots(Object[] mapData) { + return mapData == null ? 0 : mapData.length >> 1; + } + + public static int size(Object[] mapData) { + if (mapData == null) return 0; + + int size = 0; + int numSlots = numSlots(mapData); + for (int slot = 0; slot < numSlots; ++slot) { + Object key = mapData[slot]; + if (key != null && key != REMOVED) size += 1; + } + return size; + } + + public static Object[] clear(Object[] mapData) { + if (mapData != null) { + Arrays.fill(mapData, null); + } + return mapData; + } + + public static Object[] copy(Object[] mapData) { + return mapData == null ? null : mapData.clone(); + } + + public static boolean isPresent(int slotIndex) { + return (slotIndex >= 0); + } + + public static boolean isRemoved(String key) { + return (key == REMOVED); + } + + public static String keyAt(Object[] mapData, int slotIndex) { + if (mapData == null) return null; + if (slotIndex < 0) return null; + + String key = str(mapData[slotIndex]); + return (key == REMOVED) ? null : key; + } + + @SuppressWarnings("unchecked") + public static V valueAt(Object[] mapData, int slotIndex) { + if (mapData == null) return null; + + return (V) mapData[slotIndex + numSlots(mapData)]; + } + + @SuppressWarnings("unchecked") + public static V prevValueAt(Object[] mapData, int slotIndex) { + if (mapData == null || slotIndex < 0) return null; + + return (V) mapData[slotIndex + numSlots(mapData)]; + } + + public static final boolean containsKey(Object[] mapData, String key) { + if (mapData == null) return false; + + // not bothering with optimizing literal checks + int numSlots = numSlots(mapData); + + int hash = key.hashCode(); + int preferredSlot = preferredSlot(numSlots, hash); + + // TODO: check whether fast literal search is worth it + for (int slot = preferredSlot; slot < numSlots; ++slot) { + String curKey = str(mapData[slot]); + if (curKey == null) return false; + if (curKey != REMOVED && key.equals(curKey)) return true; + } + for (int slot = 0; slot < preferredSlot; ++slot) { + String curKey = str(mapData[slot]); + if (curKey == null) return false; + if (curKey != REMOVED && key.equals(curKey)) return true; + } + return false; + } + + public static final boolean containsValue(Object[] mapData, Object value) { + if (mapData == null) return false; + + for (int valueIndex = numSlots(mapData); valueIndex < mapData.length; ++valueIndex) { + Object curValue = mapData[valueIndex]; + if (value.equals(curValue)) return true; + } + return false; + } + + public static Object[] set(Object[] mapData, String key, V value) { + return set(DEFAULT_CAPACITY, mapData, key, value); + } + + public static Object[] setAll(Object[] destMapData, Object[] srcMapData) { + return setAll(destMapData, size(destMapData), srcMapData, size(srcMapData)); + } + + public static Object[] setAll( + Object[] destMapData, int destSize, Object[] srcMapData, int srcSize) { + if (srcMapData == null || srcSize == 0) return destMapData; + if (destMapData == null) return srcMapData.clone(); + + int expectedSize = destSize + srcSize; + int initDestSlots = numSlots(destMapData); + int initFreeSpace = initDestSlots - destSize; + + int numDestSlots; + if (expectedSize > initDestSlots) { + destMapData = expandMapData(destMapData, expectedSize + Math.max(initFreeSpace, 10)); + numDestSlots = numSlots(destMapData); // re-read after expandMapData rounds to pow-of-2 + } else { + numDestSlots = initDestSlots; + } + + int numSrcSlots = numSlots(srcMapData); + for (int srcSlot = 0; srcSlot < numSrcSlots; ++srcSlot) { + String srcKey = str(srcMapData[srcSlot]); + if (srcKey == null || srcKey == REMOVED) continue; + + Object srcValue = srcMapData[srcSlot + numSrcSlots]; + checkedInsert(destMapData, numDestSlots, srcKey, srcValue); + } + + return destMapData; + } + + public static Object[] set(int initialCapacity, Object[] mapData, String key, V value) { + if (mapData == null) { + int numSlots = roundUpToPow2(initialCapacity); + mapData = new Object[numSlots << 1]; + + int keyIndex = preferredSlot(numSlots, key.hashCode()); + mapData[keyIndex] = key; + mapData[keyIndex + numSlots] = value; + return mapData; + } + + int numSlots = numSlots(mapData); + if (checkedInsert(mapData, numSlots, key, value)) { + return mapData; + } + + mapData = expandMapData(mapData); + numSlots = numSlots(mapData); + newMapUncheckedInsert(mapData, numSlots, key, value); + return mapData; + } + + @SuppressWarnings("unchecked") + public static V get(Object[] mapData, String key) { + if (mapData == null) return null; + + int numSlots = numSlots(mapData); + int foundIndex = findSlot(mapData, numSlots, key); + + return (foundIndex >= 0) ? (V) mapData[numSlots + foundIndex] : null; + } + + public static boolean remove(Object[] mapData, String key) { + if (mapData == null) return false; + + int numSlots = numSlots(mapData); + int foundIndex = findSlot(mapData, numSlots, key); + if (foundIndex >= 0) { + mapData[foundIndex] = REMOVED; + mapData[foundIndex + numSlots] = null; + + return true; + } else { + return false; + } + } + + public static boolean checkedInsert(Object[] mapData, int numSlots, String key, Object value) { + int insertionSlot = findInsertionSlot(mapData, numSlots, key); + + if (insertionSlot >= 0) { + mapData[insertionSlot + numSlots] = value; + return true; + } else if (insertionSlot != NO_SPACE) { + int availableSlot = flip(insertionSlot); + mapData[availableSlot] = key; + mapData[availableSlot + numSlots] = value; + return true; + } else { + return false; + } + } + + public static int findInsertionSlot(Object[] mapData, String key) { + if (mapData == null) return NO_SPACE; + + return findInsertionSlot(mapData, numSlots(mapData), key); + } + + public static final Object[] insertAt( + int initialCapacity, Object[] mapData, int insertionSlot, String key, Object value) { + if (mapData == null) { + return newMapData(initialCapacity, key, value); + } + + if (insertionSlot == NO_SPACE) { + mapData = expandMapData(mapData); + newMapUncheckedInsert(mapData, numSlots(mapData), key, value); + } else { + if (insertionSlot < 0) insertionSlot = flip(insertionSlot); + + mapData[insertionSlot] = key; + mapData[insertionSlot + numSlots(mapData)] = value; + } + + return mapData; + } + + static final int findInsertionSlot(Object[] mapData, int numSlots, String key) { + int hash = key.hashCode(); + int preferredSlot = preferredSlot(numSlots, hash); + + int availableIndex = NO_SPACE; + for (int keyIndex = preferredSlot; keyIndex < numSlots; ++keyIndex) { + Object curKey = mapData[keyIndex]; + if (curKey == null) return flip(keyIndex); + if (curKey == key) return keyIndex; + if (curKey == REMOVED) { + if (availableIndex == NO_SPACE) availableIndex = flip(keyIndex); + } else if (key.equals(curKey)) return keyIndex; + } + for (int keyIndex = 0; keyIndex < preferredSlot; ++keyIndex) { + Object curKey = mapData[keyIndex]; + if (curKey == null) return flip(keyIndex); + if (curKey == key) return keyIndex; + if (curKey == REMOVED) { + if (availableIndex == NO_SPACE) availableIndex = flip(keyIndex); + } else if (key.equals(curKey)) return keyIndex; + } + return availableIndex; + } + + static int flip(int keyIndex) { + return -keyIndex - 1; + } + + public static final void removeAt(Object[] mapData, int slot) { + if (mapData == null || slot < 0) return; + + mapData[slot] = REMOVED; + mapData[slot + numSlots(mapData)] = null; + } + + public static final Object getAndRemoveAt(Object[] mapData, int slot) { + if (mapData == null || slot < 0) return null; + + mapData[slot] = REMOVED; + + int valueIndex = slot + numSlots(mapData); + Object prev = mapData[valueIndex]; + mapData[valueIndex] = null; + + return prev; + } + + public static final int findSlot(Object[] mapData, String key) { + if (mapData == null) return NOT_FOUND; + + return findSlot(mapData, numSlots(mapData), key); + } + + static final int findSlot(Object[] mapData, int numSlots, String key) { + int hash = key.hashCode(); + int preferredSlot = preferredSlot(numSlots, hash); + + // Tight ref-equality first pass — optimized for the literal-key hit case. + // Trade-off: on a miss this walks the whole key array; that's acceptable + // since the documented use case is mostly-hit lookups against known keys. + for (int keyIndex = preferredSlot; keyIndex < numSlots; ++keyIndex) { + if (mapData[keyIndex] == key) return keyIndex; + } + for (int keyIndex = 0; keyIndex < preferredSlot; ++keyIndex) { + if (mapData[keyIndex] == key) return keyIndex; + } + + // Fallback equals pass for non-literal-key hits; terminates at null. + for (int keyIndex = preferredSlot; keyIndex < numSlots; ++keyIndex) { + Object curKey = mapData[keyIndex]; + if (curKey == null) return -1; + if (curKey != REMOVED && key.equals(curKey)) return keyIndex; + } + for (int keyIndex = 0; keyIndex < preferredSlot; ++keyIndex) { + Object curKey = mapData[keyIndex]; + if (curKey == null) return -1; + if (curKey != REMOVED && key.equals(curKey)) return keyIndex; + } + return -1; + } + + static final Object[] newMapData(int initialCapacity, String key, Object value) { + int numSlots = roundUpToPow2(initialCapacity); + Object[] mapData = new Object[numSlots << 1]; + + int slotIndex = preferredSlot(numSlots, key.hashCode()); + mapData[slotIndex] = key; + mapData[slotIndex + numSlots] = value; + return mapData; + } + + public static final Object[] expandMapData(Object[] mapData) { + // Subtle - capacity is in terms of slots - not array size, so passing length is + // asking to double capacity + return expandMapData(mapData, mapData.length); + } + + public static Object[] expandMapData(Object[] origMapData, int newCapacity) { + newCapacity = roundUpToPow2(newCapacity); + int newSize = newCapacity << 1; + // Don't try to optimize by returning origMapData if big enough to contain + // the newCapacity. There's subtle invariant that new maps also don't + // contain remove markers that must also be maintained. + + int origNumSlots = numSlots(origMapData); + + Object[] newMapData = new Object[newSize]; + for (int slot = 0; slot < origNumSlots; ++slot) { + String key = str(origMapData[slot]); + if (key == null || key == REMOVED) continue; + + Object value = origMapData[slot + origNumSlots]; + newMapUncheckedInsert(newMapData, newCapacity, key, value); + } + return newMapData; + } + + public static void forEach(Object[] mapData, BiConsumer entryConsumer) { + if (mapData == null) return; + + int numSlots = numSlots(mapData); + for (int slot = 0; slot < numSlots; ++slot) { + String key = str(mapData[slot]); + if (key == null || key == REMOVED) continue; + + Object value = mapData[slot + numSlots]; + entryConsumer.accept(key, value); + } + } + + public static void forEach( + Object[] mapData, C ctx, TriConsumer entryConsumer) { + if (mapData == null) return; + + int numSlots = numSlots(mapData); + for (int slot = 0; slot < numSlots; ++slot) { + String key = str(mapData[slot]); + if (key == null || key == REMOVED) continue; + + Object value = mapData[slot + numSlots]; + entryConsumer.accept(ctx, key, value); + } + } + + public static String toInternalString(Object[] mapData) { + StringBuilder builder = new StringBuilder(128); + builder.append('{'); + + int numSlots = numSlots(mapData); + for (int slot = 0; slot < numSlots; ++slot) { + String key = str(mapData[slot]); + if (key == null) continue; + + builder.append('[').append(slot).append("]="); + + if (key == REMOVED) { + builder.append("--REMOVED--"); + } else { + Object value = mapData[slot + numSlots]; + builder.append(key).append(':').append(value); + } + builder.append('\n'); + } + builder.append('}'); + return builder.toString(); + } + + static void newMapUncheckedInsert(Object[] mapData, int numSlots, String key, Object value) { + int hash = key.hashCode(); + int preferredSlot = preferredSlot(numSlots, hash); + + for (int slot = preferredSlot; slot < numSlots; ++slot) { + Object curKey = mapData[slot]; + if (curKey == null) { + mapData[slot] = key; + mapData[slot + numSlots] = value; + + return; + } + } + for (int slot = 0; slot < preferredSlot; ++slot) { + Object curKey = mapData[slot]; + if (curKey == null) { + mapData[slot] = key; + mapData[slot + numSlots] = value; + + return; + } + } + } + + static int preferredSlot(int numSlots, int hash) { + // numSlots is required to be a power of 2 (allocation sites round up via roundUpToPow2). + // The bit mask is ~20x faster than a modulo divide on typical hardware. + return (hash ^ (hash >>> 16)) & (numSlots - 1); + } + + static int roundUpToPow2(int n) { + return n <= 1 ? 1 : Integer.highestOneBit(n - 1) << 1; + } + + static final String str(Object key) { + return (String) key; + } + } +} From 4a2a505adef439b30b3425e672ad311809f868d5 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 11:57:41 -0400 Subject: [PATCH 02/43] Test LightMap instance API and EmbeddingSupport spine JUnit 5 coverage for both surfaces: instance set/get/remove/forEach with grow-and-preserve across resizes, equals-fallback lookup for non-literal keys, and remove-then-reinsert; the EmbeddingSupport statics for empty-map probes, tombstone chains, expand-purges-tombstones, copy independence, setAll merge/clone, keyAt/valueAt/prevValueAt, findSlot/findInsertionSlot, insertAt/removeAt/getAndRemoveAt, both forEach variants, and the slot math (roundUpToPow2, preferredSlot, isRemoved sentinel). Co-Authored-By: Claude Opus 4.8 --- .../java/datadog/trace/util/LightMapTest.java | 351 ++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 internal-api/src/test/java/datadog/trace/util/LightMapTest.java diff --git a/internal-api/src/test/java/datadog/trace/util/LightMapTest.java b/internal-api/src/test/java/datadog/trace/util/LightMapTest.java new file mode 100644 index 00000000000..3b3e97c583a --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/util/LightMapTest.java @@ -0,0 +1,351 @@ +package datadog.trace.util; + +import static datadog.trace.util.LightMap.EmbeddingSupport; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class LightMapTest { + + // ============ Instance API ============ + + @Nested + class InstanceTests { + + @Test + void getOnEmptyMapReturnsNull() { + LightMap map = new LightMap<>(LightMap.DEFAULT_CAPACITY); + assertNull(map.get("absent")); + } + + @Test + void setThenGet() { + LightMap map = new LightMap<>(8); + map.set("a", 1); + map.set("b", 2); + assertEquals(1, map.get("a")); + assertEquals(2, map.get("b")); + assertNull(map.get("c")); + } + + @Test + void setOverwritesExistingKey() { + LightMap map = new LightMap<>(8); + map.set("a", 1); + map.set("a", 42); + assertEquals(42, map.get("a")); + } + + @Test + void removeMakesKeyAbsent() { + LightMap map = new LightMap<>(8); + map.set("a", 1); + map.set("b", 2); + map.remove("a"); + assertNull(map.get("a")); + assertEquals(2, map.get("b")); + } + + @Test + void growsAndPreservesAllEntries() { + // initial capacity 2 forces several resizes as we insert well past it. + LightMap map = new LightMap<>(2); + int n = 100; + for (int i = 0; i < n; i++) { + map.set("key-" + i, i); + } + for (int i = 0; i < n; i++) { + assertEquals(i, map.get("key-" + i), "key-" + i); + } + } + + @Test + void forEachVisitsEveryLiveEntry() { + LightMap map = new LightMap<>(4); + for (int i = 0; i < 20; i++) { + map.set("k" + i, i); + } + map.remove("k5"); + map.remove("k12"); + + Map seen = new HashMap<>(); + map.forEach(seen::put); + + assertEquals(18, seen.size()); + assertFalse(seen.containsKey("k5")); + assertFalse(seen.containsKey("k12")); + assertEquals(7, seen.get("k7")); + } + + @Test + void nonLiteralKeyResolvesViaEqualsFallback() { + LightMap map = new LightMap<>(8); + map.set("hello", 1); + // Distinct String instance with the same content -- must be found via the equals pass. + String lookup = new String("hello"); + assertEquals(1, map.get(lookup)); + } + + @Test + void removeThenReinsertSameKey() { + LightMap map = new LightMap<>(4); + for (int i = 0; i < 4; i++) { + map.set("k" + i, i); + } + map.remove("k1"); + assertNull(map.get("k1")); + map.set("k1", 99); + assertEquals(99, map.get("k1")); + } + } + + // ============ EmbeddingSupport spine ============ + + @Nested + class EmbeddingSupportTests { + + @Test + void emptyMapProbes() { + assertTrue(EmbeddingSupport.isDefinitelyEmpty(EmbeddingSupport.EMPTY_DATA)); + assertEquals(0, EmbeddingSupport.numSlots(null)); + assertEquals(0, EmbeddingSupport.size(null)); + assertNull(EmbeddingSupport.get(null, "x")); + assertFalse(EmbeddingSupport.remove(null, "x")); + assertFalse(EmbeddingSupport.containsKey(null, "x")); + assertFalse(EmbeddingSupport.containsValue(null, "x")); + assertEquals(EmbeddingSupport.NOT_FOUND, EmbeddingSupport.findSlot(null, "x")); + } + + @Test + void setGrowsFromNullAndReadsBack() { + Object[] data = null; + data = EmbeddingSupport.set(4, data, "a", "A"); + data = EmbeddingSupport.set(4, data, "b", "B"); + assertEquals("A", EmbeddingSupport.get(data, "a")); + assertEquals("B", EmbeddingSupport.get(data, "b")); + assertEquals(2, EmbeddingSupport.size(data)); + } + + @Test + void defaultCapacitySetOverload() { + Object[] data = EmbeddingSupport.set(null, "a", "A"); + assertEquals(LightMap.DEFAULT_CAPACITY, EmbeddingSupport.numSlots(data)); + assertEquals("A", EmbeddingSupport.get(data, "a")); + } + + @Test + void containsKeyAndContainsValue() { + Object[] data = null; + data = EmbeddingSupport.set(8, data, "a", "A"); + data = EmbeddingSupport.set(8, data, "b", "B"); + assertTrue(EmbeddingSupport.containsKey(data, "a")); + assertFalse(EmbeddingSupport.containsKey(data, "z")); + assertTrue(EmbeddingSupport.containsValue(data, "B")); + assertFalse(EmbeddingSupport.containsValue(data, "Z")); + } + + @Test + void removeLeavesTombstoneButKeepsProbeChainIntact() { + // Insert many keys into a small table, remove a middle one, ensure the rest still resolve. + Object[] data = null; + for (int i = 0; i < 16; i++) { + data = EmbeddingSupport.set(2, data, "k" + i, i); + } + assertTrue(EmbeddingSupport.remove(data, "k8")); + assertNull(EmbeddingSupport.get(data, "k8")); + for (int i = 0; i < 16; i++) { + if (i == 8) continue; + assertEquals(i, (Object) EmbeddingSupport.get(data, "k" + i), "k" + i); + } + assertEquals(15, EmbeddingSupport.size(data)); + } + + @Test + void expandDiscardsTombstones() { + Object[] data = null; + for (int i = 0; i < 4; i++) { + data = EmbeddingSupport.set(4, data, "k" + i, i); + } + EmbeddingSupport.remove(data, "k0"); + EmbeddingSupport.remove(data, "k1"); + // A live tombstone exists now; expanding must purge them (size unchanged, no REMOVED slots). + Object[] expanded = EmbeddingSupport.expandMapData(data); + assertEquals(2, EmbeddingSupport.size(expanded)); + int numSlots = EmbeddingSupport.numSlots(expanded); + for (int slot = 0; slot < numSlots; slot++) { + assertFalse( + EmbeddingSupport.isRemoved((String) expanded[slot]), + "expanded table must not carry tombstones"); + } + assertEquals(2, (Object) EmbeddingSupport.get(expanded, "k2")); + assertEquals(3, (Object) EmbeddingSupport.get(expanded, "k3")); + } + + @Test + void copyIsIndependent() { + Object[] data = EmbeddingSupport.set(4, null, "a", "A"); + Object[] copy = EmbeddingSupport.copy(data); + EmbeddingSupport.set(4, copy, "b", "B"); + assertNull(EmbeddingSupport.get(data, "b")); + assertEquals("B", EmbeddingSupport.get(copy, "b")); + assertNull(EmbeddingSupport.copy(null)); + } + + @Test + void clearNullsEverything() { + Object[] data = EmbeddingSupport.set(4, null, "a", "A"); + EmbeddingSupport.clear(data); + assertEquals(0, EmbeddingSupport.size(data)); + assertNull(EmbeddingSupport.get(data, "a")); + } + + @Test + void keyAtValueAtAndPresence() { + Object[] data = EmbeddingSupport.set(8, null, "a", "A"); + int slot = EmbeddingSupport.findSlot(data, "a"); + assertTrue(EmbeddingSupport.isPresent(slot)); + assertEquals("a", EmbeddingSupport.keyAt(data, slot)); + assertEquals("A", EmbeddingSupport.valueAt(data, slot)); + assertEquals("A", EmbeddingSupport.prevValueAt(data, slot)); + // negative slot -> absent + assertNull(EmbeddingSupport.keyAt(data, -1)); + assertFalse(EmbeddingSupport.isPresent(-1)); + } + + @Test + void findSlotMissReturnsMinusOneNotSentinel() { + Object[] data = EmbeddingSupport.set(8, null, "a", "A"); + assertEquals(-1, EmbeddingSupport.findSlot(data, "absent")); + } + + @Test + void removeAtAndGetAndRemoveAt() { + Object[] data = EmbeddingSupport.set(8, null, "a", "A"); + int slot = EmbeddingSupport.findSlot(data, "a"); + Object prev = EmbeddingSupport.getAndRemoveAt(data, slot); + assertEquals("A", prev); + assertNull(EmbeddingSupport.get(data, "a")); + + Object[] data2 = EmbeddingSupport.set(8, null, "b", "B"); + int slot2 = EmbeddingSupport.findSlot(data2, "b"); + EmbeddingSupport.removeAt(data2, slot2); + assertNull(EmbeddingSupport.get(data2, "b")); + } + + @Test + void insertAtUsingFoundInsertionSlot() { + Object[] data = EmbeddingSupport.set(8, null, "a", "A"); + int insertionSlot = EmbeddingSupport.findInsertionSlot(data, "b"); + data = EmbeddingSupport.insertAt(8, data, insertionSlot, "b", "B"); + assertEquals("A", EmbeddingSupport.get(data, "a")); + assertEquals("B", EmbeddingSupport.get(data, "b")); + } + + @Test + void insertAtFromNullBuildsNewMap() { + Object[] data = EmbeddingSupport.insertAt(4, null, EmbeddingSupport.NO_SPACE, "a", "A"); + assertEquals("A", EmbeddingSupport.get(data, "a")); + } + + @Test + void setAllMergesSourceIntoDest() { + Object[] dest = null; + dest = EmbeddingSupport.set(8, dest, "a", "A"); + dest = EmbeddingSupport.set(8, dest, "b", "B"); + + Object[] src = null; + src = EmbeddingSupport.set(8, src, "c", "C"); + src = EmbeddingSupport.set(8, src, "d", "D"); + + dest = EmbeddingSupport.setAll(dest, src); + assertEquals(4, EmbeddingSupport.size(dest)); + assertEquals("A", EmbeddingSupport.get(dest, "a")); + assertEquals("C", EmbeddingSupport.get(dest, "c")); + assertEquals("D", EmbeddingSupport.get(dest, "d")); + } + + @Test + void setAllFromNullDestClonesSource() { + Object[] src = EmbeddingSupport.set(8, null, "c", "C"); + Object[] dest = EmbeddingSupport.setAll(null, src); + assertEquals("C", EmbeddingSupport.get(dest, "c")); + // clone -> independent from src + EmbeddingSupport.set(8, dest, "d", "D"); + assertNull(EmbeddingSupport.get(src, "d")); + } + + @Test + void forEachStaticVisitsLiveEntries() { + Object[] data = null; + for (int i = 0; i < 6; i++) { + data = EmbeddingSupport.set(4, data, "k" + i, i); + } + EmbeddingSupport.remove(data, "k3"); + + Map seen = new HashMap<>(); + EmbeddingSupport.forEach(data, seen::put); + assertEquals(5, seen.size()); + assertFalse(seen.containsKey("k3")); + } + + @Test + void forEachStaticWithContext() { + Object[] data = null; + for (int i = 0; i < 4; i++) { + data = EmbeddingSupport.set(4, data, "k" + i, i); + } + Map ctx = new HashMap<>(); + EmbeddingSupport.forEach(data, ctx, (c, k, v) -> c.put(k, v)); + assertEquals(4, ctx.size()); + assertEquals(2, ctx.get("k2")); + } + + @Test + void toInternalStringRendersLiveAndRemoved() { + Object[] data = EmbeddingSupport.set(4, null, "a", "A"); + data = EmbeddingSupport.set(4, data, "b", "B"); + EmbeddingSupport.remove(data, "a"); + String rendered = EmbeddingSupport.toInternalString(data); + assertTrue(rendered.contains("b:B")); + assertTrue(rendered.contains("--REMOVED--")); + } + } + + // ============ Slot math ============ + + @Nested + class SlotMathTests { + + @Test + void roundUpToPow2() { + assertEquals(1, EmbeddingSupport.roundUpToPow2(0)); + assertEquals(1, EmbeddingSupport.roundUpToPow2(1)); + assertEquals(2, EmbeddingSupport.roundUpToPow2(2)); + assertEquals(4, EmbeddingSupport.roundUpToPow2(3)); + assertEquals(8, EmbeddingSupport.roundUpToPow2(5)); + assertEquals(16, EmbeddingSupport.roundUpToPow2(16)); + } + + @Test + void preferredSlotIsBoundedByNumSlots() { + int numSlots = 16; + for (int hash : new int[] {0, 1, -1, Integer.MIN_VALUE, Integer.MAX_VALUE, 123456}) { + int slot = EmbeddingSupport.preferredSlot(numSlots, hash); + assertTrue(slot >= 0 && slot < numSlots, "slot out of range for hash " + hash); + } + } + + @Test + void isRemovedOnlyMatchesTheSentinel() { + assertTrue(EmbeddingSupport.isRemoved(EmbeddingSupport.REMOVED)); + assertFalse(EmbeddingSupport.isRemoved("removed")); + assertFalse(EmbeddingSupport.isRemoved(new String("REMOVED"))); + } + } +} From 893be8477db3aa20da6b685a6f793eb0343cc5ba Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 12:08:18 -0400 Subject: [PATCH 03/43] Simplify LightMap.findSlot to a single equals-based probe The two-pass findSlot ran a ref-equality pre-pass (optimized for interned keys) that did not terminate at null -- it scanned the whole key array on every miss -- before an equals pass that did terminate. Dropping the assumption that keys are interned, the pre-pass is pure cost: String.equals already short-circuits on `this == other`, so a literal-key hit still resolves on a pointer compare inside the single equals pass, while a miss now touches only the probe chain instead of the entire array. Pure deletion of the pre-pass; the equals loops already were the correct single terminating probe. No behavior change. Co-Authored-By: Claude Opus 4.8 --- .../main/java/datadog/trace/util/LightMap.java | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/LightMap.java b/internal-api/src/main/java/datadog/trace/util/LightMap.java index 2e61378ed6e..59fb3373faa 100644 --- a/internal-api/src/main/java/datadog/trace/util/LightMap.java +++ b/internal-api/src/main/java/datadog/trace/util/LightMap.java @@ -348,17 +348,10 @@ static final int findSlot(Object[] mapData, int numSlots, String key) { int hash = key.hashCode(); int preferredSlot = preferredSlot(numSlots, hash); - // Tight ref-equality first pass — optimized for the literal-key hit case. - // Trade-off: on a miss this walks the whole key array; that's acceptable - // since the documented use case is mostly-hit lookups against known keys. - for (int keyIndex = preferredSlot; keyIndex < numSlots; ++keyIndex) { - if (mapData[keyIndex] == key) return keyIndex; - } - for (int keyIndex = 0; keyIndex < preferredSlot; ++keyIndex) { - if (mapData[keyIndex] == key) return keyIndex; - } - - // Fallback equals pass for non-literal-key hits; terminates at null. + // Single equals-based probe that terminates at the first null slot. We do not + // assume interned keys, so there is no separate ref-equality pre-pass: String.equals + // already short-circuits on `this == other`, so a literal-key hit still resolves on a + // pointer compare, while a miss touches only the probe chain (not the whole array). for (int keyIndex = preferredSlot; keyIndex < numSlots; ++keyIndex) { Object curKey = mapData[keyIndex]; if (curKey == null) return -1; From cc0e6f91f2fe01b9170b93ad9fcf639d6248c673 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 12:25:01 -0400 Subject: [PATCH 04/43] Rename LightMap to LightStringMap Frees the LightMap name for a future LightClassMap sibling and makes the String-key specialization explicit. Pure rename (file, class, constructor, test); no behavior change. Co-Authored-By: Claude Opus 4.8 --- .../{LightMap.java => LightStringMap.java} | 4 ++-- ...htMapTest.java => LightStringMapTest.java} | 22 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) rename internal-api/src/main/java/datadog/trace/util/{LightMap.java => LightStringMap.java} (99%) rename internal-api/src/test/java/datadog/trace/util/{LightMapTest.java => LightStringMapTest.java} (94%) diff --git a/internal-api/src/main/java/datadog/trace/util/LightMap.java b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java similarity index 99% rename from internal-api/src/main/java/datadog/trace/util/LightMap.java rename to internal-api/src/main/java/datadog/trace/util/LightStringMap.java index 59fb3373faa..ae8789c5d30 100644 --- a/internal-api/src/main/java/datadog/trace/util/LightMap.java +++ b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java @@ -28,13 +28,13 @@ * In the event of resizing, removal markers are discarded while assigning * slots in the new data array. */ -public final class LightMap { +public final class LightStringMap { public static final int DEFAULT_CAPACITY = 8; private final int initialCapacity; private Object[] data = EmbeddingSupport.EMPTY_DATA; - public LightMap(int capacity) { + public LightStringMap(int capacity) { this.initialCapacity = capacity; } diff --git a/internal-api/src/test/java/datadog/trace/util/LightMapTest.java b/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java similarity index 94% rename from internal-api/src/test/java/datadog/trace/util/LightMapTest.java rename to internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java index 3b3e97c583a..6b0bfe03b62 100644 --- a/internal-api/src/test/java/datadog/trace/util/LightMapTest.java +++ b/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java @@ -1,6 +1,6 @@ package datadog.trace.util; -import static datadog.trace.util.LightMap.EmbeddingSupport; +import static datadog.trace.util.LightStringMap.EmbeddingSupport; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; @@ -11,7 +11,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -class LightMapTest { +class LightStringMapTest { // ============ Instance API ============ @@ -20,13 +20,13 @@ class InstanceTests { @Test void getOnEmptyMapReturnsNull() { - LightMap map = new LightMap<>(LightMap.DEFAULT_CAPACITY); + LightStringMap map = new LightStringMap<>(LightStringMap.DEFAULT_CAPACITY); assertNull(map.get("absent")); } @Test void setThenGet() { - LightMap map = new LightMap<>(8); + LightStringMap map = new LightStringMap<>(8); map.set("a", 1); map.set("b", 2); assertEquals(1, map.get("a")); @@ -36,7 +36,7 @@ void setThenGet() { @Test void setOverwritesExistingKey() { - LightMap map = new LightMap<>(8); + LightStringMap map = new LightStringMap<>(8); map.set("a", 1); map.set("a", 42); assertEquals(42, map.get("a")); @@ -44,7 +44,7 @@ void setOverwritesExistingKey() { @Test void removeMakesKeyAbsent() { - LightMap map = new LightMap<>(8); + LightStringMap map = new LightStringMap<>(8); map.set("a", 1); map.set("b", 2); map.remove("a"); @@ -55,7 +55,7 @@ void removeMakesKeyAbsent() { @Test void growsAndPreservesAllEntries() { // initial capacity 2 forces several resizes as we insert well past it. - LightMap map = new LightMap<>(2); + LightStringMap map = new LightStringMap<>(2); int n = 100; for (int i = 0; i < n; i++) { map.set("key-" + i, i); @@ -67,7 +67,7 @@ void growsAndPreservesAllEntries() { @Test void forEachVisitsEveryLiveEntry() { - LightMap map = new LightMap<>(4); + LightStringMap map = new LightStringMap<>(4); for (int i = 0; i < 20; i++) { map.set("k" + i, i); } @@ -85,7 +85,7 @@ void forEachVisitsEveryLiveEntry() { @Test void nonLiteralKeyResolvesViaEqualsFallback() { - LightMap map = new LightMap<>(8); + LightStringMap map = new LightStringMap<>(8); map.set("hello", 1); // Distinct String instance with the same content -- must be found via the equals pass. String lookup = new String("hello"); @@ -94,7 +94,7 @@ void nonLiteralKeyResolvesViaEqualsFallback() { @Test void removeThenReinsertSameKey() { - LightMap map = new LightMap<>(4); + LightStringMap map = new LightStringMap<>(4); for (int i = 0; i < 4; i++) { map.set("k" + i, i); } @@ -135,7 +135,7 @@ void setGrowsFromNullAndReadsBack() { @Test void defaultCapacitySetOverload() { Object[] data = EmbeddingSupport.set(null, "a", "A"); - assertEquals(LightMap.DEFAULT_CAPACITY, EmbeddingSupport.numSlots(data)); + assertEquals(LightStringMap.DEFAULT_CAPACITY, EmbeddingSupport.numSlots(data)); assertEquals("A", EmbeddingSupport.get(data, "a")); } From bf1b1eb65f2067adfeffca30df7fdf9daaec0d0f Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 12:31:04 -0400 Subject: [PATCH 05/43] LightStringMap: enforce non-null values, add containsKey, annotate nullability Front door: reject null values in set() so a null get() unambiguously means "absent"; add containsKey() for explicit presence probes. Rename key params literal -> key (interned keys are not assumed) and reframe the class doc. Add @Nonnull/@Nullable across the front door and EmbeddingSupport hatch, and guard valueAt() against a negative slot index (mirroring prevValueAt()). Co-Authored-By: Claude Opus 4.8 --- .../datadog/trace/util/LightStringMap.java | 128 ++++++++++++------ 1 file changed, 84 insertions(+), 44 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java index ae8789c5d30..c8c298e403c 100644 --- a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java +++ b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java @@ -2,23 +2,33 @@ import datadog.trace.api.function.TriConsumer; import java.util.Arrays; +import java.util.Objects; import java.util.function.BiConsumer; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; /** - * Implements a Map-like interface + * A lightweight {@link String}-keyed map, designed to be small and fast for tiny maps. * - *

Designed to be light weight and fast for tiny maps -- Especially when the keys are likely to - * be string literals + *

Supports the common map operations -- {@code set}, {@code get}, {@code remove}, {@code + * containsKey}, and {@code forEach} -- as an easy, largely footgun-free stand-in wherever a small + * {@code Map} is needed. It deliberately does not implement {@link + * java.util.Map}; the surface is intentionally small. * - *

Map data is stored in a single flat array that can be embedded into another object via - * EmbeddingSupport as a further optimization. + *

Neither null keys nor null values are supported: {@code set} rejects a null value, so a null + * {@code get} result unambiguously means "absent". Use {@link #containsKey} if you need to probe + * for presence separately. Interned/literal keys resolve slightly faster but are not assumed. + * + *

This class is not thread-safe. + * + *

Map data is stored in a single flat array that can be embedded into another object via {@link + * EmbeddingSupport} as a further optimization. */ /* * Keys are stored in the first half of the array and values in the second half. * Key collisions are resolved via linear probing. * - * This layout is intended to optimize scanning for available and matching slots -- - * especially in the common case where the keys are string literals. + * This layout is intended to optimize scanning for available and matching slots. * * Key removal is handled by placing a poison key in the previously occupied slot. * This approach was chosen so that linear probing can break out of the loop @@ -38,20 +48,26 @@ public LightStringMap(int capacity) { this.initialCapacity = capacity; } - public void set(String literal, V value) { - this.data = EmbeddingSupport.set(this.initialCapacity, this.data, literal, value); + public void set(@Nonnull String key, @Nonnull V value) { + Objects.requireNonNull(value, "value"); + this.data = EmbeddingSupport.set(this.initialCapacity, this.data, key, value); + } + + @Nullable + public V get(@Nonnull String key) { + return EmbeddingSupport.get(this.data, key); } - public V get(String literal) { - return EmbeddingSupport.get(this.data, literal); + public void remove(@Nonnull String key) { + EmbeddingSupport.remove(this.data, key); } - public void remove(String literal) { - EmbeddingSupport.remove(this.data, literal); + public boolean containsKey(@Nonnull String key) { + return EmbeddingSupport.containsKey(this.data, key); } @SuppressWarnings("unchecked") - public void forEach(java.util.function.BiConsumer consumer) { + public void forEach(@Nonnull BiConsumer consumer) { Object[] mapData = this.data; if (mapData == null) return; int numSlots = mapData.length >> 1; @@ -70,17 +86,17 @@ public static final class EmbeddingSupport { // an internal marker that we don't want intern-ed static final String REMOVED = new String("\0D\0a\07\04\0\0d\00\0G"); - public static final Object[] EMPTY_DATA = null; + @Nullable public static final Object[] EMPTY_DATA = null; - public static boolean isDefinitelyEmpty(Object[] mapData) { + public static boolean isDefinitelyEmpty(@Nullable Object[] mapData) { return (mapData == null); } - public static int numSlots(Object[] mapData) { + public static int numSlots(@Nullable Object[] mapData) { return mapData == null ? 0 : mapData.length >> 1; } - public static int size(Object[] mapData) { + public static int size(@Nullable Object[] mapData) { if (mapData == null) return 0; int size = 0; @@ -92,14 +108,16 @@ public static int size(Object[] mapData) { return size; } - public static Object[] clear(Object[] mapData) { + @Nullable + public static Object[] clear(@Nullable Object[] mapData) { if (mapData != null) { Arrays.fill(mapData, null); } return mapData; } - public static Object[] copy(Object[] mapData) { + @Nullable + public static Object[] copy(@Nullable Object[] mapData) { return mapData == null ? null : mapData.clone(); } @@ -107,11 +125,12 @@ public static boolean isPresent(int slotIndex) { return (slotIndex >= 0); } - public static boolean isRemoved(String key) { + public static boolean isRemoved(@Nonnull String key) { return (key == REMOVED); } - public static String keyAt(Object[] mapData, int slotIndex) { + @Nullable + public static String keyAt(@Nullable Object[] mapData, int slotIndex) { if (mapData == null) return null; if (slotIndex < 0) return null; @@ -120,20 +139,22 @@ public static String keyAt(Object[] mapData, int slotIndex) { } @SuppressWarnings("unchecked") - public static V valueAt(Object[] mapData, int slotIndex) { - if (mapData == null) return null; + @Nullable + public static V valueAt(@Nullable Object[] mapData, int slotIndex) { + if (mapData == null || slotIndex < 0) return null; return (V) mapData[slotIndex + numSlots(mapData)]; } @SuppressWarnings("unchecked") - public static V prevValueAt(Object[] mapData, int slotIndex) { + @Nullable + public static V prevValueAt(@Nullable Object[] mapData, int slotIndex) { if (mapData == null || slotIndex < 0) return null; return (V) mapData[slotIndex + numSlots(mapData)]; } - public static final boolean containsKey(Object[] mapData, String key) { + public static final boolean containsKey(@Nullable Object[] mapData, @Nonnull String key) { if (mapData == null) return false; // not bothering with optimizing literal checks @@ -156,7 +177,7 @@ public static final boolean containsKey(Object[] mapData, String key) { return false; } - public static final boolean containsValue(Object[] mapData, Object value) { + public static final boolean containsValue(@Nullable Object[] mapData, @Nonnull Object value) { if (mapData == null) return false; for (int valueIndex = numSlots(mapData); valueIndex < mapData.length; ++valueIndex) { @@ -166,16 +187,21 @@ public static final boolean containsValue(Object[] mapData, Object value) { return false; } - public static Object[] set(Object[] mapData, String key, V value) { + @Nonnull + public static Object[] set( + @Nullable Object[] mapData, @Nonnull String key, @Nonnull V value) { return set(DEFAULT_CAPACITY, mapData, key, value); } - public static Object[] setAll(Object[] destMapData, Object[] srcMapData) { + @Nullable + public static Object[] setAll( + @Nullable Object[] destMapData, @Nullable Object[] srcMapData) { return setAll(destMapData, size(destMapData), srcMapData, size(srcMapData)); } + @Nullable public static Object[] setAll( - Object[] destMapData, int destSize, Object[] srcMapData, int srcSize) { + @Nullable Object[] destMapData, int destSize, @Nullable Object[] srcMapData, int srcSize) { if (srcMapData == null || srcSize == 0) return destMapData; if (destMapData == null) return srcMapData.clone(); @@ -203,7 +229,9 @@ public static Object[] setAll( return destMapData; } - public static Object[] set(int initialCapacity, Object[] mapData, String key, V value) { + @Nonnull + public static Object[] set( + int initialCapacity, @Nullable Object[] mapData, @Nonnull String key, @Nonnull V value) { if (mapData == null) { int numSlots = roundUpToPow2(initialCapacity); mapData = new Object[numSlots << 1]; @@ -226,7 +254,8 @@ public static Object[] set(int initialCapacity, Object[] mapData, String key } @SuppressWarnings("unchecked") - public static V get(Object[] mapData, String key) { + @Nullable + public static V get(@Nullable Object[] mapData, @Nonnull String key) { if (mapData == null) return null; int numSlots = numSlots(mapData); @@ -235,7 +264,7 @@ public static V get(Object[] mapData, String key) { return (foundIndex >= 0) ? (V) mapData[numSlots + foundIndex] : null; } - public static boolean remove(Object[] mapData, String key) { + public static boolean remove(@Nullable Object[] mapData, @Nonnull String key) { if (mapData == null) return false; int numSlots = numSlots(mapData); @@ -250,7 +279,8 @@ public static boolean remove(Object[] mapData, String key) { } } - public static boolean checkedInsert(Object[] mapData, int numSlots, String key, Object value) { + public static boolean checkedInsert( + @Nonnull Object[] mapData, int numSlots, @Nonnull String key, @Nonnull Object value) { int insertionSlot = findInsertionSlot(mapData, numSlots, key); if (insertionSlot >= 0) { @@ -266,14 +296,19 @@ public static boolean checkedInsert(Object[] mapData, int numSlots, String key, } } - public static int findInsertionSlot(Object[] mapData, String key) { + public static int findInsertionSlot(@Nullable Object[] mapData, @Nonnull String key) { if (mapData == null) return NO_SPACE; return findInsertionSlot(mapData, numSlots(mapData), key); } + @Nonnull public static final Object[] insertAt( - int initialCapacity, Object[] mapData, int insertionSlot, String key, Object value) { + int initialCapacity, + @Nullable Object[] mapData, + int insertionSlot, + @Nonnull String key, + @Nonnull Object value) { if (mapData == null) { return newMapData(initialCapacity, key, value); } @@ -319,14 +354,15 @@ static int flip(int keyIndex) { return -keyIndex - 1; } - public static final void removeAt(Object[] mapData, int slot) { + public static final void removeAt(@Nullable Object[] mapData, int slot) { if (mapData == null || slot < 0) return; mapData[slot] = REMOVED; mapData[slot + numSlots(mapData)] = null; } - public static final Object getAndRemoveAt(Object[] mapData, int slot) { + @Nullable + public static final Object getAndRemoveAt(@Nullable Object[] mapData, int slot) { if (mapData == null || slot < 0) return null; mapData[slot] = REMOVED; @@ -338,7 +374,7 @@ public static final Object getAndRemoveAt(Object[] mapData, int slot) { return prev; } - public static final int findSlot(Object[] mapData, String key) { + public static final int findSlot(@Nullable Object[] mapData, @Nonnull String key) { if (mapData == null) return NOT_FOUND; return findSlot(mapData, numSlots(mapData), key); @@ -375,13 +411,15 @@ static final Object[] newMapData(int initialCapacity, String key, Object value) return mapData; } - public static final Object[] expandMapData(Object[] mapData) { + @Nonnull + public static final Object[] expandMapData(@Nonnull Object[] mapData) { // Subtle - capacity is in terms of slots - not array size, so passing length is // asking to double capacity return expandMapData(mapData, mapData.length); } - public static Object[] expandMapData(Object[] origMapData, int newCapacity) { + @Nonnull + public static Object[] expandMapData(@Nonnull Object[] origMapData, int newCapacity) { newCapacity = roundUpToPow2(newCapacity); int newSize = newCapacity << 1; // Don't try to optimize by returning origMapData if big enough to contain @@ -401,7 +439,8 @@ public static Object[] expandMapData(Object[] origMapData, int newCapacity) { return newMapData; } - public static void forEach(Object[] mapData, BiConsumer entryConsumer) { + public static void forEach( + @Nullable Object[] mapData, @Nonnull BiConsumer entryConsumer) { if (mapData == null) return; int numSlots = numSlots(mapData); @@ -415,7 +454,7 @@ public static void forEach(Object[] mapData, BiConsumer entryCon } public static void forEach( - Object[] mapData, C ctx, TriConsumer entryConsumer) { + @Nullable Object[] mapData, C ctx, @Nonnull TriConsumer entryConsumer) { if (mapData == null) return; int numSlots = numSlots(mapData); @@ -428,7 +467,8 @@ public static void forEach( } } - public static String toInternalString(Object[] mapData) { + @Nonnull + public static String toInternalString(@Nullable Object[] mapData) { StringBuilder builder = new StringBuilder(128); builder.append('{'); From 543bf45b7d43f1355457f2edcd5e50d5eabe395b Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 12:32:00 -0400 Subject: [PATCH 06/43] LightStringMap: test containsKey and null-value rejection Co-Authored-By: Claude Opus 4.8 --- .../trace/util/LightStringMapTest.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java b/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java index 6b0bfe03b62..7274fc8278c 100644 --- a/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java +++ b/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.HashMap; @@ -52,6 +53,24 @@ void removeMakesKeyAbsent() { assertEquals(2, map.get("b")); } + @Test + void containsKeyDistinguishesPresenceFromAbsence() { + LightStringMap map = new LightStringMap<>(8); + assertFalse(map.containsKey("a")); + map.set("a", 1); + assertTrue(map.containsKey("a")); + assertFalse(map.containsKey("b")); + map.remove("a"); + assertFalse(map.containsKey("a")); + } + + @Test + void setRejectsNullValue() { + LightStringMap map = new LightStringMap<>(8); + assertThrows(NullPointerException.class, () -> map.set("a", null)); + assertFalse(map.containsKey("a")); + } + @Test void growsAndPreservesAllEntries() { // initial capacity 2 forces several resizes as we insert well past it. From d6250cf19eb6514afbc8ff1e0563b91f3a25bb4d Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 13:28:16 -0400 Subject: [PATCH 07/43] LightStringMap: self-tuning SizingHint and grow-at-threshold Add a per-construction-site SizingHint: a caller declares one static final hint via LightStringMap.sizingHint(), passes it to the constructor, and never touches it again. The map seeds itself from the hint and tunes it back on its own -- monotonic-max on grow (with one power-of-two class of headroom) and a periodic step-down decay so a stale high-water self-corrects. Also add object-tier growth-at-threshold: the map now tracks its live entry count and grows once a new key would exceed a 0.75 load factor, instead of filling to a load factor of 1.0 before doubling -- keeping linear-probe chains short. The static EmbeddingSupport spine has no live count to consult, so it keeps its grow-only-when-physically-full behavior; the threshold is purely an object-tier policy. Co-Authored-By: Claude Opus 4.8 --- .../datadog/trace/util/LightStringMap.java | 161 +++++++++++++++++- 1 file changed, 159 insertions(+), 2 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java index c8c298e403c..9e9829a50cd 100644 --- a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java +++ b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java @@ -41,16 +41,95 @@ public final class LightStringMap { public static final int DEFAULT_CAPACITY = 8; + // Slots a fresh (un-tuned) hint seeds -- a reasonable default so a cold site behaves like a + // plain new LightStringMap(DEFAULT_CAPACITY); it then self-tunes up or down from here. + static final int DEFAULT_HINT_SLOTS = DEFAULT_CAPACITY; + // Floor step-down never drops a hint below (in slots), so a genuinely tiny site can still tune + // below the default. Must stay >= 1 (a zero-slot table is degenerate). + static final int MIN_HINT_SLOTS = 1; + // Step the learned estimate down one power-of-two class every this-many constructions. A + // power of two so the tick test is a bit-mask. Large => decay is a slow background correction, + // not something that fights a steady workload. + static final int DECAY_INTERVAL = 1024; + // Safety ceiling on how large a hint will pre-provision (in slots). Bounds the shared hint's + // over-provision from an outlier; the map itself is uncapped and grows past this on its own. + static final int MAX_HINT_SLOTS = 1024; + + // Object-tier growth trigger. We grow before the live entry count would exceed this fraction of + // the slots, so linear-probe chains stay short instead of the map filling to a load factor of + // 1.0 right before every doubling. Expressed as a 1/4 reserve (a 0.75 trigger) via a shift so + // the hot-path check stays float-free. The static EmbeddingSupport spine has no live count to + // consult, so it keeps its grow-only-when-physically-full behavior -- this threshold is purely + // an object-tier policy layered on top of the spine. + private static final int LOAD_FACTOR_RESERVE_SHIFT = 2; + private final int initialCapacity; + @Nullable private final SizingHint sizingHint; private Object[] data = EmbeddingSupport.EMPTY_DATA; + // Live entry count (excludes tombstones), tracked so the growth threshold is an O(1) check + // rather than an O(capacity) scan on the hot path. + private int size; public LightStringMap(int capacity) { this.initialCapacity = capacity; + this.sizingHint = null; + } + + public LightStringMap(@Nonnull SizingHint hint) { + this.sizingHint = hint; + this.initialCapacity = hint.seedSlots(); + } + + /** + * Mints a self-tuning {@link SizingHint} for a single construction site. Hold it in a {@code + * static final} field and pass it to every {@link #LightStringMap(SizingHint)} at that site; the + * map sizes itself from the hint and tunes the hint back on its own. The caller never touches the + * hint again. + */ + @Nonnull + public static SizingHint sizingHint() { + return new SizingHint(); } public void set(@Nonnull String key, @Nonnull V value) { Objects.requireNonNull(value, "value"); - this.data = EmbeddingSupport.set(this.initialCapacity, this.data, key, value); + + Object[] mapData = this.data; + if (mapData == null) { + // Lazy first allocation, sized to the seed. This is the seed itself, not a grow, so it is + // never recorded back to the hint. + this.data = EmbeddingSupport.newMapData(this.initialCapacity, key, value); + this.size = 1; + return; + } + + int numSlots = EmbeddingSupport.numSlots(mapData); + int slot = EmbeddingSupport.findInsertionSlot(mapData, numSlots, key); + if (slot >= 0) { + // Key already present -- overwrite in place, no size change, no growth. + mapData[slot + numSlots] = value; + return; + } + + // A new key. Grow first if there is no space at all, or if landing it would push the live + // count past the load-factor threshold; otherwise fill the available slot directly. + if (slot == EmbeddingSupport.NO_SPACE || this.size + 1 > growThreshold(numSlots)) { + mapData = EmbeddingSupport.expandMapData(mapData); + this.data = mapData; + numSlots = EmbeddingSupport.numSlots(mapData); + EmbeddingSupport.newMapUncheckedInsert(mapData, numSlots, key, value); + this.size++; + // Record the grow so the hint learns this site's high-water mark. + if (this.sizingHint != null) { + this.sizingHint.recordSlots(numSlots); + } + return; + } + + int availableSlot = EmbeddingSupport.flip(slot); + mapData[availableSlot] = key; + mapData[availableSlot + numSlots] = value; + this.size++; } @Nullable @@ -59,7 +138,19 @@ public V get(@Nonnull String key) { } public void remove(@Nonnull String key) { - EmbeddingSupport.remove(this.data, key); + if (EmbeddingSupport.remove(this.data, key)) { + this.size--; + } + } + + /** The number of live entries in this map (tombstones excluded). */ + public int size() { + return this.size; + } + + // Grow before the live count exceeds this many slots -- a 1/4 reserve, i.e. a 0.75 load factor. + private static int growThreshold(int numSlots) { + return numSlots - (numSlots >> LOAD_FACTOR_RESERVE_SHIFT); } public boolean containsKey(@Nonnull String key) { @@ -78,6 +169,72 @@ public void forEach(@Nonnull BiConsumer consumer) { } } + // Visible for testing: the backing spine (null until the first set). + @Nullable + Object[] dataForTesting() { + return this.data; + } + + /** + * A self-tuning, per-construction-site sizing estimate. Mint one via {@link + * LightStringMap#sizingHint()}, hold it in a {@code static final} field, and pass it to {@link + * LightStringMap#LightStringMap(SizingHint)}; the map reads it to size itself and tunes it back + * as it grows. The caller never updates it. + * + *

Opaque by design (no public members). The estimate self-tunes on two events the map already + * observes -- a new map is started ({@link #seedSlots()}) and a map grows ({@link + * #recordSlots(int)}) -- so it is tier-agnostic: the same hint can drive both this object and the + * static {@link EmbeddingSupport} spine. + * + *

Tuning is racy by design: {@code slots}/{@code constructs} are plain ints, so a lost or torn + * update only mis-sizes a future array (over/under-provision) for an instance or two, never + * corrupts map data -- no synchronization. + */ + public static final class SizingHint { + // Learned seed capacity in slots (always a power of two). Additive-increase on grow (with one + // class of headroom), multiplicative-decrease on the decay tick. + private int slots = DEFAULT_HINT_SLOTS; + // Approximate count of maps started from this hint; drives the periodic step-down decay. + private int constructs; + + private SizingHint() {} + + /** + * The seed capacity (in slots) for a map just started from this hint. Advances the decay clock + * and, every {@link #DECAY_INTERVAL} maps, steps the estimate down one class so a stale + * high-water from a past spike self-corrects; if that made it too tight, the next grow snaps it + * back. + */ + int seedSlots() { + int n = ++this.constructs; // racy; a torn read only jitters the decay cadence + if ((n & (DECAY_INTERVAL - 1)) == 0) { + int reduced = this.slots >> 1; + this.slots = (reduced < MIN_HINT_SLOTS) ? MIN_HINT_SLOTS : reduced; + } + return this.slots; + } + + /** + * Records that a map grew to {@code grownSlots}. Reserves one extra power-of-two class so the + * steady-state load factor stays ≤ 0.5 (kills the load-factor-1.0 tail without a terminal + * read), monotonic-max, clamped to {@link #MAX_HINT_SLOTS}. + */ + void recordSlots(int grownSlots) { + int candidate = grownSlots << 1; + if (candidate > MAX_HINT_SLOTS) { + candidate = MAX_HINT_SLOTS; + } + if (candidate > this.slots) { + this.slots = candidate; + } + } + + // Visible for testing: the current learned seed capacity in slots. + int currentSeedSlots() { + return this.slots; + } + } + public static final class EmbeddingSupport { public static final int NOT_FOUND = Integer.MIN_VALUE; static final int NO_SPACE = Integer.MIN_VALUE; From 65a46dc2baebf2a6da831acda878277d15301b9b Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 13:28:18 -0400 Subject: [PATCH 08/43] LightStringMap: test self-tuning SizingHint and grow-at-threshold Co-Authored-By: Claude Opus 4.8 --- .../trace/util/LightStringMapTest.java | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) diff --git a/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java b/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java index 7274fc8278c..b012fe2bf8f 100644 --- a/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java +++ b/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java @@ -71,6 +71,39 @@ void setRejectsNullValue() { assertFalse(map.containsKey("a")); } + @Test + void sizeTracksLiveEntries() { + LightStringMap map = new LightStringMap<>(8); + assertEquals(0, map.size()); + map.set("a", 1); + map.set("b", 2); + assertEquals(2, map.size()); + map.set("a", 11); // overwrite does not change size + assertEquals(2, map.size()); + map.remove("a"); + assertEquals(1, map.size()); + map.remove("missing"); // no-op does not change size + assertEquals(1, map.size()); + } + + @Test + void growsBeforeReachingFullLoad() { + // With a 0.75 load-factor trigger, a capacity-8 map grows on the 7th distinct key rather + // than filling all 8 slots first -- keeping linear-probe chains short. + LightStringMap map = new LightStringMap<>(8); + for (int i = 0; i < 6; i++) { + map.set("k" + i, i); + } + assertEquals(8, EmbeddingSupport.numSlots(map.dataForTesting())); + map.set("k6", 6); + assertEquals(16, EmbeddingSupport.numSlots(map.dataForTesting())); + // All entries survive the grow. + assertEquals(7, map.size()); + for (int i = 0; i < 7; i++) { + assertEquals(i, map.get("k" + i)); + } + } + @Test void growsAndPreservesAllEntries() { // initial capacity 2 forces several resizes as we insert well past it. @@ -367,4 +400,139 @@ void isRemovedOnlyMatchesTheSentinel() { assertFalse(EmbeddingSupport.isRemoved(new String("REMOVED"))); } } + + @Nested + class SizingHintTests { + + @Test + void freshHintSeedsAtDefault() { + LightStringMap.SizingHint hint = LightStringMap.sizingHint(); + assertEquals(LightStringMap.DEFAULT_HINT_SLOTS, hint.currentSeedSlots()); + } + + @Test + void hintSeedsAFreshMapAtItsLearnedCapacity() { + LightStringMap.SizingHint hint = LightStringMap.sizingHint(); + LightStringMap map = new LightStringMap<>(hint); + // A hint-seeded map allocates its backing array lazily, sized to the hint. + map.set("a", 1); + Object[] data = map.dataForTesting(); + assertEquals(LightStringMap.DEFAULT_HINT_SLOTS, EmbeddingSupport.numSlots(data)); + } + + @Test + void growthRaisesSeedWithOneClassOfHeadroom() { + LightStringMap.SizingHint hint = LightStringMap.sizingHint(); + // Fill a hint-seeded map past its seed so it grows; the hint should learn the new size + // PLUS one power-of-two class of headroom (so the steady-state load factor stays <= 0.5). + LightStringMap map = new LightStringMap<>(hint); + for (int i = 0; i < LightStringMap.DEFAULT_HINT_SLOTS + 1; i++) { + map.set("k" + i, i); + } + int grownSlots = EmbeddingSupport.numSlots(map.dataForTesting()); + assertEquals(grownSlots * 2, hint.currentSeedSlots()); + } + + @Test + void seedIsMonotonicMaxAcrossMaps() { + LightStringMap.SizingHint hint = LightStringMap.sizingHint(); + // A big map ratchets the hint up. + LightStringMap big = new LightStringMap<>(hint); + for (int i = 0; i < 20; i++) { + big.set("k" + i, i); + } + int learned = hint.currentSeedSlots(); + assertTrue(learned > LightStringMap.DEFAULT_HINT_SLOTS); + // A subsequent tiny map does not lower the learned seed. + LightStringMap small = new LightStringMap<>(hint); + small.set("a", 1); + assertEquals(learned, hint.currentSeedSlots()); + } + + @Test + void decayStepsSeedDownAfterInterval() { + LightStringMap.SizingHint hint = LightStringMap.sizingHint(); + // Ratchet the hint above the default so a step-down is observable. + LightStringMap big = new LightStringMap<>(hint); + for (int i = 0; i < 20; i++) { + big.set("k" + i, i); + } + int learned = hint.currentSeedSlots(); + // One full decay interval of constructions steps the seed down exactly one class. + for (int i = 0; i < LightStringMap.DECAY_INTERVAL; i++) { + new LightStringMap(hint); + } + assertEquals(learned / 2, hint.currentSeedSlots()); + } + + @Test + void decayFloorsAtMinimum() { + LightStringMap.SizingHint hint = LightStringMap.sizingHint(); + // Enough decay intervals to drive an un-ratcheted hint to the floor and hold there. + int intervals = 32; + for (int i = 0; i < intervals * LightStringMap.DECAY_INTERVAL; i++) { + new LightStringMap(hint); + } + assertEquals(LightStringMap.MIN_HINT_SLOTS, hint.currentSeedSlots()); + } + + @Test + void seedIsCappedAtMax() { + LightStringMap.SizingHint hint = LightStringMap.sizingHint(); + // A very large map cannot push the learned seed past the pre-provisioning ceiling. + LightStringMap big = new LightStringMap<>(hint); + for (int i = 0; i < LightStringMap.MAX_HINT_SLOTS * 4; i++) { + big.set("k" + i, i); + } + assertTrue(hint.currentSeedSlots() <= LightStringMap.MAX_HINT_SLOTS); + assertEquals(LightStringMap.MAX_HINT_SLOTS, hint.currentSeedSlots()); + } + + @Test + void oneDecayStepStaysSafeThenSecondDecayRepins() { + LightStringMap.SizingHint hint = LightStringMap.sizingHint(); + // Learn a large size. With one class of headroom, `learned` is 2x the array the workload + // physically grew into. + LightStringMap big = new LightStringMap<>(hint); + for (int i = 0; i < 20; i++) { + big.set("k" + i, i); + } + int learned = hint.currentSeedSlots(); + + // First decay lands the seed exactly on the physical high-water: the same workload now fits + // without regrowing, so the seed holds (the headroom step-down is "free"). + for (int i = 0; i < LightStringMap.DECAY_INTERVAL; i++) { + new LightStringMap(hint); + } + assertEquals(learned / 2, hint.currentSeedSlots()); + LightStringMap stillFits = new LightStringMap<>(hint); + for (int i = 0; i < 20; i++) { + stillFits.set("k" + i, i); + } + assertEquals(learned / 2, hint.currentSeedSlots()); + + // Second decay probes below the need: the workload now regrows and snaps the seed back up. + for (int i = 0; i < LightStringMap.DECAY_INTERVAL; i++) { + new LightStringMap(hint); + } + assertEquals(learned / 4, hint.currentSeedSlots()); + LightStringMap recovered = new LightStringMap<>(hint); + for (int i = 0; i < 20; i++) { + recovered.set("k" + i, i); + } + assertEquals(learned, hint.currentSeedSlots()); + } + + @Test + void hintSeededMapStoresAndReadsBackCorrectly() { + LightStringMap.SizingHint hint = LightStringMap.sizingHint(); + LightStringMap map = new LightStringMap<>(hint); + for (int i = 0; i < 50; i++) { + map.set("k" + i, i); + } + for (int i = 0; i < 50; i++) { + assertEquals(i, map.get("k" + i)); + } + } + } } From 0dab4b65bd0cc5bddcb12a0d06ecc7448a831f1d Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 14:11:14 -0400 Subject: [PATCH 09/43] LightStringMap: probe-bound grow trigger in the spine; object becomes a thin delegate Replace the object-tier 0.75 load-factor grow threshold with a stateless maximum-probe-distance trigger in the EmbeddingSupport spine: an insertion that would land MAX_PROBES (8) or more slots from its home slot forces a resize. Why max-probes over load factor (measured, LightStringMapGrowBenchmark, -f 5): a load factor is blind to local clustering -- colliding keys pile into one probe chain long before global occupancy is high -- so it gives no worst-case bound. On adversarial (all-colliding) keys a load-factor table reads at 31.9 ns/op, identical to grow-when-full; the probe bound reads at 6.4 ns/op (~5x). On the tiny, interned-key consumer profile the trigger is provably inert (a probe can never travel 8 slots in an 8-slot table), so that case is byte-for-byte unchanged -- build/get within noise across all three triggers. Because the trigger is derived entirely from the probe walk, it needs no live count, so it moves into the static spine and the object collapses to a thin delegate: set() delegates to EmbeddingSupport.set and only teaches the sizing hint on a genuine grow. Drops the size field, growThreshold, and LOAD_FACTOR_RESERVE_SHIFT; size() reverts to the spine's O(n) scan (its original behavior -- the field existed only to serve the load-factor threshold). Co-Authored-By: Claude Opus 4.8 --- .../datadog/trace/util/LightStringMap.java | 115 +++++++++--------- 1 file changed, 55 insertions(+), 60 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java index 9e9829a50cd..9d9562903f5 100644 --- a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java +++ b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java @@ -55,20 +55,9 @@ public final class LightStringMap { // over-provision from an outlier; the map itself is uncapped and grows past this on its own. static final int MAX_HINT_SLOTS = 1024; - // Object-tier growth trigger. We grow before the live entry count would exceed this fraction of - // the slots, so linear-probe chains stay short instead of the map filling to a load factor of - // 1.0 right before every doubling. Expressed as a 1/4 reserve (a 0.75 trigger) via a shift so - // the hot-path check stays float-free. The static EmbeddingSupport spine has no live count to - // consult, so it keeps its grow-only-when-physically-full behavior -- this threshold is purely - // an object-tier policy layered on top of the spine. - private static final int LOAD_FACTOR_RESERVE_SHIFT = 2; - private final int initialCapacity; @Nullable private final SizingHint sizingHint; private Object[] data = EmbeddingSupport.EMPTY_DATA; - // Live entry count (excludes tombstones), tracked so the growth threshold is an O(1) check - // rather than an O(capacity) scan on the hot path. - private int size; public LightStringMap(int capacity) { this.initialCapacity = capacity; @@ -94,42 +83,21 @@ public static SizingHint sizingHint() { public void set(@Nonnull String key, @Nonnull V value) { Objects.requireNonNull(value, "value"); - Object[] mapData = this.data; - if (mapData == null) { - // Lazy first allocation, sized to the seed. This is the seed itself, not a grow, so it is - // never recorded back to the hint. - this.data = EmbeddingSupport.newMapData(this.initialCapacity, key, value); - this.size = 1; - return; - } - - int numSlots = EmbeddingSupport.numSlots(mapData); - int slot = EmbeddingSupport.findInsertionSlot(mapData, numSlots, key); - if (slot >= 0) { - // Key already present -- overwrite in place, no size change, no growth. - mapData[slot + numSlots] = value; - return; - } - - // A new key. Grow first if there is no space at all, or if landing it would push the live - // count past the load-factor threshold; otherwise fill the available slot directly. - if (slot == EmbeddingSupport.NO_SPACE || this.size + 1 > growThreshold(numSlots)) { - mapData = EmbeddingSupport.expandMapData(mapData); - this.data = mapData; - numSlots = EmbeddingSupport.numSlots(mapData); - EmbeddingSupport.newMapUncheckedInsert(mapData, numSlots, key, value); - this.size++; - // Record the grow so the hint learns this site's high-water mark. - if (this.sizingHint != null) { - this.sizingHint.recordSlots(numSlots); + Object[] before = this.data; + int beforeSlots = EmbeddingSupport.numSlots(before); + // The spine owns the grow decision (probe-bound trigger) and returns the map data, resized if + // it grew. The object is a thin delegate: its only extra job is to teach the sizing hint. + Object[] after = EmbeddingSupport.set(this.initialCapacity, before, key, value); + this.data = after; + + // Record a genuine grow (not the lazy first allocation, which seeds from beforeSlots == 0) so + // the hint learns this site's high-water mark. seedSlots()/newMapData never feed the hint. + if (this.sizingHint != null && beforeSlots != 0) { + int afterSlots = EmbeddingSupport.numSlots(after); + if (afterSlots > beforeSlots) { + this.sizingHint.recordSlots(afterSlots); } - return; } - - int availableSlot = EmbeddingSupport.flip(slot); - mapData[availableSlot] = key; - mapData[availableSlot + numSlots] = value; - this.size++; } @Nullable @@ -138,19 +106,12 @@ public V get(@Nonnull String key) { } public void remove(@Nonnull String key) { - if (EmbeddingSupport.remove(this.data, key)) { - this.size--; - } + EmbeddingSupport.remove(this.data, key); } /** The number of live entries in this map (tombstones excluded). */ public int size() { - return this.size; - } - - // Grow before the live count exceeds this many slots -- a 1/4 reserve, i.e. a 0.75 load factor. - private static int growThreshold(int numSlots) { - return numSlots - (numSlots >> LOAD_FACTOR_RESERVE_SHIFT); + return EmbeddingSupport.size(this.data); } public boolean containsKey(@Nonnull String key) { @@ -215,9 +176,9 @@ int seedSlots() { } /** - * Records that a map grew to {@code grownSlots}. Reserves one extra power-of-two class so the - * steady-state load factor stays ≤ 0.5 (kills the load-factor-1.0 tail without a terminal - * read), monotonic-max, clamped to {@link #MAX_HINT_SLOTS}. + * Records that a map grew to {@code grownSlots}. Reserves one extra power-of-two class so a + * reseeded map starts with slack and is unlikely to immediately re-trip the grow trigger for + * the same workload. Monotonic-max, clamped to {@link #MAX_HINT_SLOTS}. */ void recordSlots(int grownSlots) { int candidate = grownSlots << 1; @@ -239,6 +200,20 @@ public static final class EmbeddingSupport { public static final int NOT_FOUND = Integer.MIN_VALUE; static final int NO_SPACE = Integer.MIN_VALUE; + // Grow trigger: an insertion that would land this many slots or more from its home slot forces + // a resize, rather than waiting for the table to fill completely. This bounds the worst-case + // probe length (and therefore lookup cost) directly -- a load-factor threshold cannot, because + // it is blind to local clustering (colliding keys pile into one chain long before global + // occupancy is high). The check is derived entirely from the probe walk, so it is stateless and + // lives here in the spine rather than needing a maintained live count in the object tier. + // + // Chosen as a power-of-two-friendly 8 from measurement (LightStringMapGrowBenchmark): it caps + // the worst-case probe at 8 while keeping the memory over-provision modest on well-spread keys. + // Because a table never has more than (numSlots - 1) probe distance, this is inert for tables + // of + // 8 slots or fewer -- tiny maps still grow only when physically full, exactly as before. + static final int MAX_PROBES = 8; + // TODO: use of String constructor is deliberate, since this is // an internal marker that we don't want intern-ed static final String REMOVED = new String("\0D\0a\07\04\0\0d\00\0G"); @@ -400,16 +375,36 @@ public static Object[] set( } int numSlots = numSlots(mapData); - if (checkedInsert(mapData, numSlots, key, value)) { + int slot = findInsertionSlot(mapData, numSlots, key); + if (slot >= 0) { + // Key already present -- overwrite in place, no growth. + mapData[slot + numSlots] = value; + return mapData; + } + if (slot != NO_SPACE && withinProbeBound(numSlots, slot, key)) { + // A free slot within the probe bound -- fill it directly. + int availableSlot = flip(slot); + mapData[availableSlot] = key; + mapData[availableSlot + numSlots] = value; return mapData; } + // No space at all, or the insertion would exceed the probe bound: grow, then insert into the + // fresh (tombstone-free, better-spread) table. mapData = expandMapData(mapData); - numSlots = numSlots(mapData); - newMapUncheckedInsert(mapData, numSlots, key, value); + newMapUncheckedInsert(mapData, numSlots(mapData), key, value); return mapData; } + // Whether an insertion at the free slot encoded by {@code insertionSlot} (as returned by + // findInsertionSlot for an absent key) lands within {@link #MAX_PROBES} of the key's home slot. + private static boolean withinProbeBound(int numSlots, int insertionSlot, String key) { + int availableSlot = flip(insertionSlot); + int home = preferredSlot(numSlots, key.hashCode()); + int distance = (availableSlot - home) & (numSlots - 1); + return distance < MAX_PROBES; + } + @SuppressWarnings("unchecked") @Nullable public static V get(@Nullable Object[] mapData, @Nonnull String key) { From 73fddc0062333cbc9823755d52ebcf5199a8a3f5 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 14:11:30 -0400 Subject: [PATCH 10/43] LightStringMap: test the probe-bound grow trigger Replace growsBeforeReachingFullLoad (asserted the removed 0.75 load-factor threshold) with two tests for the new behavior: - tinyMapGrowsOnlyWhenPhysicallyFull: a seed-8 map fills all 8 slots before it grows, locking in the guarantee that the trigger is inert on tiny maps (the consumer-neutral property). - clusteredKeysGrowEarlierThanWellSpreadKeys: the same key count, all colliding onto one home slot, drives a strictly larger table than a well-spread set -- proving the probe bound fires on clustering (a load factor would not have), while both sets stay fully retrievable. Co-Authored-By: Claude Opus 4.8 --- .../trace/util/LightStringMapTest.java | 71 ++++++++++++++++--- 1 file changed, 61 insertions(+), 10 deletions(-) diff --git a/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java b/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java index b012fe2bf8f..35a6218b997 100644 --- a/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java +++ b/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java @@ -7,7 +7,9 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -87,23 +89,56 @@ void sizeTracksLiveEntries() { } @Test - void growsBeforeReachingFullLoad() { - // With a 0.75 load-factor trigger, a capacity-8 map grows on the 7th distinct key rather - // than filling all 8 slots first -- keeping linear-probe chains short. + void tinyMapGrowsOnlyWhenPhysicallyFull() { + // The probe-bound grow trigger (MAX_PROBES == 8) cannot fire on an 8-slot table -- a probe + // can never travel 8 slots there -- so a seed-8 map fills all 8 slots before it grows, + // exactly as it did before the trigger existed. This is the property that keeps the tiny, + // miss-dominated consumer (springweb6 localAttributes) behaviorally unchanged. LightStringMap map = new LightStringMap<>(8); - for (int i = 0; i < 6; i++) { + for (int i = 0; i < 8; i++) { map.set("k" + i, i); } - assertEquals(8, EmbeddingSupport.numSlots(map.dataForTesting())); - map.set("k6", 6); - assertEquals(16, EmbeddingSupport.numSlots(map.dataForTesting())); - // All entries survive the grow. - assertEquals(7, map.size()); - for (int i = 0; i < 7; i++) { + assertEquals( + 8, EmbeddingSupport.numSlots(map.dataForTesting()), "fills to full before growing"); + map.set("k8", 8); + assertEquals(16, EmbeddingSupport.numSlots(map.dataForTesting()), "9th key forces the grow"); + assertEquals(9, map.size()); + for (int i = 0; i < 9; i++) { assertEquals(i, map.get("k" + i)); } } + @Test + void clusteredKeysGrowEarlierThanWellSpreadKeys() { + // The whole point of the probe bound: it responds to local clustering that a load factor is + // blind to. The same number of keys, all colliding onto one home slot, must drive the table + // strictly larger than a well-spread set would -- the trigger is firing on long probe chains + // well before the table is anywhere near full. Both sets stay fully retrievable. + int count = 32; + + LightStringMap spread = new LightStringMap<>(8); + for (int i = 0; i < count; i++) { + spread.set("spread." + i, i); + } + + LightStringMap clustered = new LightStringMap<>(8); + List colliding = collidingKeys(count); + for (int i = 0; i < count; i++) { + clustered.set(colliding.get(i), i); + } + + int spreadSlots = EmbeddingSupport.numSlots(spread.dataForTesting()); + int clusteredSlots = EmbeddingSupport.numSlots(clustered.dataForTesting()); + assertTrue( + clusteredSlots > spreadSlots, + "clustered keys should over-grow (" + clusteredSlots + " vs spread " + spreadSlots + ")"); + + for (int i = 0; i < count; i++) { + assertEquals(i, spread.get("spread." + i)); + assertEquals(i, clustered.get(colliding.get(i))); + } + } + @Test void growsAndPreservesAllEntries() { // initial capacity 2 forces several resizes as we insert well past it. @@ -144,6 +179,22 @@ void nonLiteralKeyResolvesViaEqualsFallback() { assertEquals(1, map.get(lookup)); } + // Strings whose home slot collides in a large reference table (2^16 slots), and therefore in + // every smaller power-of-two table the map passes through while growing -- so they pile onto a + // single probe chain no matter how the map is sized here. + private List collidingKeys(int count) { + int reference = 1 << 16; + int target = EmbeddingSupport.preferredSlot(reference, "anchor".hashCode()); + List keys = new ArrayList<>(count); + for (int i = 0; keys.size() < count; i++) { + String candidate = "collide." + i; + if (EmbeddingSupport.preferredSlot(reference, candidate.hashCode()) == target) { + keys.add(candidate); + } + } + return keys; + } + @Test void removeThenReinsertSameKey() { LightStringMap map = new LightStringMap<>(4); From 7b0eba1e13c81f73f83a6d67ba7346887c90cdd4 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 14:11:30 -0400 Subject: [PATCH 11/43] LightStringMap: JMH benchmark for the grow trigger Add LightStringMapGrowBenchmark, the measurement backing the probe-bound trigger choice. Implements all three candidate triggers (FULL / LOAD_FACTOR / MAX_PROBES) over the real EmbeddingSupport spine across three regimes (tiny/interned, medium/well-spread, adversarial/all-colliding). @Benchmark methods answer the hot-path-tax question; a main() prints a timing-free probe-length distribution and grow count per arm. Co-Authored-By: Claude Opus 4.8 --- .../util/LightStringMapGrowBenchmark.java | 311 ++++++++++++++++++ 1 file changed, 311 insertions(+) create mode 100644 internal-api/src/jmh/java/datadog/trace/util/LightStringMapGrowBenchmark.java diff --git a/internal-api/src/jmh/java/datadog/trace/util/LightStringMapGrowBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/LightStringMapGrowBenchmark.java new file mode 100644 index 00000000000..889f409a105 --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/util/LightStringMapGrowBenchmark.java @@ -0,0 +1,311 @@ +package datadog.trace.util; + +import datadog.trace.util.LightStringMap.EmbeddingSupport; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * Measures three candidate grow triggers for {@code LightStringMap}, to decide whether to reframe + * "when to grow" from a load-factor threshold to a maximum-probe bound. + * + *

    + *
  • {@code FULL} -- grow only when the table is physically full (the pre-existing spine + * behavior; the probe walks the whole table before giving up). + *
  • {@code LOAD_FACTOR} -- grow before the live count would exceed 0.75 of the slots (the + * currently committed object-tier policy; needs a maintained live count). + *
  • {@code MAX_PROBES} -- grow when an insertion would land more than {@code K} slots from its + * home slot (stateless; derived from the probe walk, so it can live entirely in the spine). + *
+ * + *

All three arms drive the same real {@code EmbeddingSupport} primitives (this + * benchmark lives in {@code datadog.trace.util} so it can call the package-private spine directly), + * so the only difference between arms is the grow decision. + * + *

The {@code @Benchmark} methods answer the hot-path tax question (does the extra probe + * distance check cost anything on the tiny, miss-dominated majority case?). The {@link #main} + * method runs a deterministic, timing-free analysis of the behavior question (grow frequency + * and the probe-length distribution each trigger produces), including the tail that a mean would + * hide. + */ +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Fork(1) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 3, time = 1) +@State(Scope.Thread) +public class LightStringMapGrowBenchmark { + + static final int SEED_SLOTS = 8; + static final int LOAD_FACTOR_RESERVE_SHIFT = 2; // 1/4 reserve => 0.75 trigger + static final int MAX_PROBES = 8; + + public enum Trigger { + FULL, + LOAD_FACTOR, + MAX_PROBES + } + + public enum Regime { + // springweb6 localAttributes: a handful of interned keys, get-heavy, some maps stay empty. + TINY, + // where triggers begin to diverge. + MEDIUM, + // keys crafted to collide to one home slot -- where MAX_PROBES should bound the tail that + // LOAD_FACTOR / FULL let run long. + ADVERSARIAL + } + + // ---- key sets ------------------------------------------------------------------------------- + + static String[] tinyKeys() { + return new String[] { + "org.springframework.web.servlet.HandlerMapping.bestMatchingPattern", + "org.springframework.web.servlet.HandlerMapping.pathWithinHandlerMapping", + "org.springframework.web.servlet.HandlerMapping.uriTemplateVariables", + "org.springframework.web.servlet.HandlerMapping.producibleMediaTypes", + "org.springframework.web.servlet.View.selectedContentType" + }; + } + + static String[] mediumKeys() { + String[] keys = new String[64]; + for (int i = 0; i < keys.length; i++) { + keys[i] = "attribute.key.number." + i; + } + return keys; + } + + // Strings whose home slot (after the spine's hash spread) collides mod 64, and therefore also mod + // every smaller power-of-two size the map passes through while growing from the 8-slot seed. + static String[] adversarialKeys(int count) { + List keys = new ArrayList<>(count); + int target = EmbeddingSupport.preferredSlot(64, "seed".hashCode()); + for (int i = 0; keys.size() < count; i++) { + String candidate = "adv" + i; + if (EmbeddingSupport.preferredSlot(64, candidate.hashCode()) == target) { + keys.add(candidate); + } + } + return keys.toArray(new String[0]); + } + + static String[] keysFor(Regime regime) { + switch (regime) { + case TINY: + return tinyKeys(); + case MEDIUM: + return mediumKeys(); + case ADVERSARIAL: + return adversarialKeys(48); + default: + throw new IllegalStateException(); + } + } + + // ---- the shared, trigger-parameterized insert over the real spine --------------------------- + + /** Per-map mutable state so LOAD_FACTOR can consult a live count and we can tally grows. */ + static final class Cursor { + Object[] data; + int size; + int grows; + } + + static void put(Trigger trigger, int k, Cursor c, String key, Object value) { + Object[] data = c.data; + if (data == null) { + c.data = EmbeddingSupport.newMapData(SEED_SLOTS, key, value); + c.size = 1; + return; + } + + int numSlots = EmbeddingSupport.numSlots(data); + int slot = EmbeddingSupport.findInsertionSlot(data, numSlots, key); + if (slot >= 0) { + // overwrite -- no size change, no grow + data[slot + numSlots] = value; + return; + } + + boolean grow; + if (slot == EmbeddingSupport.NO_SPACE) { + grow = true; + } else { + int available = EmbeddingSupport.flip(slot); + switch (trigger) { + case FULL: + grow = false; + break; + case LOAD_FACTOR: + grow = c.size + 1 > numSlots - (numSlots >> LOAD_FACTOR_RESERVE_SHIFT); + break; + case MAX_PROBES: + { + int home = EmbeddingSupport.preferredSlot(numSlots, key.hashCode()); + int distance = (available - home) & (numSlots - 1); + grow = distance >= k; + break; + } + default: + grow = false; + } + if (!grow) { + data[available] = key; + data[available + numSlots] = value; + c.size++; + return; + } + } + + data = EmbeddingSupport.expandMapData(data); + c.grows++; + EmbeddingSupport.newMapUncheckedInsert(data, EmbeddingSupport.numSlots(data), key, value); + c.data = data; + c.size++; + } + + static Object[] build(Trigger trigger, int k, String[] keys) { + Cursor c = new Cursor(); + for (int i = 0; i < keys.length; i++) { + put(trigger, k, c, keys[i], i); + } + return c.data; + } + + // ---- JMH state ------------------------------------------------------------------------------ + + @Param({"FULL", "LOAD_FACTOR", "MAX_PROBES"}) + Trigger trigger; + + @Param({"TINY", "MEDIUM", "ADVERSARIAL"}) + Regime regime; + + String[] insertionKeys; + String[] lookupKeys; // distinct instances (equals path) plus misses + Object[] prebuilt; + int index; + + @Setup(Level.Trial) + public void setUp() { + insertionKeys = keysFor(regime); + + // Lookup mix: distinct-instance copies of half the present keys (hits, via equals) interleaved + // with absent keys (misses) -- the miss-dominated read pattern of the wrapper. + lookupKeys = new String[insertionKeys.length]; + for (int i = 0; i < insertionKeys.length; i++) { + lookupKeys[i] = (i % 2 == 0) ? new String(insertionKeys[i]) : ("absent." + i); + } + + prebuilt = build(trigger, MAX_PROBES, insertionKeys); + } + + String nextLookupKey() { + if (++index >= lookupKeys.length) index = 0; + return lookupKeys[index]; + } + + @Benchmark + public Object[] build() { + return build(trigger, MAX_PROBES, insertionKeys); + } + + @Benchmark + public Object get() { + return EmbeddingSupport.get(prebuilt, nextLookupKey()); + } + + @Benchmark + public void buildThenReadAll(Blackhole bh) { + Object[] data = build(trigger, MAX_PROBES, insertionKeys); + for (int i = 0; i < lookupKeys.length; i++) { + bh.consume(EmbeddingSupport.get(data, lookupKeys[i])); + } + bh.consume(data); + } + + // ---- deterministic behavior analysis (run via main, no timing) ------------------------------ + + static int[] displacements(Object[] data) { + int numSlots = EmbeddingSupport.numSlots(data); + List ds = new ArrayList<>(); + for (int slot = 0; slot < numSlots; slot++) { + String key = (String) data[slot]; + if (key == null || EmbeddingSupport.isRemoved(key)) continue; + int home = EmbeddingSupport.preferredSlot(numSlots, key.hashCode()); + ds.add((slot - home) & (numSlots - 1)); + } + int[] out = new int[ds.size()]; + for (int i = 0; i < out.length; i++) out[i] = ds.get(i); + Arrays.sort(out); + return out; + } + + static int pct(int[] sorted, double p) { + if (sorted.length == 0) return 0; + int idx = (int) Math.ceil(p * sorted.length) - 1; + if (idx < 0) idx = 0; + if (idx >= sorted.length) idx = sorted.length - 1; + return sorted[idx]; + } + + static double mean(int[] xs) { + if (xs.length == 0) return 0; + long sum = 0; + for (int x : xs) sum += x; + return (double) sum / xs.length; + } + + public static void main(String[] args) { + int[] ks = {4, 8}; + System.out.printf( + "%-12s %-12s %6s %6s %6s %6s %6s %6s %6s %6s%n", + "regime", "trigger", "n", "slots", "LF", "grows", "meanP", "p90", "p99", "maxP"); + for (Regime regime : Regime.values()) { + String[] keys = keysFor(regime); + // FULL and LOAD_FACTOR ignore K; MAX_PROBES reported for each K. + reportRow(regime, Trigger.FULL, 0, keys); + reportRow(regime, Trigger.LOAD_FACTOR, 0, keys); + for (int k : ks) { + reportRow(regime, Trigger.MAX_PROBES, k, keys); + } + System.out.println(); + } + } + + static void reportRow(Regime regime, Trigger trigger, int k, String[] keys) { + Cursor c = new Cursor(); + for (int i = 0; i < keys.length; i++) { + put(trigger, k, c, keys[i], i); + } + int[] ds = displacements(c.data); + int slots = EmbeddingSupport.numSlots(c.data); + String label = trigger == Trigger.MAX_PROBES ? "MAX_PROBES(" + k + ")" : trigger.name(); + System.out.printf( + "%-12s %-12s %6d %6d %6.2f %6d %6.2f %6d %6d %6d%n", + regime.name(), + label, + ds.length, + slots, + slots == 0 ? 0.0 : (double) ds.length / slots, + c.grows, + mean(ds), + pct(ds, 0.90), + pct(ds, 0.99), + ds.length == 0 ? 0 : ds[ds.length - 1]); + } +} From beddbca1bf78df7108fd1e55961524a2ba13015e Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 14:45:06 -0400 Subject: [PATCH 12/43] Add maxCapacity hard cap and boolean set() to LightStringMap Pairs the probe-bound grow trigger with a hard slot cap so a capped map's worst-case memory is bounded even under clustering. The cap rides on the SizingHint via buildSizingHint().maxCapacity(...), so every map at a construction site shares one bound. set() now returns boolean: true when the mapping was stored (or overwrote an existing one), false only when a capped map is physically full at its cap and the key is genuinely new. The rejection is non-fatal (the map is unchanged) and uncapped maps always return true, keeping the hot path a thin spine delegate. Co-Authored-By: Claude Opus 4.8 --- .../datadog/trace/util/LightStringMap.java | 160 ++++++++++++++++-- 1 file changed, 146 insertions(+), 14 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java index 9d9562903f5..dc1706d512e 100644 --- a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java +++ b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java @@ -52,21 +52,30 @@ public final class LightStringMap { // not something that fights a steady workload. static final int DECAY_INTERVAL = 1024; // Safety ceiling on how large a hint will pre-provision (in slots). Bounds the shared hint's - // over-provision from an outlier; the map itself is uncapped and grows past this on its own. + // over-provision from an outlier; a map without a maxCapacity still grows past this on its own. static final int MAX_HINT_SLOTS = 1024; + // Sentinel maxSlots meaning "no hard cap": the map grows freely (numSlots < NO_MAX_SLOTS always + // holds, so the cap check never fires and set() always stores). + static final int NO_MAX_SLOTS = Integer.MAX_VALUE; + private final int initialCapacity; + // Hard cap on slots (a power of two), or NO_MAX_SLOTS when uncapped. Comes from the sizing hint's + // maxCapacity so every map at a construction site shares one bound. + private final int maxSlots; @Nullable private final SizingHint sizingHint; private Object[] data = EmbeddingSupport.EMPTY_DATA; public LightStringMap(int capacity) { this.initialCapacity = capacity; this.sizingHint = null; + this.maxSlots = NO_MAX_SLOTS; } public LightStringMap(@Nonnull SizingHint hint) { this.sizingHint = hint; this.initialCapacity = hint.seedSlots(); + this.maxSlots = hint.maxSlots(); } /** @@ -80,18 +89,121 @@ public static SizingHint sizingHint() { return new SizingHint(); } - public void set(@Nonnull String key, @Nonnull V value) { + /** + * Opens a builder for a {@link SizingHint} that carries an initial capacity and/or a hard {@code + * maxCapacity}. Use this instead of {@link #sizingHint()} when a site wants to bound its maps' + * worst-case memory: every map built from the returned hint shares the same cap, and {@link #set} + * rejects (returns {@code false}) once a map is physically full at that cap. The hint still + * self-tunes its seed capacity within the cap. + */ + @Nonnull + public static SizingHintBuilder buildSizingHint() { + return new SizingHintBuilder(); + } + + /** Builds a {@link SizingHint} with an initial and/or maximum capacity. */ + public static final class SizingHintBuilder { + private int initCapacity = DEFAULT_HINT_SLOTS; + private int maxCapacity = NO_MAX_SLOTS; + + private SizingHintBuilder() {} + + /** Seed capacity in slots for a cold map (rounded up to a power of two). */ + @Nonnull + public SizingHintBuilder initCapacity(int slots) { + this.initCapacity = slots; + return this; + } + + /** + * Hard cap, in slots (rounded up to a power of two), on how large any map built from this hint + * may grow. Once a map is physically full at this many slots, {@link #set} rejects a new key + * (returns {@code false}) instead of growing further -- bounding worst-case memory. + */ + @Nonnull + public SizingHintBuilder maxCapacity(int slots) { + this.maxCapacity = slots; + return this; + } + + @Nonnull + public SizingHint build() { + int seed = EmbeddingSupport.roundUpToPow2(this.initCapacity); + int max = + (this.maxCapacity == NO_MAX_SLOTS) + ? NO_MAX_SLOTS + : EmbeddingSupport.roundUpToPow2(this.maxCapacity); + if (max != NO_MAX_SLOTS && seed > max) { + throw new IllegalArgumentException( + "initCapacity (" + seed + " slots) exceeds maxCapacity (" + max + " slots)"); + } + return new SizingHint(seed, max); + } + } + + /** + * Stores {@code value} under {@code key}, growing the backing table if the probe-bound grow + * trigger fires. Returns {@code true} if the mapping was stored (or overwrote an existing one). + * + *

Returns {@code false} only for a capped map (one built from a {@link + * #buildSizingHint()}.{@code maxCapacity(...)} hint): once the table is physically full at its + * cap, a genuinely new key is rejected rather than growing past the cap. The rejection is + * non-fatal -- the map is unchanged and the caller may ignore the return. An uncapped map always + * returns {@code true}. + */ + public boolean set(@Nonnull String key, @Nonnull V value) { Objects.requireNonNull(value, "value"); Object[] before = this.data; int beforeSlots = EmbeddingSupport.numSlots(before); - // The spine owns the grow decision (probe-bound trigger) and returns the map data, resized if - // it grew. The object is a thin delegate: its only extra job is to teach the sizing hint. - Object[] after = EmbeddingSupport.set(this.initialCapacity, before, key, value); - this.data = after; - // Record a genuine grow (not the lazy first allocation, which seeds from beforeSlots == 0) so - // the hint learns this site's high-water mark. seedSlots()/newMapData never feed the hint. + if (this.maxSlots == NO_MAX_SLOTS) { + // Uncapped: the spine owns the grow decision (probe-bound trigger) and always stores the key. + // The object stays a thin delegate whose only extra job is to teach the sizing hint. + Object[] after = EmbeddingSupport.set(this.initialCapacity, before, key, value); + this.data = after; + recordGrowth(beforeSlots, after); + return true; + } + + // Capped: orchestrate the insert so a grow past maxSlots becomes a (non-fatal) rejection rather + // than an unbounded resize. Reuses the same spine primitives as the uncapped path. + if (before == null) { + this.data = EmbeddingSupport.newMapData(this.initialCapacity, key, value); + return true; + } + int numSlots = beforeSlots; + int slot = EmbeddingSupport.findInsertionSlot(before, numSlots, key); + if (slot >= 0) { + before[slot + numSlots] = value; // key already present -- overwrite in place + return true; + } + + boolean hasFreeSlot = slot != EmbeddingSupport.NO_SPACE; + boolean wantsGrow = !hasFreeSlot || !EmbeddingSupport.withinProbeBound(numSlots, slot, key); + if (wantsGrow && numSlots < this.maxSlots) { + Object[] after = EmbeddingSupport.expandMapData(before); + EmbeddingSupport.newMapUncheckedInsert(after, EmbeddingSupport.numSlots(after), key, value); + this.data = after; + recordGrowth(beforeSlots, after); + return true; + } + if (hasFreeSlot) { + // At the cap: the probe bound is relaxed (we cannot grow), so fill any free slot the probe + // walk found, even one past MAX_PROBES, until the table is physically full. + int availableSlot = EmbeddingSupport.flip(slot); + before[availableSlot] = key; + before[availableSlot + numSlots] = value; + return true; + } + // Physically full at the cap and the key is new: reject. + return false; + } + + // Teach the sizing hint after a genuine grow (not the lazy first allocation, which seeds from + // beforeSlots == 0) so it learns this site's high-water mark. seedSlots()/newMapData never feed + // the hint. + private void recordGrowth(int beforeSlots, @Nullable Object[] after) { if (this.sizingHint != null && beforeSlots != 0) { int afterSlots = EmbeddingSupport.numSlots(after); if (afterSlots > beforeSlots) { @@ -154,11 +266,27 @@ Object[] dataForTesting() { public static final class SizingHint { // Learned seed capacity in slots (always a power of two). Additive-increase on grow (with one // class of headroom), multiplicative-decrease on the decay tick. - private int slots = DEFAULT_HINT_SLOTS; + private int slots; // Approximate count of maps started from this hint; drives the periodic step-down decay. private int constructs; + // Hard cap on slots for maps built from this hint (a power of two), or NO_MAX_SLOTS when + // uncapped. Immutable; the learned seed is clamped to it so a hint never over-provisions past + // the cap. + private final int maxSlots; + + private SizingHint() { + this(DEFAULT_HINT_SLOTS, NO_MAX_SLOTS); + } - private SizingHint() {} + private SizingHint(int seedSlots, int maxSlots) { + this.slots = seedSlots; + this.maxSlots = maxSlots; + } + + // The hard slot cap for maps built from this hint (NO_MAX_SLOTS when uncapped). + int maxSlots() { + return this.maxSlots; + } /** * The seed capacity (in slots) for a map just started from this hint. Advances the decay clock @@ -178,12 +306,14 @@ int seedSlots() { /** * Records that a map grew to {@code grownSlots}. Reserves one extra power-of-two class so a * reseeded map starts with slack and is unlikely to immediately re-trip the grow trigger for - * the same workload. Monotonic-max, clamped to {@link #MAX_HINT_SLOTS}. + * the same workload. Monotonic-max, clamped to {@link #MAX_HINT_SLOTS} and to this hint's hard + * {@code maxSlots} cap so a capped hint never seeds a map larger than its cap. */ void recordSlots(int grownSlots) { int candidate = grownSlots << 1; - if (candidate > MAX_HINT_SLOTS) { - candidate = MAX_HINT_SLOTS; + int ceiling = Math.min(MAX_HINT_SLOTS, this.maxSlots); + if (candidate > ceiling) { + candidate = ceiling; } if (candidate > this.slots) { this.slots = candidate; @@ -398,7 +528,9 @@ public static Object[] set( // Whether an insertion at the free slot encoded by {@code insertionSlot} (as returned by // findInsertionSlot for an absent key) lands within {@link #MAX_PROBES} of the key's home slot. - private static boolean withinProbeBound(int numSlots, int insertionSlot, String key) { + // Package-private so the capped object-tier set() can share the same grow decision as the + // spine. + static boolean withinProbeBound(int numSlots, int insertionSlot, String key) { int availableSlot = flip(insertionSlot); int home = preferredSlot(numSlots, key.hashCode()); int distance = (availableSlot - home) & (numSlots - 1); From ad2133a33c3194a4dfe427058640fd1c062ee85f Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 14:45:08 -0400 Subject: [PATCH 13/43] Test LightStringMap maxCapacity cap and boolean set() Covers: uncapped set always stores; a capped map grows up to the cap then rejects a new key when physically full (leaving the map unchanged) while still allowing overwrites; rejection is non-fatal (freeing a slot readmits a key); maxCapacity rounds up to a power of two; build() rejects initCapacity > maxCapacity; and the learned seed is clamped to the cap. Co-Authored-By: Claude Opus 4.8 --- .../trace/util/LightStringMapTest.java | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java b/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java index 35a6218b997..15a19542e39 100644 --- a/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java +++ b/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java @@ -586,4 +586,137 @@ void hintSeededMapStoresAndReadsBackCorrectly() { } } } + + // ============ maxCapacity hard cap + boolean set() ============ + + @Nested + class CapTests { + + @Test + void uncappedMapAlwaysReturnsTrueFromSet() { + // The plain capacity constructor is uncapped: set stores unconditionally and never rejects, + // even well past the initial capacity. + LightStringMap map = new LightStringMap<>(2); + for (int i = 0; i < 100; i++) { + assertTrue(map.set("k" + i, i), "uncapped set should always store"); + } + assertEquals(100, map.size()); + } + + @Test + void hintWithoutMaxCapacityIsUncapped() { + // buildSizingHint() with no maxCapacity behaves like the zero-config sizingHint(): no cap, so + // set always stores. + LightStringMap.SizingHint hint = LightStringMap.buildSizingHint().build(); + LightStringMap map = new LightStringMap<>(hint); + for (int i = 0; i < 100; i++) { + assertTrue(map.set("k" + i, i)); + } + assertEquals(100, map.size()); + } + + @Test + void cappedSetStoresUntilPhysicallyFullThenRejects() { + // maxCapacity(8) bounds the table at 8 slots. Eight distinct keys fill every slot (a + // 8-slot table never trips the probe bound, so it fills before it would grow); a ninth, + // genuinely new key cannot grow past the cap, so set rejects it and leaves the map unchanged. + LightStringMap.SizingHint hint = LightStringMap.buildSizingHint().maxCapacity(8).build(); + LightStringMap map = new LightStringMap<>(hint); + for (int i = 0; i < 8; i++) { + assertTrue(map.set("k" + i, i), "slot " + i + " should store"); + } + assertEquals(8, EmbeddingSupport.numSlots(map.dataForTesting()), "capped at 8 slots"); + assertEquals(8, map.size()); + + assertFalse(map.set("overflow", 99), "new key past the cap should be rejected"); + assertEquals(8, map.size(), "rejected set must not change the map"); + assertNull(map.get("overflow")); + // Every prior entry is still readable. + for (int i = 0; i < 8; i++) { + assertEquals(i, map.get("k" + i)); + } + } + + @Test + void cappedSetOverwritesExistingKeyEvenWhenFull() { + // Rejection only applies to a genuinely new key. Overwriting a present key when the table is + // full at the cap still succeeds -- no new slot is needed. + LightStringMap.SizingHint hint = LightStringMap.buildSizingHint().maxCapacity(8).build(); + LightStringMap map = new LightStringMap<>(hint); + for (int i = 0; i < 8; i++) { + map.set("k" + i, i); + } + assertTrue(map.set("k3", 300), "overwrite of a present key should succeed when full"); + assertEquals(300, map.get("k3")); + assertEquals(8, map.size()); + } + + @Test + void cappedMapGrowsUpToTheCap() { + // A cap does not pin the seed size: a map started small still grows through the + // power-of-two classes up to the cap, retaining every entry along the way. + LightStringMap.SizingHint hint = + LightStringMap.buildSizingHint().initCapacity(2).maxCapacity(16).build(); + LightStringMap map = new LightStringMap<>(hint); + for (int i = 0; i < 16; i++) { + assertTrue(map.set("k" + i, i), "slot " + i + " should store below the cap"); + } + assertEquals(16, EmbeddingSupport.numSlots(map.dataForTesting())); + assertEquals(16, map.size()); + assertFalse(map.set("k16", 16), "the 17th key exceeds the 16-slot cap"); + for (int i = 0; i < 16; i++) { + assertEquals(i, map.get("k" + i)); + } + } + + @Test + void afterRejectionRemovingThenReinsertingSucceeds() { + // A rejection is non-fatal: freeing a slot (remove) makes room again, so a subsequent set of + // a + // new key succeeds via the reclaimed tombstone. + LightStringMap.SizingHint hint = LightStringMap.buildSizingHint().maxCapacity(8).build(); + LightStringMap map = new LightStringMap<>(hint); + for (int i = 0; i < 8; i++) { + map.set("k" + i, i); + } + assertFalse(map.set("late", 99)); + map.remove("k0"); + assertTrue(map.set("late", 99), "a freed slot admits a new key"); + assertEquals(99, map.get("late")); + } + + @Test + void maxCapacityRoundsUpToPowerOfTwo() { + // A non-power-of-two cap rounds up: maxCapacity(5) becomes an 8-slot cap. + LightStringMap.SizingHint hint = LightStringMap.buildSizingHint().maxCapacity(5).build(); + LightStringMap map = new LightStringMap<>(hint); + for (int i = 0; i < 8; i++) { + assertTrue(map.set("k" + i, i)); + } + assertFalse(map.set("k8", 8), "cap of 5 rounds up to 8 slots"); + } + + @Test + void buildThrowsWhenInitCapacityExceedsMaxCapacity() { + assertThrows( + IllegalArgumentException.class, + () -> LightStringMap.buildSizingHint().initCapacity(16).maxCapacity(8).build()); + } + + @Test + void cappedHintNeverSeedsAMapLargerThanTheCap() { + // The learned seed is clamped to the cap: even after a map grows to the cap, recordSlots + // (which + // normally reserves a class of headroom) cannot push the seed past maxSlots. + LightStringMap.SizingHint hint = + LightStringMap.buildSizingHint().initCapacity(2).maxCapacity(16).build(); + LightStringMap map = new LightStringMap<>(hint); + for (int i = 0; i < 16; i++) { + map.set("k" + i, i); + } + assertTrue( + hint.currentSeedSlots() <= 16, + "learned seed " + hint.currentSeedSlots() + " must not exceed the 16-slot cap"); + } + } } From 405b9a8d0f31d92dc46eaa6af36067510521a09a Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 14:51:06 -0400 Subject: [PATCH 14/43] Make LightStringMap construction go through create() factories Adds create(), create(int capacity), and create(SizingHint) as the single front door and makes the constructors private. create() is a new zero-config default (seeds at DEFAULT_CAPACITY) so call sites no longer name a magic capacity, and the factory set mirrors the existing sizingHint()/buildSizingHint() static idiom. Co-Authored-By: Claude Opus 4.8 --- .../datadog/trace/util/LightStringMap.java | 41 +++++++++++++++---- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java index dc1706d512e..0eee0103c62 100644 --- a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java +++ b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java @@ -42,7 +42,7 @@ public final class LightStringMap { public static final int DEFAULT_CAPACITY = 8; // Slots a fresh (un-tuned) hint seeds -- a reasonable default so a cold site behaves like a - // plain new LightStringMap(DEFAULT_CAPACITY); it then self-tunes up or down from here. + // plain LightStringMap.create(); it then self-tunes up or down from here. static final int DEFAULT_HINT_SLOTS = DEFAULT_CAPACITY; // Floor step-down never drops a hint below (in slots), so a genuinely tiny site can still tune // below the default. Must stay >= 1 (a zero-slot table is degenerate). @@ -66,22 +66,49 @@ public final class LightStringMap { @Nullable private final SizingHint sizingHint; private Object[] data = EmbeddingSupport.EMPTY_DATA; - public LightStringMap(int capacity) { + private LightStringMap(int capacity) { this.initialCapacity = capacity; this.sizingHint = null; this.maxSlots = NO_MAX_SLOTS; } - public LightStringMap(@Nonnull SizingHint hint) { + private LightStringMap(@Nonnull SizingHint hint) { this.sizingHint = hint; this.initialCapacity = hint.seedSlots(); this.maxSlots = hint.maxSlots(); } + /** A new, uncapped map seeded at the default capacity. The "just give me a map" front door. */ + @Nonnull + public static LightStringMap create() { + return new LightStringMap<>(DEFAULT_CAPACITY); + } + + /** + * A new, uncapped map seeded at {@code capacity} (rounded up to a power of two on first write). + * Use when the caller already knows the rough size; otherwise prefer {@link #create()} or a + * {@link #sizingHint()}. + */ + @Nonnull + public static LightStringMap create(int capacity) { + return new LightStringMap<>(capacity); + } + + /** + * A new map sized (and, if the hint carries a {@code maxCapacity}, capped) from {@code hint}. + * Mint the hint once per construction site via {@link #sizingHint()} or {@link + * #buildSizingHint()}, hold it in a {@code static final} field, and pass it to every map at that + * site. + */ + @Nonnull + public static LightStringMap create(@Nonnull SizingHint hint) { + return new LightStringMap<>(hint); + } + /** * Mints a self-tuning {@link SizingHint} for a single construction site. Hold it in a {@code - * static final} field and pass it to every {@link #LightStringMap(SizingHint)} at that site; the - * map sizes itself from the hint and tunes the hint back on its own. The caller never touches the + * static final} field and pass it to every {@link #create(SizingHint)} at that site; the map + * sizes itself from the hint and tunes the hint back on its own. The caller never touches the * hint again. */ @Nonnull @@ -251,8 +278,8 @@ Object[] dataForTesting() { /** * A self-tuning, per-construction-site sizing estimate. Mint one via {@link * LightStringMap#sizingHint()}, hold it in a {@code static final} field, and pass it to {@link - * LightStringMap#LightStringMap(SizingHint)}; the map reads it to size itself and tunes it back - * as it grows. The caller never updates it. + * LightStringMap#create(SizingHint)}; the map reads it to size itself and tunes it back as it + * grows. The caller never updates it. * *

Opaque by design (no public members). The estimate self-tunes on two events the map already * observes -- a new map is started ({@link #seedSlots()}) and a map grows ({@link From 7fe8e0aa8110ebf5d5706105a63b4e75fb3456a8 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 14:51:08 -0400 Subject: [PATCH 15/43] Construct LightStringMap via create() in tests Co-Authored-By: Claude Opus 4.8 --- .../trace/util/LightStringMapTest.java | 72 +++++++++---------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java b/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java index 15a19542e39..6da1aca9a32 100644 --- a/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java +++ b/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java @@ -23,13 +23,13 @@ class InstanceTests { @Test void getOnEmptyMapReturnsNull() { - LightStringMap map = new LightStringMap<>(LightStringMap.DEFAULT_CAPACITY); + LightStringMap map = LightStringMap.create(LightStringMap.DEFAULT_CAPACITY); assertNull(map.get("absent")); } @Test void setThenGet() { - LightStringMap map = new LightStringMap<>(8); + LightStringMap map = LightStringMap.create(8); map.set("a", 1); map.set("b", 2); assertEquals(1, map.get("a")); @@ -39,7 +39,7 @@ void setThenGet() { @Test void setOverwritesExistingKey() { - LightStringMap map = new LightStringMap<>(8); + LightStringMap map = LightStringMap.create(8); map.set("a", 1); map.set("a", 42); assertEquals(42, map.get("a")); @@ -47,7 +47,7 @@ void setOverwritesExistingKey() { @Test void removeMakesKeyAbsent() { - LightStringMap map = new LightStringMap<>(8); + LightStringMap map = LightStringMap.create(8); map.set("a", 1); map.set("b", 2); map.remove("a"); @@ -57,7 +57,7 @@ void removeMakesKeyAbsent() { @Test void containsKeyDistinguishesPresenceFromAbsence() { - LightStringMap map = new LightStringMap<>(8); + LightStringMap map = LightStringMap.create(8); assertFalse(map.containsKey("a")); map.set("a", 1); assertTrue(map.containsKey("a")); @@ -68,14 +68,14 @@ void containsKeyDistinguishesPresenceFromAbsence() { @Test void setRejectsNullValue() { - LightStringMap map = new LightStringMap<>(8); + LightStringMap map = LightStringMap.create(8); assertThrows(NullPointerException.class, () -> map.set("a", null)); assertFalse(map.containsKey("a")); } @Test void sizeTracksLiveEntries() { - LightStringMap map = new LightStringMap<>(8); + LightStringMap map = LightStringMap.create(8); assertEquals(0, map.size()); map.set("a", 1); map.set("b", 2); @@ -94,7 +94,7 @@ void tinyMapGrowsOnlyWhenPhysicallyFull() { // can never travel 8 slots there -- so a seed-8 map fills all 8 slots before it grows, // exactly as it did before the trigger existed. This is the property that keeps the tiny, // miss-dominated consumer (springweb6 localAttributes) behaviorally unchanged. - LightStringMap map = new LightStringMap<>(8); + LightStringMap map = LightStringMap.create(8); for (int i = 0; i < 8; i++) { map.set("k" + i, i); } @@ -116,12 +116,12 @@ void clusteredKeysGrowEarlierThanWellSpreadKeys() { // well before the table is anywhere near full. Both sets stay fully retrievable. int count = 32; - LightStringMap spread = new LightStringMap<>(8); + LightStringMap spread = LightStringMap.create(8); for (int i = 0; i < count; i++) { spread.set("spread." + i, i); } - LightStringMap clustered = new LightStringMap<>(8); + LightStringMap clustered = LightStringMap.create(8); List colliding = collidingKeys(count); for (int i = 0; i < count; i++) { clustered.set(colliding.get(i), i); @@ -142,7 +142,7 @@ void clusteredKeysGrowEarlierThanWellSpreadKeys() { @Test void growsAndPreservesAllEntries() { // initial capacity 2 forces several resizes as we insert well past it. - LightStringMap map = new LightStringMap<>(2); + LightStringMap map = LightStringMap.create(2); int n = 100; for (int i = 0; i < n; i++) { map.set("key-" + i, i); @@ -154,7 +154,7 @@ void growsAndPreservesAllEntries() { @Test void forEachVisitsEveryLiveEntry() { - LightStringMap map = new LightStringMap<>(4); + LightStringMap map = LightStringMap.create(4); for (int i = 0; i < 20; i++) { map.set("k" + i, i); } @@ -172,7 +172,7 @@ void forEachVisitsEveryLiveEntry() { @Test void nonLiteralKeyResolvesViaEqualsFallback() { - LightStringMap map = new LightStringMap<>(8); + LightStringMap map = LightStringMap.create(8); map.set("hello", 1); // Distinct String instance with the same content -- must be found via the equals pass. String lookup = new String("hello"); @@ -197,7 +197,7 @@ private List collidingKeys(int count) { @Test void removeThenReinsertSameKey() { - LightStringMap map = new LightStringMap<>(4); + LightStringMap map = LightStringMap.create(4); for (int i = 0; i < 4; i++) { map.set("k" + i, i); } @@ -464,7 +464,7 @@ void freshHintSeedsAtDefault() { @Test void hintSeedsAFreshMapAtItsLearnedCapacity() { LightStringMap.SizingHint hint = LightStringMap.sizingHint(); - LightStringMap map = new LightStringMap<>(hint); + LightStringMap map = LightStringMap.create(hint); // A hint-seeded map allocates its backing array lazily, sized to the hint. map.set("a", 1); Object[] data = map.dataForTesting(); @@ -476,7 +476,7 @@ void growthRaisesSeedWithOneClassOfHeadroom() { LightStringMap.SizingHint hint = LightStringMap.sizingHint(); // Fill a hint-seeded map past its seed so it grows; the hint should learn the new size // PLUS one power-of-two class of headroom (so the steady-state load factor stays <= 0.5). - LightStringMap map = new LightStringMap<>(hint); + LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < LightStringMap.DEFAULT_HINT_SLOTS + 1; i++) { map.set("k" + i, i); } @@ -488,14 +488,14 @@ void growthRaisesSeedWithOneClassOfHeadroom() { void seedIsMonotonicMaxAcrossMaps() { LightStringMap.SizingHint hint = LightStringMap.sizingHint(); // A big map ratchets the hint up. - LightStringMap big = new LightStringMap<>(hint); + LightStringMap big = LightStringMap.create(hint); for (int i = 0; i < 20; i++) { big.set("k" + i, i); } int learned = hint.currentSeedSlots(); assertTrue(learned > LightStringMap.DEFAULT_HINT_SLOTS); // A subsequent tiny map does not lower the learned seed. - LightStringMap small = new LightStringMap<>(hint); + LightStringMap small = LightStringMap.create(hint); small.set("a", 1); assertEquals(learned, hint.currentSeedSlots()); } @@ -504,14 +504,14 @@ void seedIsMonotonicMaxAcrossMaps() { void decayStepsSeedDownAfterInterval() { LightStringMap.SizingHint hint = LightStringMap.sizingHint(); // Ratchet the hint above the default so a step-down is observable. - LightStringMap big = new LightStringMap<>(hint); + LightStringMap big = LightStringMap.create(hint); for (int i = 0; i < 20; i++) { big.set("k" + i, i); } int learned = hint.currentSeedSlots(); // One full decay interval of constructions steps the seed down exactly one class. for (int i = 0; i < LightStringMap.DECAY_INTERVAL; i++) { - new LightStringMap(hint); + LightStringMap.create(hint); } assertEquals(learned / 2, hint.currentSeedSlots()); } @@ -522,7 +522,7 @@ void decayFloorsAtMinimum() { // Enough decay intervals to drive an un-ratcheted hint to the floor and hold there. int intervals = 32; for (int i = 0; i < intervals * LightStringMap.DECAY_INTERVAL; i++) { - new LightStringMap(hint); + LightStringMap.create(hint); } assertEquals(LightStringMap.MIN_HINT_SLOTS, hint.currentSeedSlots()); } @@ -531,7 +531,7 @@ void decayFloorsAtMinimum() { void seedIsCappedAtMax() { LightStringMap.SizingHint hint = LightStringMap.sizingHint(); // A very large map cannot push the learned seed past the pre-provisioning ceiling. - LightStringMap big = new LightStringMap<>(hint); + LightStringMap big = LightStringMap.create(hint); for (int i = 0; i < LightStringMap.MAX_HINT_SLOTS * 4; i++) { big.set("k" + i, i); } @@ -544,7 +544,7 @@ void oneDecayStepStaysSafeThenSecondDecayRepins() { LightStringMap.SizingHint hint = LightStringMap.sizingHint(); // Learn a large size. With one class of headroom, `learned` is 2x the array the workload // physically grew into. - LightStringMap big = new LightStringMap<>(hint); + LightStringMap big = LightStringMap.create(hint); for (int i = 0; i < 20; i++) { big.set("k" + i, i); } @@ -553,10 +553,10 @@ void oneDecayStepStaysSafeThenSecondDecayRepins() { // First decay lands the seed exactly on the physical high-water: the same workload now fits // without regrowing, so the seed holds (the headroom step-down is "free"). for (int i = 0; i < LightStringMap.DECAY_INTERVAL; i++) { - new LightStringMap(hint); + LightStringMap.create(hint); } assertEquals(learned / 2, hint.currentSeedSlots()); - LightStringMap stillFits = new LightStringMap<>(hint); + LightStringMap stillFits = LightStringMap.create(hint); for (int i = 0; i < 20; i++) { stillFits.set("k" + i, i); } @@ -564,10 +564,10 @@ void oneDecayStepStaysSafeThenSecondDecayRepins() { // Second decay probes below the need: the workload now regrows and snaps the seed back up. for (int i = 0; i < LightStringMap.DECAY_INTERVAL; i++) { - new LightStringMap(hint); + LightStringMap.create(hint); } assertEquals(learned / 4, hint.currentSeedSlots()); - LightStringMap recovered = new LightStringMap<>(hint); + LightStringMap recovered = LightStringMap.create(hint); for (int i = 0; i < 20; i++) { recovered.set("k" + i, i); } @@ -577,7 +577,7 @@ void oneDecayStepStaysSafeThenSecondDecayRepins() { @Test void hintSeededMapStoresAndReadsBackCorrectly() { LightStringMap.SizingHint hint = LightStringMap.sizingHint(); - LightStringMap map = new LightStringMap<>(hint); + LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 50; i++) { map.set("k" + i, i); } @@ -596,7 +596,7 @@ class CapTests { void uncappedMapAlwaysReturnsTrueFromSet() { // The plain capacity constructor is uncapped: set stores unconditionally and never rejects, // even well past the initial capacity. - LightStringMap map = new LightStringMap<>(2); + LightStringMap map = LightStringMap.create(2); for (int i = 0; i < 100; i++) { assertTrue(map.set("k" + i, i), "uncapped set should always store"); } @@ -608,7 +608,7 @@ void hintWithoutMaxCapacityIsUncapped() { // buildSizingHint() with no maxCapacity behaves like the zero-config sizingHint(): no cap, so // set always stores. LightStringMap.SizingHint hint = LightStringMap.buildSizingHint().build(); - LightStringMap map = new LightStringMap<>(hint); + LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 100; i++) { assertTrue(map.set("k" + i, i)); } @@ -621,7 +621,7 @@ void cappedSetStoresUntilPhysicallyFullThenRejects() { // 8-slot table never trips the probe bound, so it fills before it would grow); a ninth, // genuinely new key cannot grow past the cap, so set rejects it and leaves the map unchanged. LightStringMap.SizingHint hint = LightStringMap.buildSizingHint().maxCapacity(8).build(); - LightStringMap map = new LightStringMap<>(hint); + LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 8; i++) { assertTrue(map.set("k" + i, i), "slot " + i + " should store"); } @@ -642,7 +642,7 @@ void cappedSetOverwritesExistingKeyEvenWhenFull() { // Rejection only applies to a genuinely new key. Overwriting a present key when the table is // full at the cap still succeeds -- no new slot is needed. LightStringMap.SizingHint hint = LightStringMap.buildSizingHint().maxCapacity(8).build(); - LightStringMap map = new LightStringMap<>(hint); + LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 8; i++) { map.set("k" + i, i); } @@ -657,7 +657,7 @@ void cappedMapGrowsUpToTheCap() { // power-of-two classes up to the cap, retaining every entry along the way. LightStringMap.SizingHint hint = LightStringMap.buildSizingHint().initCapacity(2).maxCapacity(16).build(); - LightStringMap map = new LightStringMap<>(hint); + LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 16; i++) { assertTrue(map.set("k" + i, i), "slot " + i + " should store below the cap"); } @@ -675,7 +675,7 @@ void afterRejectionRemovingThenReinsertingSucceeds() { // a // new key succeeds via the reclaimed tombstone. LightStringMap.SizingHint hint = LightStringMap.buildSizingHint().maxCapacity(8).build(); - LightStringMap map = new LightStringMap<>(hint); + LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 8; i++) { map.set("k" + i, i); } @@ -689,7 +689,7 @@ void afterRejectionRemovingThenReinsertingSucceeds() { void maxCapacityRoundsUpToPowerOfTwo() { // A non-power-of-two cap rounds up: maxCapacity(5) becomes an 8-slot cap. LightStringMap.SizingHint hint = LightStringMap.buildSizingHint().maxCapacity(5).build(); - LightStringMap map = new LightStringMap<>(hint); + LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 8; i++) { assertTrue(map.set("k" + i, i)); } @@ -710,7 +710,7 @@ void cappedHintNeverSeedsAMapLargerThanTheCap() { // normally reserves a class of headroom) cannot push the seed past maxSlots. LightStringMap.SizingHint hint = LightStringMap.buildSizingHint().initCapacity(2).maxCapacity(16).build(); - LightStringMap map = new LightStringMap<>(hint); + LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 16; i++) { map.set("k" + i, i); } From 48f4605399eeb5d942e00d15b5e887ef95153756 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 14:59:26 -0400 Subject: [PATCH 16/43] Remove vestigial EmbeddingSupport.prevValueAt prevValueAt was byte-identical to valueAt (same guards, same return) and had no caller -- a leftover from earlier experimentation. Drop it and its test line. Co-Authored-By: Claude Opus 4.8 --- .../src/main/java/datadog/trace/util/LightStringMap.java | 8 -------- .../test/java/datadog/trace/util/LightStringMapTest.java | 1 - 2 files changed, 9 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java index 0eee0103c62..d5db547b399 100644 --- a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java +++ b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java @@ -435,14 +435,6 @@ public static V valueAt(@Nullable Object[] mapData, int slotIndex) { return (V) mapData[slotIndex + numSlots(mapData)]; } - @SuppressWarnings("unchecked") - @Nullable - public static V prevValueAt(@Nullable Object[] mapData, int slotIndex) { - if (mapData == null || slotIndex < 0) return null; - - return (V) mapData[slotIndex + numSlots(mapData)]; - } - public static final boolean containsKey(@Nullable Object[] mapData, @Nonnull String key) { if (mapData == null) return false; diff --git a/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java b/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java index 6da1aca9a32..9d2576c9474 100644 --- a/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java +++ b/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java @@ -315,7 +315,6 @@ void keyAtValueAtAndPresence() { assertTrue(EmbeddingSupport.isPresent(slot)); assertEquals("a", EmbeddingSupport.keyAt(data, slot)); assertEquals("A", EmbeddingSupport.valueAt(data, slot)); - assertEquals("A", EmbeddingSupport.prevValueAt(data, slot)); // negative slot -> absent assertNull(EmbeddingSupport.keyAt(data, -1)); assertFalse(EmbeddingSupport.isPresent(-1)); From 7563e6c0c759744949df4476c75278558b38d5c1 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 15:21:54 -0400 Subject: [PATCH 17/43] Merge the two LightStringMap insert orchestrations into one core The uncapped spine set(int,Object[],...) and the capped object-tier set(String,V) were the same probe -> overwrite/fill/grow sequence with different tails. Extract a single EmbeddingSupport.setOrReject core that takes maxSlots: NO_MAX_SLOTS makes the cap check inert (spine path, never rejects, result never null), a finite cap lets a would-be grow past the cap become a non-fatal null rejection (object-tier path). Both set() methods become thin delegates. No behavior change. Co-Authored-By: Claude Opus 4.8 --- .../datadog/trace/util/LightStringMap.java | 108 ++++++++---------- 1 file changed, 49 insertions(+), 59 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java index d5db547b399..f996e91d6a1 100644 --- a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java +++ b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java @@ -181,50 +181,19 @@ public SizingHint build() { public boolean set(@Nonnull String key, @Nonnull V value) { Objects.requireNonNull(value, "value"); + // A thin delegate over the shared spine orchestration: it passes this map's cap (NO_MAX_SLOTS + // when uncapped) and does the two things only the object tier can -- swap in the new backing + // array and teach the sizing hint. A null result is the spine's non-fatal rejection signal. Object[] before = this.data; int beforeSlots = EmbeddingSupport.numSlots(before); - - if (this.maxSlots == NO_MAX_SLOTS) { - // Uncapped: the spine owns the grow decision (probe-bound trigger) and always stores the key. - // The object stays a thin delegate whose only extra job is to teach the sizing hint. - Object[] after = EmbeddingSupport.set(this.initialCapacity, before, key, value); - this.data = after; - recordGrowth(beforeSlots, after); - return true; - } - - // Capped: orchestrate the insert so a grow past maxSlots becomes a (non-fatal) rejection rather - // than an unbounded resize. Reuses the same spine primitives as the uncapped path. - if (before == null) { - this.data = EmbeddingSupport.newMapData(this.initialCapacity, key, value); - return true; - } - int numSlots = beforeSlots; - int slot = EmbeddingSupport.findInsertionSlot(before, numSlots, key); - if (slot >= 0) { - before[slot + numSlots] = value; // key already present -- overwrite in place - return true; - } - - boolean hasFreeSlot = slot != EmbeddingSupport.NO_SPACE; - boolean wantsGrow = !hasFreeSlot || !EmbeddingSupport.withinProbeBound(numSlots, slot, key); - if (wantsGrow && numSlots < this.maxSlots) { - Object[] after = EmbeddingSupport.expandMapData(before); - EmbeddingSupport.newMapUncheckedInsert(after, EmbeddingSupport.numSlots(after), key, value); - this.data = after; - recordGrowth(beforeSlots, after); - return true; - } - if (hasFreeSlot) { - // At the cap: the probe bound is relaxed (we cannot grow), so fill any free slot the probe - // walk found, even one past MAX_PROBES, until the table is physically full. - int availableSlot = EmbeddingSupport.flip(slot); - before[availableSlot] = key; - before[availableSlot + numSlots] = value; - return true; - } - // Physically full at the cap and the key is new: reject. - return false; + Object[] after = + EmbeddingSupport.setOrReject(this.initialCapacity, this.maxSlots, before, key, value); + if (after == null) { + return false; // capped, physically full, key is new + } + this.data = after; + recordGrowth(beforeSlots, after); + return true; } // Teach the sizing hint after a genuine grow (not the lazy first allocation, which seeds from @@ -513,14 +482,28 @@ public static Object[] setAll( @Nonnull public static Object[] set( int initialCapacity, @Nullable Object[] mapData, @Nonnull String key, @Nonnull V value) { - if (mapData == null) { - int numSlots = roundUpToPow2(initialCapacity); - mapData = new Object[numSlots << 1]; + // Uncapped: NO_MAX_SLOTS makes the cap check inert, so setOrReject grows on demand and never + // rejects -- the result is always non-null. + return setOrReject(initialCapacity, NO_MAX_SLOTS, mapData, key, value); + } - int keyIndex = preferredSlot(numSlots, key.hashCode()); - mapData[keyIndex] = key; - mapData[keyIndex + numSlots] = value; - return mapData; + // The single insert orchestration shared by the uncapped spine set() above and the capped + // object-tier LightStringMap.set(): probe, then either overwrite in place, fill a free slot, or + // grow. Stores {@code key -> value} and returns the (possibly new) backing array. + // + // Returns null ONLY when {@code maxSlots} is a finite cap, the table is physically full at it, + // and the key is genuinely new -- the caller's non-fatal rejection signal. With {@code maxSlots + // == NO_MAX_SLOTS} the cap check is inert (numSlots is always below it), so a grow is always + // available and the result is never null. + @Nullable + static Object[] setOrReject( + int initialCapacity, + int maxSlots, + @Nullable Object[] mapData, + @Nonnull String key, + @Nonnull V value) { + if (mapData == null) { + return newMapData(initialCapacity, key, value); } int numSlots = numSlots(mapData); @@ -530,25 +513,32 @@ public static Object[] set( mapData[slot + numSlots] = value; return mapData; } - if (slot != NO_SPACE && withinProbeBound(numSlots, slot, key)) { - // A free slot within the probe bound -- fill it directly. + + boolean hasFreeSlot = slot != NO_SPACE; + boolean wantsGrow = !hasFreeSlot || !withinProbeBound(numSlots, slot, key); + if (wantsGrow && numSlots < maxSlots) { + // No space, or the insertion would exceed the probe bound: grow, then insert into the fresh + // (tombstone-free, better-spread) table. + mapData = expandMapData(mapData); + newMapUncheckedInsert(mapData, numSlots(mapData), key, value); + return mapData; + } + if (hasFreeSlot) { + // A free slot the probe walk found. Below the cap this is within the probe bound; at the + // cap + // the bound is relaxed (we cannot grow), so fill it even past MAX_PROBES until physically + // full. int availableSlot = flip(slot); mapData[availableSlot] = key; mapData[availableSlot + numSlots] = value; return mapData; } - - // No space at all, or the insertion would exceed the probe bound: grow, then insert into the - // fresh (tombstone-free, better-spread) table. - mapData = expandMapData(mapData); - newMapUncheckedInsert(mapData, numSlots(mapData), key, value); - return mapData; + // Physically full at a finite cap and the key is new: reject. + return null; } // Whether an insertion at the free slot encoded by {@code insertionSlot} (as returned by // findInsertionSlot for an absent key) lands within {@link #MAX_PROBES} of the key's home slot. - // Package-private so the capped object-tier set() can share the same grow decision as the - // spine. static boolean withinProbeBound(int numSlots, int insertionSlot, String key) { int availableSlot = flip(insertionSlot); int home = preferredSlot(numSlots, key.hashCode()); From dec40bbbf868f3a0a368b1f82ad5ac13d20994b0 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 15:25:41 -0400 Subject: [PATCH 18/43] Add hint-aware spine set(SizingHint, ...) overload Migration counterpart of the object-tier LightStringMap.set for a map dropped to the embedded spine: seeds a fresh table from hint.seedSlots() and teaches the hint back on a genuine grow, mirroring the object tier so graduating a map to the spine keeps its self-tuning (a strict superset). The hint's maxCapacity cap does NOT follow down -- an Object[] return can't signal rejection -- so the spine always stores; taking the spine means owning your own growth bound. Co-Authored-By: Claude Opus 4.8 --- .../datadog/trace/util/LightStringMap.java | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java index f996e91d6a1..1a6f3d738ba 100644 --- a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java +++ b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java @@ -487,6 +487,40 @@ public static Object[] set( return setOrReject(initialCapacity, NO_MAX_SLOTS, mapData, key, value); } + /** + * Hint-aware spine insert: the migration counterpart of {@link LightStringMap#set} for a map + * that has been dropped to the embedded spine but wants to keep the self-tuning it had as an + * object. Seeds a fresh table from {@code hint.seedSlots()} and teaches the hint back on a + * genuine grow, exactly as the object tier does -- so graduating a map to the spine is a strict + * superset (it gains the embedding win without losing its sizing). + * + *

Unlike the object tier, the hint's {@code maxCapacity} cap does not bound growth + * here: a returned {@code Object[]} cannot signal a rejection, so this always stores and always + * returns non-null. Taking the spine means you own bounding your own growth; only the sizing + * tuning follows down, not the cap guardrail. + */ + @Nonnull + public static Object[] set( + @Nonnull SizingHint hint, + @Nullable Object[] mapData, + @Nonnull String key, + @Nonnull V value) { + int beforeSlots = numSlots(mapData); + // Seed a fresh table from the hint (mirrors LightStringMap(hint)); initialCapacity is only + // read on the null-array branch, so the 0 below is never consumed when mapData is non-null. + int seedCapacity = (mapData == null) ? hint.seedSlots() : 0; + Object[] after = setOrReject(seedCapacity, NO_MAX_SLOTS, mapData, key, value); + // Teach the hint on a genuine grow only (not the lazy first allocation, which already seeded + // from the hint) -- mirrors LightStringMap.recordGrowth. + if (beforeSlots != 0) { + int afterSlots = numSlots(after); + if (afterSlots > beforeSlots) { + hint.recordSlots(afterSlots); + } + } + return after; + } + // The single insert orchestration shared by the uncapped spine set() above and the capped // object-tier LightStringMap.set(): probe, then either overwrite in place, fill a free slot, or // grow. Stores {@code key -> value} and returns the (possibly new) backing array. From 04e877865bdf1e9c4c40abd2c0f16d95c1441a42 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 15:48:33 -0400 Subject: [PATCH 19/43] Test hint-aware spine set(SizingHint, ...) overload Covers the four behaviors that make dropping a map to the spine a strict superset of the object tier: seeds a fresh table from the hint, teaches the hint (with one class of headroom) on a genuine grow, and leaves the hint untouched on an in-place overwrite -- plus the deliberate discontinuity that the hint's maxCapacity cap does NOT follow to the spine (it grows past the cap and always stores). Co-Authored-By: Claude Opus 4.8 --- .../trace/util/LightStringMapTest.java | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java b/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java index 9d2576c9474..d55f6954a29 100644 --- a/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java +++ b/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -586,6 +587,68 @@ void hintSeededMapStoresAndReadsBackCorrectly() { } } + // ============ hint-aware spine set(SizingHint, ...) overload ============ + + @Nested + class SpineHintTests { + + @Test + void spineSetSeedsAFreshTableFromTheHint() { + // Dropping to the spine keeps the hint's sizing: a fresh table seeds from seedSlots(), just + // like LightStringMap.create(hint) does through the object tier. + LightStringMap.SizingHint hint = LightStringMap.buildSizingHint().initCapacity(4).build(); + Object[] data = EmbeddingSupport.set(hint, null, "a", 1); + assertEquals(4, EmbeddingSupport.numSlots(data)); + assertEquals(1, (Object) EmbeddingSupport.get(data, "a")); + } + + @Test + void spineSetTeachesTheHintOnGrow() { + // A grow through the spine ratchets the hint up with one class of headroom -- identical to + // the + // object tier's growthRaisesSeedWithOneClassOfHeadroom. + LightStringMap.SizingHint hint = LightStringMap.sizingHint(); + Object[] data = null; + for (int i = 0; i < LightStringMap.DEFAULT_HINT_SLOTS + 1; i++) { + data = EmbeddingSupport.set(hint, data, "k" + i, i); + } + int grownSlots = EmbeddingSupport.numSlots(data); + assertEquals(grownSlots * 2, hint.currentSeedSlots()); + } + + @Test + void spineSetDoesNotEnforceTheHintCap() { + // The cap guardrail does NOT follow to the spine: an Object[] return cannot signal rejection, + // so a capped hint used at the spine grows past its cap and always stores. (Contrast the + // object tier, which rejects at the cap -- see CapTests.) + LightStringMap.SizingHint hint = + LightStringMap.buildSizingHint().initCapacity(2).maxCapacity(4).build(); + Object[] data = null; + for (int i = 0; i < 12; i++) { + data = EmbeddingSupport.set(hint, data, "k" + i, i); + } + assertTrue(EmbeddingSupport.numSlots(data) > 4, "spine grew past the hint's 4-slot cap"); + for (int i = 0; i < 12; i++) { + assertEquals( + i, (Object) EmbeddingSupport.get(data, "k" + i), "every key stored despite the cap"); + } + } + + @Test + void spineSetOverwriteInPlaceDoesNotTeachTheHint() { + // An in-place overwrite neither grows nor teaches the hint -- same array back, seed + // unchanged. + LightStringMap.SizingHint hint = LightStringMap.sizingHint(); + Object[] data = EmbeddingSupport.set(hint, null, "a", 1); + int seedAfterFirstInsert = hint.currentSeedSlots(); + + Object[] afterOverwrite = EmbeddingSupport.set(hint, data, "a", 2); + assertSame(data, afterOverwrite, "overwrite reuses the same backing array"); + assertEquals(2, (Object) EmbeddingSupport.get(afterOverwrite, "a")); + assertEquals(seedAfterFirstInsert, hint.currentSeedSlots(), "no grow -> hint not taught"); + } + } + // ============ maxCapacity hard cap + boolean set() ============ @Nested From a8cfb775644eec8b49a7e3f9e72d131178c478a5 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 16:13:01 -0400 Subject: [PATCH 20/43] Rename SizingHint to AdaptiveSizingHint Rename the per-site self-tuning sizing controller and its factories so the name carries its JVM/JIT adaptive-sizing inspiration: SizingHint -> AdaptiveSizingHint SizingHintBuilder -> AdaptiveSizingHintBuilder sizingHint() -> adaptiveSizingHint() buildSizingHint() -> buildAdaptiveSizingHint() Co-Authored-By: Claude Opus 4.8 --- .../datadog/trace/util/LightStringMap.java | 72 +++++++++---------- .../trace/util/LightStringMapTest.java | 60 +++++++++------- 2 files changed, 69 insertions(+), 63 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java index 1a6f3d738ba..f38d7b5372c 100644 --- a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java +++ b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java @@ -63,7 +63,7 @@ public final class LightStringMap { // Hard cap on slots (a power of two), or NO_MAX_SLOTS when uncapped. Comes from the sizing hint's // maxCapacity so every map at a construction site shares one bound. private final int maxSlots; - @Nullable private final SizingHint sizingHint; + @Nullable private final AdaptiveSizingHint sizingHint; private Object[] data = EmbeddingSupport.EMPTY_DATA; private LightStringMap(int capacity) { @@ -72,7 +72,7 @@ private LightStringMap(int capacity) { this.maxSlots = NO_MAX_SLOTS; } - private LightStringMap(@Nonnull SizingHint hint) { + private LightStringMap(@Nonnull AdaptiveSizingHint hint) { this.sizingHint = hint; this.initialCapacity = hint.seedSlots(); this.maxSlots = hint.maxSlots(); @@ -87,7 +87,7 @@ public static LightStringMap create() { /** * A new, uncapped map seeded at {@code capacity} (rounded up to a power of two on first write). * Use when the caller already knows the rough size; otherwise prefer {@link #create()} or a - * {@link #sizingHint()}. + * {@link #adaptiveSizingHint()}. */ @Nonnull public static LightStringMap create(int capacity) { @@ -96,48 +96,48 @@ public static LightStringMap create(int capacity) { /** * A new map sized (and, if the hint carries a {@code maxCapacity}, capped) from {@code hint}. - * Mint the hint once per construction site via {@link #sizingHint()} or {@link - * #buildSizingHint()}, hold it in a {@code static final} field, and pass it to every map at that - * site. + * Mint the hint once per construction site via {@link #adaptiveSizingHint()} or {@link + * #buildAdaptiveSizingHint()}, hold it in a {@code static final} field, and pass it to every map + * at that site. */ @Nonnull - public static LightStringMap create(@Nonnull SizingHint hint) { + public static LightStringMap create(@Nonnull AdaptiveSizingHint hint) { return new LightStringMap<>(hint); } /** - * Mints a self-tuning {@link SizingHint} for a single construction site. Hold it in a {@code - * static final} field and pass it to every {@link #create(SizingHint)} at that site; the map - * sizes itself from the hint and tunes the hint back on its own. The caller never touches the - * hint again. + * Mints a self-tuning {@link AdaptiveSizingHint} for a single construction site. Hold it in a + * {@code static final} field and pass it to every {@link #create(AdaptiveSizingHint)} at that + * site; the map sizes itself from the hint and tunes the hint back on its own. The caller never + * touches the hint again. */ @Nonnull - public static SizingHint sizingHint() { - return new SizingHint(); + public static AdaptiveSizingHint adaptiveSizingHint() { + return new AdaptiveSizingHint(); } /** - * Opens a builder for a {@link SizingHint} that carries an initial capacity and/or a hard {@code - * maxCapacity}. Use this instead of {@link #sizingHint()} when a site wants to bound its maps' - * worst-case memory: every map built from the returned hint shares the same cap, and {@link #set} - * rejects (returns {@code false}) once a map is physically full at that cap. The hint still - * self-tunes its seed capacity within the cap. + * Opens a builder for an {@link AdaptiveSizingHint} that carries an initial capacity and/or a + * hard {@code maxCapacity}. Use this instead of {@link #adaptiveSizingHint()} when a site wants + * to bound its maps' worst-case memory: every map built from the returned hint shares the same + * cap, and {@link #set} rejects (returns {@code false}) once a map is physically full at that + * cap. The hint still self-tunes its seed capacity within the cap. */ @Nonnull - public static SizingHintBuilder buildSizingHint() { - return new SizingHintBuilder(); + public static AdaptiveSizingHintBuilder buildAdaptiveSizingHint() { + return new AdaptiveSizingHintBuilder(); } - /** Builds a {@link SizingHint} with an initial and/or maximum capacity. */ - public static final class SizingHintBuilder { + /** Builds an {@link AdaptiveSizingHint} with an initial and/or maximum capacity. */ + public static final class AdaptiveSizingHintBuilder { private int initCapacity = DEFAULT_HINT_SLOTS; private int maxCapacity = NO_MAX_SLOTS; - private SizingHintBuilder() {} + private AdaptiveSizingHintBuilder() {} /** Seed capacity in slots for a cold map (rounded up to a power of two). */ @Nonnull - public SizingHintBuilder initCapacity(int slots) { + public AdaptiveSizingHintBuilder initCapacity(int slots) { this.initCapacity = slots; return this; } @@ -148,13 +148,13 @@ public SizingHintBuilder initCapacity(int slots) { * (returns {@code false}) instead of growing further -- bounding worst-case memory. */ @Nonnull - public SizingHintBuilder maxCapacity(int slots) { + public AdaptiveSizingHintBuilder maxCapacity(int slots) { this.maxCapacity = slots; return this; } @Nonnull - public SizingHint build() { + public AdaptiveSizingHint build() { int seed = EmbeddingSupport.roundUpToPow2(this.initCapacity); int max = (this.maxCapacity == NO_MAX_SLOTS) @@ -164,7 +164,7 @@ public SizingHint build() { throw new IllegalArgumentException( "initCapacity (" + seed + " slots) exceeds maxCapacity (" + max + " slots)"); } - return new SizingHint(seed, max); + return new AdaptiveSizingHint(seed, max); } } @@ -173,8 +173,8 @@ public SizingHint build() { * trigger fires. Returns {@code true} if the mapping was stored (or overwrote an existing one). * *

Returns {@code false} only for a capped map (one built from a {@link - * #buildSizingHint()}.{@code maxCapacity(...)} hint): once the table is physically full at its - * cap, a genuinely new key is rejected rather than growing past the cap. The rejection is + * #buildAdaptiveSizingHint()}.{@code maxCapacity(...)} hint): once the table is physically full + * at its cap, a genuinely new key is rejected rather than growing past the cap. The rejection is * non-fatal -- the map is unchanged and the caller may ignore the return. An uncapped map always * returns {@code true}. */ @@ -246,9 +246,9 @@ Object[] dataForTesting() { /** * A self-tuning, per-construction-site sizing estimate. Mint one via {@link - * LightStringMap#sizingHint()}, hold it in a {@code static final} field, and pass it to {@link - * LightStringMap#create(SizingHint)}; the map reads it to size itself and tunes it back as it - * grows. The caller never updates it. + * LightStringMap#adaptiveSizingHint()}, hold it in a {@code static final} field, and pass it to + * {@link LightStringMap#create(AdaptiveSizingHint)}; the map reads it to size itself and tunes it + * back as it grows. The caller never updates it. * *

Opaque by design (no public members). The estimate self-tunes on two events the map already * observes -- a new map is started ({@link #seedSlots()}) and a map grows ({@link @@ -259,7 +259,7 @@ Object[] dataForTesting() { * update only mis-sizes a future array (over/under-provision) for an instance or two, never * corrupts map data -- no synchronization. */ - public static final class SizingHint { + public static final class AdaptiveSizingHint { // Learned seed capacity in slots (always a power of two). Additive-increase on grow (with one // class of headroom), multiplicative-decrease on the decay tick. private int slots; @@ -270,11 +270,11 @@ public static final class SizingHint { // the cap. private final int maxSlots; - private SizingHint() { + private AdaptiveSizingHint() { this(DEFAULT_HINT_SLOTS, NO_MAX_SLOTS); } - private SizingHint(int seedSlots, int maxSlots) { + private AdaptiveSizingHint(int seedSlots, int maxSlots) { this.slots = seedSlots; this.maxSlots = maxSlots; } @@ -501,7 +501,7 @@ public static Object[] set( */ @Nonnull public static Object[] set( - @Nonnull SizingHint hint, + @Nonnull AdaptiveSizingHint hint, @Nullable Object[] mapData, @Nonnull String key, @Nonnull V value) { diff --git a/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java b/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java index d55f6954a29..f5202d09a11 100644 --- a/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java +++ b/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java @@ -453,17 +453,17 @@ void isRemovedOnlyMatchesTheSentinel() { } @Nested - class SizingHintTests { + class AdaptiveSizingHintTests { @Test void freshHintSeedsAtDefault() { - LightStringMap.SizingHint hint = LightStringMap.sizingHint(); + LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHint(); assertEquals(LightStringMap.DEFAULT_HINT_SLOTS, hint.currentSeedSlots()); } @Test void hintSeedsAFreshMapAtItsLearnedCapacity() { - LightStringMap.SizingHint hint = LightStringMap.sizingHint(); + LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHint(); LightStringMap map = LightStringMap.create(hint); // A hint-seeded map allocates its backing array lazily, sized to the hint. map.set("a", 1); @@ -473,7 +473,7 @@ void hintSeedsAFreshMapAtItsLearnedCapacity() { @Test void growthRaisesSeedWithOneClassOfHeadroom() { - LightStringMap.SizingHint hint = LightStringMap.sizingHint(); + LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHint(); // Fill a hint-seeded map past its seed so it grows; the hint should learn the new size // PLUS one power-of-two class of headroom (so the steady-state load factor stays <= 0.5). LightStringMap map = LightStringMap.create(hint); @@ -486,7 +486,7 @@ void growthRaisesSeedWithOneClassOfHeadroom() { @Test void seedIsMonotonicMaxAcrossMaps() { - LightStringMap.SizingHint hint = LightStringMap.sizingHint(); + LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHint(); // A big map ratchets the hint up. LightStringMap big = LightStringMap.create(hint); for (int i = 0; i < 20; i++) { @@ -502,7 +502,7 @@ void seedIsMonotonicMaxAcrossMaps() { @Test void decayStepsSeedDownAfterInterval() { - LightStringMap.SizingHint hint = LightStringMap.sizingHint(); + LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHint(); // Ratchet the hint above the default so a step-down is observable. LightStringMap big = LightStringMap.create(hint); for (int i = 0; i < 20; i++) { @@ -518,7 +518,7 @@ void decayStepsSeedDownAfterInterval() { @Test void decayFloorsAtMinimum() { - LightStringMap.SizingHint hint = LightStringMap.sizingHint(); + LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHint(); // Enough decay intervals to drive an un-ratcheted hint to the floor and hold there. int intervals = 32; for (int i = 0; i < intervals * LightStringMap.DECAY_INTERVAL; i++) { @@ -529,7 +529,7 @@ void decayFloorsAtMinimum() { @Test void seedIsCappedAtMax() { - LightStringMap.SizingHint hint = LightStringMap.sizingHint(); + LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHint(); // A very large map cannot push the learned seed past the pre-provisioning ceiling. LightStringMap big = LightStringMap.create(hint); for (int i = 0; i < LightStringMap.MAX_HINT_SLOTS * 4; i++) { @@ -541,7 +541,7 @@ void seedIsCappedAtMax() { @Test void oneDecayStepStaysSafeThenSecondDecayRepins() { - LightStringMap.SizingHint hint = LightStringMap.sizingHint(); + LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHint(); // Learn a large size. With one class of headroom, `learned` is 2x the array the workload // physically grew into. LightStringMap big = LightStringMap.create(hint); @@ -576,7 +576,7 @@ void oneDecayStepStaysSafeThenSecondDecayRepins() { @Test void hintSeededMapStoresAndReadsBackCorrectly() { - LightStringMap.SizingHint hint = LightStringMap.sizingHint(); + LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHint(); LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 50; i++) { map.set("k" + i, i); @@ -587,7 +587,7 @@ void hintSeededMapStoresAndReadsBackCorrectly() { } } - // ============ hint-aware spine set(SizingHint, ...) overload ============ + // ============ hint-aware spine set(AdaptiveSizingHint, ...) overload ============ @Nested class SpineHintTests { @@ -596,7 +596,8 @@ class SpineHintTests { void spineSetSeedsAFreshTableFromTheHint() { // Dropping to the spine keeps the hint's sizing: a fresh table seeds from seedSlots(), just // like LightStringMap.create(hint) does through the object tier. - LightStringMap.SizingHint hint = LightStringMap.buildSizingHint().initCapacity(4).build(); + LightStringMap.AdaptiveSizingHint hint = + LightStringMap.buildAdaptiveSizingHint().initCapacity(4).build(); Object[] data = EmbeddingSupport.set(hint, null, "a", 1); assertEquals(4, EmbeddingSupport.numSlots(data)); assertEquals(1, (Object) EmbeddingSupport.get(data, "a")); @@ -607,7 +608,7 @@ void spineSetTeachesTheHintOnGrow() { // A grow through the spine ratchets the hint up with one class of headroom -- identical to // the // object tier's growthRaisesSeedWithOneClassOfHeadroom. - LightStringMap.SizingHint hint = LightStringMap.sizingHint(); + LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHint(); Object[] data = null; for (int i = 0; i < LightStringMap.DEFAULT_HINT_SLOTS + 1; i++) { data = EmbeddingSupport.set(hint, data, "k" + i, i); @@ -621,8 +622,8 @@ void spineSetDoesNotEnforceTheHintCap() { // The cap guardrail does NOT follow to the spine: an Object[] return cannot signal rejection, // so a capped hint used at the spine grows past its cap and always stores. (Contrast the // object tier, which rejects at the cap -- see CapTests.) - LightStringMap.SizingHint hint = - LightStringMap.buildSizingHint().initCapacity(2).maxCapacity(4).build(); + LightStringMap.AdaptiveSizingHint hint = + LightStringMap.buildAdaptiveSizingHint().initCapacity(2).maxCapacity(4).build(); Object[] data = null; for (int i = 0; i < 12; i++) { data = EmbeddingSupport.set(hint, data, "k" + i, i); @@ -638,7 +639,7 @@ void spineSetDoesNotEnforceTheHintCap() { void spineSetOverwriteInPlaceDoesNotTeachTheHint() { // An in-place overwrite neither grows nor teaches the hint -- same array back, seed // unchanged. - LightStringMap.SizingHint hint = LightStringMap.sizingHint(); + LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHint(); Object[] data = EmbeddingSupport.set(hint, null, "a", 1); int seedAfterFirstInsert = hint.currentSeedSlots(); @@ -667,9 +668,10 @@ void uncappedMapAlwaysReturnsTrueFromSet() { @Test void hintWithoutMaxCapacityIsUncapped() { - // buildSizingHint() with no maxCapacity behaves like the zero-config sizingHint(): no cap, so + // buildAdaptiveSizingHint() with no maxCapacity behaves like the zero-config + // adaptiveSizingHint(): no cap, so // set always stores. - LightStringMap.SizingHint hint = LightStringMap.buildSizingHint().build(); + LightStringMap.AdaptiveSizingHint hint = LightStringMap.buildAdaptiveSizingHint().build(); LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 100; i++) { assertTrue(map.set("k" + i, i)); @@ -682,7 +684,8 @@ void cappedSetStoresUntilPhysicallyFullThenRejects() { // maxCapacity(8) bounds the table at 8 slots. Eight distinct keys fill every slot (a // 8-slot table never trips the probe bound, so it fills before it would grow); a ninth, // genuinely new key cannot grow past the cap, so set rejects it and leaves the map unchanged. - LightStringMap.SizingHint hint = LightStringMap.buildSizingHint().maxCapacity(8).build(); + LightStringMap.AdaptiveSizingHint hint = + LightStringMap.buildAdaptiveSizingHint().maxCapacity(8).build(); LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 8; i++) { assertTrue(map.set("k" + i, i), "slot " + i + " should store"); @@ -703,7 +706,8 @@ void cappedSetStoresUntilPhysicallyFullThenRejects() { void cappedSetOverwritesExistingKeyEvenWhenFull() { // Rejection only applies to a genuinely new key. Overwriting a present key when the table is // full at the cap still succeeds -- no new slot is needed. - LightStringMap.SizingHint hint = LightStringMap.buildSizingHint().maxCapacity(8).build(); + LightStringMap.AdaptiveSizingHint hint = + LightStringMap.buildAdaptiveSizingHint().maxCapacity(8).build(); LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 8; i++) { map.set("k" + i, i); @@ -717,8 +721,8 @@ void cappedSetOverwritesExistingKeyEvenWhenFull() { void cappedMapGrowsUpToTheCap() { // A cap does not pin the seed size: a map started small still grows through the // power-of-two classes up to the cap, retaining every entry along the way. - LightStringMap.SizingHint hint = - LightStringMap.buildSizingHint().initCapacity(2).maxCapacity(16).build(); + LightStringMap.AdaptiveSizingHint hint = + LightStringMap.buildAdaptiveSizingHint().initCapacity(2).maxCapacity(16).build(); LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 16; i++) { assertTrue(map.set("k" + i, i), "slot " + i + " should store below the cap"); @@ -736,7 +740,8 @@ void afterRejectionRemovingThenReinsertingSucceeds() { // A rejection is non-fatal: freeing a slot (remove) makes room again, so a subsequent set of // a // new key succeeds via the reclaimed tombstone. - LightStringMap.SizingHint hint = LightStringMap.buildSizingHint().maxCapacity(8).build(); + LightStringMap.AdaptiveSizingHint hint = + LightStringMap.buildAdaptiveSizingHint().maxCapacity(8).build(); LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 8; i++) { map.set("k" + i, i); @@ -750,7 +755,8 @@ void afterRejectionRemovingThenReinsertingSucceeds() { @Test void maxCapacityRoundsUpToPowerOfTwo() { // A non-power-of-two cap rounds up: maxCapacity(5) becomes an 8-slot cap. - LightStringMap.SizingHint hint = LightStringMap.buildSizingHint().maxCapacity(5).build(); + LightStringMap.AdaptiveSizingHint hint = + LightStringMap.buildAdaptiveSizingHint().maxCapacity(5).build(); LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 8; i++) { assertTrue(map.set("k" + i, i)); @@ -762,7 +768,7 @@ void maxCapacityRoundsUpToPowerOfTwo() { void buildThrowsWhenInitCapacityExceedsMaxCapacity() { assertThrows( IllegalArgumentException.class, - () -> LightStringMap.buildSizingHint().initCapacity(16).maxCapacity(8).build()); + () -> LightStringMap.buildAdaptiveSizingHint().initCapacity(16).maxCapacity(8).build()); } @Test @@ -770,8 +776,8 @@ void cappedHintNeverSeedsAMapLargerThanTheCap() { // The learned seed is clamped to the cap: even after a map grows to the cap, recordSlots // (which // normally reserves a class of headroom) cannot push the seed past maxSlots. - LightStringMap.SizingHint hint = - LightStringMap.buildSizingHint().initCapacity(2).maxCapacity(16).build(); + LightStringMap.AdaptiveSizingHint hint = + LightStringMap.buildAdaptiveSizingHint().initCapacity(2).maxCapacity(16).build(); LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 16; i++) { map.set("k" + i, i); From d9e271f3a1f270ba300f505e2fb900e152a0711d Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 16:38:22 -0400 Subject: [PATCH 21/43] Bound LightStringMap growth under hashCode collisions; reclaim tombstones Two hardening fixes to the probe-bound grow decision, from the #12102 review: 1. Cap probe-bound over-growth (MAX_SLOTS_PER_LIVE_ENTRY). Keys sharing a hashCode() collapse onto one home slot in every table size, so a resize never shortens their chain. The pure probe-bound trigger would then double the table on every insert past MAX_PROBES -- a few dozen colliding keys could balloon an uncapped map to hundreds of millions of slots (an adversarial-input OOM). We now refuse a probe-bound grow once the table already holds MAX_SLOTS_PER_LIVE_ENTRY slots per live entry, accepting the long chain instead. Memory stays O(live entries); growth to make physical room is never gated. Physical-full growth and the maxCapacity cap are unchanged. 2. Prefer an earlier tombstone over a fresh null in findInsertionSlot. Reusing the closer (shorter-displacement) slot keeps chains short, avoids a needless grow, and clears the tombstone. Also threads the home slot out of findInsertionSlot into the distance check so setOrReject no longer recomputes it from key.hashCode() twice (perf-review SEV-3), and drops the now-unused withinProbeBound helper. Co-Authored-By: Claude Opus 4.8 --- .../datadog/trace/util/LightStringMap.java | 84 ++++++++++++------- 1 file changed, 53 insertions(+), 31 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java index f38d7b5372c..70b67c6012f 100644 --- a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java +++ b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java @@ -340,6 +340,19 @@ public static final class EmbeddingSupport { // 8 slots or fewer -- tiny maps still grow only when physically full, exactly as before. static final int MAX_PROBES = 8; + // Backstop on probe-bound over-growth, so a hashCode() collision set cannot exhaust the heap. + // Keys that share a hashCode() collapse onto one home slot in EVERY table size, so growing can + // never shorten their probe chain. A pure probe-bound trigger would then double the table every + // insert past MAX_PROBES -- a handful of colliding keys could balloon an uncapped map to + // hundreds of millions of slots (an adversarial-input OOM). We therefore refuse a probe-bound + // grow once the table already holds this many slots per live entry: past that point the long + // chain is a genuine collision cluster no resize can spread, so we accept the chain (bounded + // lookup cost) rather than grow (unbounded memory). Growth to make physical room is never gated + // by this -- only the probe-bound trigger is. Memory stays O(live entries); the MAX_PROBES + // probe-length bound still holds for well-distributed keys and degrades gracefully, not + // catastrophically, only under genuine hashCode collisions. + static final int MAX_SLOTS_PER_LIVE_ENTRY = 8; + // TODO: use of String constructor is deliberate, since this is // an internal marker that we don't want intern-ed static final String REMOVED = new String("\0D\0a\07\04\0\0d\00\0G"); @@ -541,43 +554,46 @@ static Object[] setOrReject( } int numSlots = numSlots(mapData); - int slot = findInsertionSlot(mapData, numSlots, key); + // Compute the home slot once and thread it into the probe (findInsertionSlot) and the + // probe-bound distance check below, rather than re-deriving it from key.hashCode() twice. + int home = preferredSlot(numSlots, key.hashCode()); + int slot = findInsertionSlot(mapData, numSlots, key, home); if (slot >= 0) { // Key already present -- overwrite in place, no growth. mapData[slot + numSlots] = value; return mapData; } - boolean hasFreeSlot = slot != NO_SPACE; - boolean wantsGrow = !hasFreeSlot || !withinProbeBound(numSlots, slot, key); - if (wantsGrow && numSlots < maxSlots) { - // No space, or the insertion would exceed the probe bound: grow, then insert into the fresh - // (tombstone-free, better-spread) table. + if (slot == NO_SPACE) { + // Physically full (no null and no reclaimable tombstone). We must grow to make room, unless + // a finite cap blocks it -- then reject the new key (non-fatal, map unchanged). + if (numSlots < maxSlots) { + mapData = expandMapData(mapData); + newMapUncheckedInsert(mapData, numSlots(mapData), key, value); + return mapData; + } + return null; + } + + // A free slot the probe walk found. Grow only if the insertion is past the probe bound AND a + // grow could actually help. Keys sharing a hashCode() cluster onto one chain in every table + // size, so once the table already holds MAX_SLOTS_PER_LIVE_ENTRY slots per live entry no + // resize can spread them -- we accept the long chain instead of doubling the table forever + // (the adversarial-input OOM backstop). At a finite cap the bound is likewise relaxed: we + // cannot grow, so we fill past MAX_PROBES until physically full. + int availableSlot = flip(slot); + int distance = (availableSlot - home) & (numSlots - 1); + if (distance >= MAX_PROBES + && numSlots < maxSlots + && (long) numSlots < (long) size(mapData) * MAX_SLOTS_PER_LIVE_ENTRY) { + // Grow, then insert into the fresh (tombstone-free, better-spread) table. mapData = expandMapData(mapData); newMapUncheckedInsert(mapData, numSlots(mapData), key, value); return mapData; } - if (hasFreeSlot) { - // A free slot the probe walk found. Below the cap this is within the probe bound; at the - // cap - // the bound is relaxed (we cannot grow), so fill it even past MAX_PROBES until physically - // full. - int availableSlot = flip(slot); - mapData[availableSlot] = key; - mapData[availableSlot + numSlots] = value; - return mapData; - } - // Physically full at a finite cap and the key is new: reject. - return null; - } - - // Whether an insertion at the free slot encoded by {@code insertionSlot} (as returned by - // findInsertionSlot for an absent key) lands within {@link #MAX_PROBES} of the key's home slot. - static boolean withinProbeBound(int numSlots, int insertionSlot, String key) { - int availableSlot = flip(insertionSlot); - int home = preferredSlot(numSlots, key.hashCode()); - int distance = (availableSlot - home) & (numSlots - 1); - return distance < MAX_PROBES; + mapData[availableSlot] = key; + mapData[availableSlot + numSlots] = value; + return mapData; } @SuppressWarnings("unchecked") @@ -654,13 +670,19 @@ public static final Object[] insertAt( } static final int findInsertionSlot(Object[] mapData, int numSlots, String key) { - int hash = key.hashCode(); - int preferredSlot = preferredSlot(numSlots, hash); + return findInsertionSlot(mapData, numSlots, key, preferredSlot(numSlots, key.hashCode())); + } + static final int findInsertionSlot( + Object[] mapData, int numSlots, String key, int preferredSlot) { int availableIndex = NO_SPACE; for (int keyIndex = preferredSlot; keyIndex < numSlots; ++keyIndex) { Object curKey = mapData[keyIndex]; - if (curKey == null) return flip(keyIndex); + // A reclaimable tombstone seen earlier in the probe order beats this null: it is closer to + // the home slot (shorter displacement, so less likely to trip the probe-bound grow) and + // reusing it clears a tombstone. The key is confirmed absent either way (we reached a + // null). + if (curKey == null) return (availableIndex != NO_SPACE) ? availableIndex : flip(keyIndex); if (curKey == key) return keyIndex; if (curKey == REMOVED) { if (availableIndex == NO_SPACE) availableIndex = flip(keyIndex); @@ -668,7 +690,7 @@ static final int findInsertionSlot(Object[] mapData, int numSlots, String ke } for (int keyIndex = 0; keyIndex < preferredSlot; ++keyIndex) { Object curKey = mapData[keyIndex]; - if (curKey == null) return flip(keyIndex); + if (curKey == null) return (availableIndex != NO_SPACE) ? availableIndex : flip(keyIndex); if (curKey == key) return keyIndex; if (curKey == REMOVED) { if (availableIndex == NO_SPACE) availableIndex = flip(keyIndex); From dd1a597e969880f2990446189b6e962192fefc88 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 16:38:24 -0400 Subject: [PATCH 22/43] Regression tests for collision-bounded growth and tombstone reuse - collidingHashCodesDoNotExplodeMemory: 32 identical-hashCode() keys must leave the table bounded to O(live entries), not explode, and stay retrievable. Fails against the pre-fix pure probe-bound trigger (grows unboundedly). - insertReclaimsEarlierTombstoneInsteadOfExtendingChain: a new key on a chain with a mid-chain tombstone reuses that slot rather than growing. Co-Authored-By: Claude Opus 4.8 --- .../trace/util/LightStringMapTest.java | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java b/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java index f5202d09a11..e5138c7b667 100644 --- a/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java +++ b/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java @@ -140,6 +140,65 @@ void clusteredKeysGrowEarlierThanWellSpreadKeys() { } } + @Test + void collidingHashCodesDoNotExplodeMemory() { + // Regression: keys sharing an identical hashCode() land on the same home slot in EVERY table + // size, so growing never shortens their probe chain. A pure probe-bound grow trigger would + // double the table on every insert past MAX_PROBES, ballooning an uncapped map to hundreds of + // millions of slots from a few dozen keys (an adversarial-input OOM). The + // MAX_SLOTS_PER_LIVE_ENTRY backstop must keep the table O(live entries) while every key stays + // retrievable. + List keys = identicalHashCodeKeys(32); + int sharedHash = keys.get(0).hashCode(); + for (String key : keys) { + assertEquals(sharedHash, key.hashCode(), "fixture keys must share one hashCode: " + key); + } + + LightStringMap map = LightStringMap.create(8); + for (int i = 0; i < keys.size(); i++) { + map.set(keys.get(i), i); + } + + int slots = EmbeddingSupport.numSlots(map.dataForTesting()); + assertTrue( + slots <= keys.size() * 16, + "colliding keys must stay bounded, not explode: " + slots + " slots for " + keys.size()); + assertEquals(keys.size(), map.size()); + for (int i = 0; i < keys.size(); i++) { + assertEquals(i, map.get(keys.get(i)), keys.get(i)); + } + } + + @Test + void insertReclaimsEarlierTombstoneInsteadOfExtendingChain() { + // A chain of same-home-slot keys, with one removed mid-chain, leaves a tombstone. A later + // insert of a new key on that chain must reclaim the (earlier, shorter-displacement) + // tombstone + // rather than walk past it to a fresh null -- keeping the chain short and avoiding a needless + // grow. + List colliding = collidingKeys(5); + LightStringMap map = LightStringMap.create(16); + for (int i = 0; i < 4; i++) { + map.set(colliding.get(i), i); + } + Object[] data = map.dataForTesting(); + int numSlots = EmbeddingSupport.numSlots(data); + int reclaimedSlot = slotOf(data, numSlots, colliding.get(1)); + assertTrue(reclaimedSlot >= 0, "second key should be present before removal"); + + map.remove(colliding.get(1)); // tombstone mid-chain + map.set(colliding.get(4), 4); // new key on the same chain + + Object[] after = map.dataForTesting(); + assertEquals(16, EmbeddingSupport.numSlots(after), "reusing the tombstone avoids a grow"); + assertEquals( + colliding.get(4), + after[reclaimedSlot], + "the new key should reuse the earlier tombstone slot, not extend the chain"); + assertEquals(4, map.get(colliding.get(4))); + assertNull(map.get(colliding.get(1))); + } + @Test void growsAndPreservesAllEntries() { // initial capacity 2 forces several resizes as we insert well past it. @@ -196,6 +255,31 @@ private List collidingKeys(int count) { return keys; } + // Distinct strings that all share ONE hashCode() (not merely one home slot): the equal-hashCode + // blocks "Aa" and "BB" both hash to 2112, and any concatenation of them yields the same + // hashCode -- so these collide in every table size and can never be spread apart by growing. + private List identicalHashCodeKeys(int count) { + String[] blocks = {"Aa", "BB"}; + List keys = new ArrayList<>(count); + for (int i = 0; keys.size() < count; i++) { + StringBuilder sb = new StringBuilder(); + int bits = i; + for (int b = 0; b < 5; b++) { // 2^5 = 32 distinct combinations + sb.append(blocks[bits & 1]); + bits >>>= 1; + } + keys.add(sb.toString()); + } + return keys; + } + + private int slotOf(Object[] data, int numSlots, String key) { + for (int slot = 0; slot < numSlots; slot++) { + if (key.equals(data[slot])) return slot; + } + return -1; + } + @Test void removeThenReinsertSameKey() { LightStringMap map = LightStringMap.create(4); From 17a8ebb2a3f1856e3d6e3a3cce62e4e22ccb5591 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 17:20:43 -0400 Subject: [PATCH 23/43] Rename LightStringMap factories: create* -> createUncapped/createCapped Splits the untuned front door into explicit uncapped and hard-capped factories so the memory posture is chosen at the call site rather than implied by the argument shape: create() -> createUncapped() create(int) -> createUncapped(int) (new) createCapped(int maxCapacity) (new) createCapped(int initialCapacity, int maxCapacity) createCapped gives a thought-free worst-case memory bound (set() rejects a genuinely new key once physically full at the cap) without minting an AdaptiveSizingHint. create(AdaptiveSizingHint) is unchanged. Co-Authored-By: Claude Opus 4.8 --- .../datadog/trace/util/LightStringMap.java | 57 ++++++--- .../trace/util/LightStringMapTest.java | 108 +++++++++++++++--- 2 files changed, 134 insertions(+), 31 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java index 70b67c6012f..d89eb26a650 100644 --- a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java +++ b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java @@ -42,7 +42,7 @@ public final class LightStringMap { public static final int DEFAULT_CAPACITY = 8; // Slots a fresh (un-tuned) hint seeds -- a reasonable default so a cold site behaves like a - // plain LightStringMap.create(); it then self-tunes up or down from here. + // plain LightStringMap.createUncapped(); it then self-tunes up or down from here. static final int DEFAULT_HINT_SLOTS = DEFAULT_CAPACITY; // Floor step-down never drops a hint below (in slots), so a genuinely tiny site can still tune // below the default. Must stay >= 1 (a zero-slot table is degenerate). @@ -66,10 +66,10 @@ public final class LightStringMap { @Nullable private final AdaptiveSizingHint sizingHint; private Object[] data = EmbeddingSupport.EMPTY_DATA; - private LightStringMap(int capacity) { + private LightStringMap(int capacity, int maxSlots) { this.initialCapacity = capacity; this.sizingHint = null; - this.maxSlots = NO_MAX_SLOTS; + this.maxSlots = maxSlots; } private LightStringMap(@Nonnull AdaptiveSizingHint hint) { @@ -80,18 +80,47 @@ private LightStringMap(@Nonnull AdaptiveSizingHint hint) { /** A new, uncapped map seeded at the default capacity. The "just give me a map" front door. */ @Nonnull - public static LightStringMap create() { - return new LightStringMap<>(DEFAULT_CAPACITY); + public static LightStringMap createUncapped() { + return new LightStringMap<>(DEFAULT_CAPACITY, NO_MAX_SLOTS); } /** * A new, uncapped map seeded at {@code capacity} (rounded up to a power of two on first write). - * Use when the caller already knows the rough size; otherwise prefer {@link #create()} or a - * {@link #adaptiveSizingHint()}. + * Use when the caller already knows the rough size; otherwise prefer {@link #createUncapped()} or + * a {@link #adaptiveSizingHint()}. */ @Nonnull - public static LightStringMap create(int capacity) { - return new LightStringMap<>(capacity); + public static LightStringMap createUncapped(int capacity) { + return new LightStringMap<>(capacity, NO_MAX_SLOTS); + } + + /** + * A new map hard-capped at {@code maxCapacity} slots (rounded up to a power of two), seeded at + * the default capacity (clamped to the cap). Once the table is physically full at the cap, {@link + * #set} rejects a genuinely new key (returns {@code false}) instead of growing further -- a + * thought-free way to bound worst-case memory without minting an {@link AdaptiveSizingHint}. + */ + @Nonnull + public static LightStringMap createCapped(int maxCapacity) { + int max = EmbeddingSupport.roundUpToPow2(maxCapacity); + int seed = Math.min(DEFAULT_CAPACITY, max); + return new LightStringMap<>(seed, max); + } + + /** + * A new map seeded at {@code initialCapacity} and hard-capped at {@code maxCapacity} (both in + * slots, each rounded up to a power of two). Like {@link #createCapped(int)} but with an explicit + * seed. Throws {@link IllegalArgumentException} if the rounded seed exceeds the rounded cap. + */ + @Nonnull + public static LightStringMap createCapped(int initialCapacity, int maxCapacity) { + int seed = EmbeddingSupport.roundUpToPow2(initialCapacity); + int max = EmbeddingSupport.roundUpToPow2(maxCapacity); + if (seed > max) { + throw new IllegalArgumentException( + "initialCapacity (" + seed + " slots) exceeds maxCapacity (" + max + " slots)"); + } + return new LightStringMap<>(seed, max); } /** @@ -172,11 +201,11 @@ public AdaptiveSizingHint build() { * Stores {@code value} under {@code key}, growing the backing table if the probe-bound grow * trigger fires. Returns {@code true} if the mapping was stored (or overwrote an existing one). * - *

Returns {@code false} only for a capped map (one built from a {@link - * #buildAdaptiveSizingHint()}.{@code maxCapacity(...)} hint): once the table is physically full - * at its cap, a genuinely new key is rejected rather than growing past the cap. The rejection is - * non-fatal -- the map is unchanged and the caller may ignore the return. An uncapped map always - * returns {@code true}. + *

Returns {@code false} only for a capped map (one built via {@link #createCapped(int)} / + * {@link #createCapped(int, int)}, or from a {@link #buildAdaptiveSizingHint()}.{@code + * maxCapacity(...)} hint): once the table is physically full at its cap, a genuinely new key is + * rejected rather than growing past the cap. The rejection is non-fatal -- the map is unchanged + * and the caller may ignore the return. An uncapped map always returns {@code true}. */ public boolean set(@Nonnull String key, @Nonnull V value) { Objects.requireNonNull(value, "value"); diff --git a/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java b/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java index e5138c7b667..332e0fa7b2b 100644 --- a/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java +++ b/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java @@ -24,13 +24,13 @@ class InstanceTests { @Test void getOnEmptyMapReturnsNull() { - LightStringMap map = LightStringMap.create(LightStringMap.DEFAULT_CAPACITY); + LightStringMap map = LightStringMap.createUncapped(LightStringMap.DEFAULT_CAPACITY); assertNull(map.get("absent")); } @Test void setThenGet() { - LightStringMap map = LightStringMap.create(8); + LightStringMap map = LightStringMap.createUncapped(8); map.set("a", 1); map.set("b", 2); assertEquals(1, map.get("a")); @@ -40,7 +40,7 @@ void setThenGet() { @Test void setOverwritesExistingKey() { - LightStringMap map = LightStringMap.create(8); + LightStringMap map = LightStringMap.createUncapped(8); map.set("a", 1); map.set("a", 42); assertEquals(42, map.get("a")); @@ -48,7 +48,7 @@ void setOverwritesExistingKey() { @Test void removeMakesKeyAbsent() { - LightStringMap map = LightStringMap.create(8); + LightStringMap map = LightStringMap.createUncapped(8); map.set("a", 1); map.set("b", 2); map.remove("a"); @@ -58,7 +58,7 @@ void removeMakesKeyAbsent() { @Test void containsKeyDistinguishesPresenceFromAbsence() { - LightStringMap map = LightStringMap.create(8); + LightStringMap map = LightStringMap.createUncapped(8); assertFalse(map.containsKey("a")); map.set("a", 1); assertTrue(map.containsKey("a")); @@ -69,14 +69,14 @@ void containsKeyDistinguishesPresenceFromAbsence() { @Test void setRejectsNullValue() { - LightStringMap map = LightStringMap.create(8); + LightStringMap map = LightStringMap.createUncapped(8); assertThrows(NullPointerException.class, () -> map.set("a", null)); assertFalse(map.containsKey("a")); } @Test void sizeTracksLiveEntries() { - LightStringMap map = LightStringMap.create(8); + LightStringMap map = LightStringMap.createUncapped(8); assertEquals(0, map.size()); map.set("a", 1); map.set("b", 2); @@ -95,7 +95,7 @@ void tinyMapGrowsOnlyWhenPhysicallyFull() { // can never travel 8 slots there -- so a seed-8 map fills all 8 slots before it grows, // exactly as it did before the trigger existed. This is the property that keeps the tiny, // miss-dominated consumer (springweb6 localAttributes) behaviorally unchanged. - LightStringMap map = LightStringMap.create(8); + LightStringMap map = LightStringMap.createUncapped(8); for (int i = 0; i < 8; i++) { map.set("k" + i, i); } @@ -117,12 +117,12 @@ void clusteredKeysGrowEarlierThanWellSpreadKeys() { // well before the table is anywhere near full. Both sets stay fully retrievable. int count = 32; - LightStringMap spread = LightStringMap.create(8); + LightStringMap spread = LightStringMap.createUncapped(8); for (int i = 0; i < count; i++) { spread.set("spread." + i, i); } - LightStringMap clustered = LightStringMap.create(8); + LightStringMap clustered = LightStringMap.createUncapped(8); List colliding = collidingKeys(count); for (int i = 0; i < count; i++) { clustered.set(colliding.get(i), i); @@ -154,7 +154,7 @@ void collidingHashCodesDoNotExplodeMemory() { assertEquals(sharedHash, key.hashCode(), "fixture keys must share one hashCode: " + key); } - LightStringMap map = LightStringMap.create(8); + LightStringMap map = LightStringMap.createUncapped(8); for (int i = 0; i < keys.size(); i++) { map.set(keys.get(i), i); } @@ -177,7 +177,7 @@ void insertReclaimsEarlierTombstoneInsteadOfExtendingChain() { // rather than walk past it to a fresh null -- keeping the chain short and avoiding a needless // grow. List colliding = collidingKeys(5); - LightStringMap map = LightStringMap.create(16); + LightStringMap map = LightStringMap.createUncapped(16); for (int i = 0; i < 4; i++) { map.set(colliding.get(i), i); } @@ -202,7 +202,7 @@ void insertReclaimsEarlierTombstoneInsteadOfExtendingChain() { @Test void growsAndPreservesAllEntries() { // initial capacity 2 forces several resizes as we insert well past it. - LightStringMap map = LightStringMap.create(2); + LightStringMap map = LightStringMap.createUncapped(2); int n = 100; for (int i = 0; i < n; i++) { map.set("key-" + i, i); @@ -214,7 +214,7 @@ void growsAndPreservesAllEntries() { @Test void forEachVisitsEveryLiveEntry() { - LightStringMap map = LightStringMap.create(4); + LightStringMap map = LightStringMap.createUncapped(4); for (int i = 0; i < 20; i++) { map.set("k" + i, i); } @@ -232,7 +232,7 @@ void forEachVisitsEveryLiveEntry() { @Test void nonLiteralKeyResolvesViaEqualsFallback() { - LightStringMap map = LightStringMap.create(8); + LightStringMap map = LightStringMap.createUncapped(8); map.set("hello", 1); // Distinct String instance with the same content -- must be found via the equals pass. String lookup = new String("hello"); @@ -282,7 +282,7 @@ private int slotOf(Object[] data, int numSlots, String key) { @Test void removeThenReinsertSameKey() { - LightStringMap map = LightStringMap.create(4); + LightStringMap map = LightStringMap.createUncapped(4); for (int i = 0; i < 4; i++) { map.set("k" + i, i); } @@ -743,7 +743,7 @@ class CapTests { void uncappedMapAlwaysReturnsTrueFromSet() { // The plain capacity constructor is uncapped: set stores unconditionally and never rejects, // even well past the initial capacity. - LightStringMap map = LightStringMap.create(2); + LightStringMap map = LightStringMap.createUncapped(2); for (int i = 0; i < 100; i++) { assertTrue(map.set("k" + i, i), "uncapped set should always store"); } @@ -871,4 +871,78 @@ void cappedHintNeverSeedsAMapLargerThanTheCap() { "learned seed " + hint.currentSeedSlots() + " must not exceed the 16-slot cap"); } } + + // ============ createCapped(...) untuned hard-capped front door ============ + + @Nested + class CreateCappedTests { + + @Test + void createCappedRejectsNewKeyOncePhysicallyFullAtCap() { + // createCapped(8) hard-bounds the table at 8 slots with no hint: eight distinct keys fill it, + // a ninth genuinely new key is rejected and leaves the map unchanged. + LightStringMap map = LightStringMap.createCapped(8); + for (int i = 0; i < 8; i++) { + assertTrue(map.set("k" + i, i), "slot " + i + " should store"); + } + assertEquals(8, EmbeddingSupport.numSlots(map.dataForTesting()), "capped at 8 slots"); + assertFalse(map.set("overflow", 99), "new key past the cap should be rejected"); + assertEquals(8, map.size(), "rejected set must not change the map"); + for (int i = 0; i < 8; i++) { + assertEquals(i, map.get("k" + i)); + } + } + + @Test + void createCappedRoundsCapUpToPowerOfTwo() { + // A non-power-of-two cap rounds up: createCapped(5) becomes an 8-slot cap. + LightStringMap map = LightStringMap.createCapped(5); + for (int i = 0; i < 8; i++) { + assertTrue(map.set("k" + i, i)); + } + assertFalse(map.set("k8", 8), "cap of 5 rounds up to 8 slots"); + } + + @Test + void createCappedSeedClampedToCapSmallerThanDefault() { + // With a cap below the default seed, the seed is clamped down to the cap: createCapped(4) + // seeds and caps at 4 slots, so the fifth new key is rejected. + LightStringMap map = LightStringMap.createCapped(4); + for (int i = 0; i < 4; i++) { + assertTrue(map.set("k" + i, i)); + } + assertEquals(4, EmbeddingSupport.numSlots(map.dataForTesting()), "capped at 4 slots"); + assertFalse(map.set("k4", 4), "the fifth key exceeds the 4-slot cap"); + } + + @Test + void createCappedTwoArgSeedsSmallAndGrowsToTheCap() { + // createCapped(2, 16) seeds at 2 slots and grows through the power-of-two classes up to the + // 16-slot cap, retaining every entry; the 17th new key is rejected. + LightStringMap map = LightStringMap.createCapped(2, 16); + for (int i = 0; i < 16; i++) { + assertTrue(map.set("k" + i, i), "slot " + i + " should store below the cap"); + } + assertEquals(16, EmbeddingSupport.numSlots(map.dataForTesting())); + assertFalse(map.set("k16", 16), "the 17th key exceeds the 16-slot cap"); + for (int i = 0; i < 16; i++) { + assertEquals(i, map.get("k" + i)); + } + } + + @Test + void createCappedTwoArgRoundsBothToPowerOfTwo() { + // Both arguments round up independently: createCapped(3, 5) seeds at 4 slots, caps at 8. + LightStringMap map = LightStringMap.createCapped(3, 5); + for (int i = 0; i < 8; i++) { + assertTrue(map.set("k" + i, i)); + } + assertFalse(map.set("k8", 8), "cap of 5 rounds up to 8 slots"); + } + + @Test + void createCappedTwoArgThrowsWhenSeedExceedsCap() { + assertThrows(IllegalArgumentException.class, () -> LightStringMap.createCapped(16, 8)); + } + } } From 862e0e62f69db35e466114393a6a8d90b24a8100 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 18:23:07 -0400 Subject: [PATCH 24/43] Drop unused EmbeddingSupport.setAll / checkedInsert from LightStringMap setAll had no production consumers (only its own two tests), and checkedInsert was called only by setAll. Removing both cuts dead surface and eliminates the codex #476 concern (checkedInsert bypassed the MAX_PROBES probe-bound grow check that the instance insert path enforces via setOrReject) -- there is no caller to need the merge helper, so the right fix is to not ship it. Co-Authored-By: Claude Opus 4.8 --- .../datadog/trace/util/LightStringMap.java | 53 ------------------- .../trace/util/LightStringMapTest.java | 27 ---------- 2 files changed, 80 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java index d89eb26a650..6f6fc8abb2e 100644 --- a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java +++ b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java @@ -485,42 +485,6 @@ public static Object[] set( return set(DEFAULT_CAPACITY, mapData, key, value); } - @Nullable - public static Object[] setAll( - @Nullable Object[] destMapData, @Nullable Object[] srcMapData) { - return setAll(destMapData, size(destMapData), srcMapData, size(srcMapData)); - } - - @Nullable - public static Object[] setAll( - @Nullable Object[] destMapData, int destSize, @Nullable Object[] srcMapData, int srcSize) { - if (srcMapData == null || srcSize == 0) return destMapData; - if (destMapData == null) return srcMapData.clone(); - - int expectedSize = destSize + srcSize; - int initDestSlots = numSlots(destMapData); - int initFreeSpace = initDestSlots - destSize; - - int numDestSlots; - if (expectedSize > initDestSlots) { - destMapData = expandMapData(destMapData, expectedSize + Math.max(initFreeSpace, 10)); - numDestSlots = numSlots(destMapData); // re-read after expandMapData rounds to pow-of-2 - } else { - numDestSlots = initDestSlots; - } - - int numSrcSlots = numSlots(srcMapData); - for (int srcSlot = 0; srcSlot < numSrcSlots; ++srcSlot) { - String srcKey = str(srcMapData[srcSlot]); - if (srcKey == null || srcKey == REMOVED) continue; - - Object srcValue = srcMapData[srcSlot + numSrcSlots]; - checkedInsert(destMapData, numDestSlots, srcKey, srcValue); - } - - return destMapData; - } - @Nonnull public static Object[] set( int initialCapacity, @Nullable Object[] mapData, @Nonnull String key, @Nonnull V value) { @@ -651,23 +615,6 @@ public static boolean remove(@Nullable Object[] mapData, @Nonnull String key) { } } - public static boolean checkedInsert( - @Nonnull Object[] mapData, int numSlots, @Nonnull String key, @Nonnull Object value) { - int insertionSlot = findInsertionSlot(mapData, numSlots, key); - - if (insertionSlot >= 0) { - mapData[insertionSlot + numSlots] = value; - return true; - } else if (insertionSlot != NO_SPACE) { - int availableSlot = flip(insertionSlot); - mapData[availableSlot] = key; - mapData[availableSlot + numSlots] = value; - return true; - } else { - return false; - } - } - public static int findInsertionSlot(@Nullable Object[] mapData, @Nonnull String key) { if (mapData == null) return NO_SPACE; diff --git a/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java b/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java index 332e0fa7b2b..32cc7e627da 100644 --- a/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java +++ b/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java @@ -440,33 +440,6 @@ void insertAtFromNullBuildsNewMap() { assertEquals("A", EmbeddingSupport.get(data, "a")); } - @Test - void setAllMergesSourceIntoDest() { - Object[] dest = null; - dest = EmbeddingSupport.set(8, dest, "a", "A"); - dest = EmbeddingSupport.set(8, dest, "b", "B"); - - Object[] src = null; - src = EmbeddingSupport.set(8, src, "c", "C"); - src = EmbeddingSupport.set(8, src, "d", "D"); - - dest = EmbeddingSupport.setAll(dest, src); - assertEquals(4, EmbeddingSupport.size(dest)); - assertEquals("A", EmbeddingSupport.get(dest, "a")); - assertEquals("C", EmbeddingSupport.get(dest, "c")); - assertEquals("D", EmbeddingSupport.get(dest, "d")); - } - - @Test - void setAllFromNullDestClonesSource() { - Object[] src = EmbeddingSupport.set(8, null, "c", "C"); - Object[] dest = EmbeddingSupport.setAll(null, src); - assertEquals("C", EmbeddingSupport.get(dest, "c")); - // clone -> independent from src - EmbeddingSupport.set(8, dest, "d", "D"); - assertNull(EmbeddingSupport.get(src, "d")); - } - @Test void forEachStaticVisitsLiveEntries() { Object[] data = null; From 942d206f49b7386f92b2e299fd861dc9211da75d Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 18:56:01 -0400 Subject: [PATCH 25/43] Clarify LightStringMap spine contract: distinct slot sentinels + null-value guard Rename the two EmbeddingSupport sentinels so each names its own return contract, since findSlot and findInsertionSlot live in different numeric spaces: - findSlot follows the String.indexOf idiom -> SLOT_NOT_FOUND (-1) on any miss (both null map and absent key; previously the null-map case returned Integer.MIN_VALUE while a genuine miss returned -1 -- an inconsistency the public NOT_FOUND constant invited callers to trip over). - findInsertionSlot follows the Arrays.binarySearch idiom (flip()-encoded free slot) -> SLOT_CAPACITY_REACHED when physically full. Document the flip() encoding by naming the Arrays.binarySearch parallel. Enforce the no-null-value invariant centrally in setOrReject (the shared insert core every set path flows through) and in insertAt (a separate spine write path), instead of only at the object-tier front door. Fill in the @Nonnull/@Nullable annotations across the EmbeddingSupport spine helpers (findSlot, findInsertionSlot, newMapData, newMapUncheckedInsert, forEach ctx, str). Co-Authored-By: Claude Opus 4.8 --- .../util/LightStringMapGrowBenchmark.java | 2 +- .../datadog/trace/util/LightStringMap.java | 75 +++++++++++++------ .../trace/util/LightStringMapTest.java | 13 +++- 3 files changed, 62 insertions(+), 28 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/util/LightStringMapGrowBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/LightStringMapGrowBenchmark.java index 889f409a105..9d293e7245b 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/LightStringMapGrowBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/LightStringMapGrowBenchmark.java @@ -143,7 +143,7 @@ static void put(Trigger trigger, int k, Cursor c, String key, Object value) { } boolean grow; - if (slot == EmbeddingSupport.NO_SPACE) { + if (slot == EmbeddingSupport.SLOT_CAPACITY_REACHED) { grow = true; } else { int available = EmbeddingSupport.flip(slot); diff --git a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java index 6f6fc8abb2e..ffece7ee6f8 100644 --- a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java +++ b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java @@ -208,8 +208,8 @@ public AdaptiveSizingHint build() { * and the caller may ignore the return. An uncapped map always returns {@code true}. */ public boolean set(@Nonnull String key, @Nonnull V value) { - Objects.requireNonNull(value, "value"); - + // Null-value rejection is enforced centrally in EmbeddingSupport.setOrReject (the shared insert + // core), so it holds for the spine entry points too -- not just this object-tier front door. // A thin delegate over the shared spine orchestration: it passes this map's cap (NO_MAX_SLOTS // when uncapped) and does the two things only the object tier can -- swap in the new backing // array and teach the sizing hint. A null result is the spine's non-fatal rejection signal. @@ -352,8 +352,17 @@ int currentSeedSlots() { } public static final class EmbeddingSupport { - public static final int NOT_FOUND = Integer.MIN_VALUE; - static final int NO_SPACE = Integer.MIN_VALUE; + // findSlot is a pure lookup in the String.indexOf idiom: a non-negative slot on a hit, or this + // sentinel on any miss (null map or absent key). + public static final int SLOT_NOT_FOUND = -1; + // findInsertionSlot is a locate-or-reserve in the Arrays.binarySearch idiom, so its return + // lives + // in a different numeric space than findSlot's: a non-negative slot when the key is already + // present, a flip()-encoded free slot when it is absent, or this sentinel when the table is + // physically full. The two sentinels are deliberately named apart so a return from one method + // is + // never compared against the other's contract. + static final int SLOT_CAPACITY_REACHED = Integer.MIN_VALUE; // Grow trigger: an insertion that would land this many slots or more from its home slot forces // a resize, rather than waiting for the table to fill completely. This bounds the worst-case @@ -542,6 +551,9 @@ static Object[] setOrReject( @Nullable Object[] mapData, @Nonnull String key, @Nonnull V value) { + // The map contract forbids null values (a null get() unambiguously means "absent"), so reject + // one here -- the single chokepoint every set path (object tier and spine) flows through. + Objects.requireNonNull(value, "value"); if (mapData == null) { return newMapData(initialCapacity, key, value); } @@ -557,7 +569,7 @@ static Object[] setOrReject( return mapData; } - if (slot == NO_SPACE) { + if (slot == SLOT_CAPACITY_REACHED) { // Physically full (no null and no reclaimable tombstone). We must grow to make room, unless // a finite cap blocks it -- then reject the new key (non-fatal, map unchanged). if (numSlots < maxSlots) { @@ -616,7 +628,7 @@ public static boolean remove(@Nullable Object[] mapData, @Nonnull String key) { } public static int findInsertionSlot(@Nullable Object[] mapData, @Nonnull String key) { - if (mapData == null) return NO_SPACE; + if (mapData == null) return SLOT_CAPACITY_REACHED; return findInsertionSlot(mapData, numSlots(mapData), key); } @@ -628,11 +640,14 @@ public static final Object[] insertAt( int insertionSlot, @Nonnull String key, @Nonnull Object value) { + // Same null-value invariant as setOrReject; insertAt is a separate spine write path that does + // not flow through it, so it needs its own guard. + Objects.requireNonNull(value, "value"); if (mapData == null) { return newMapData(initialCapacity, key, value); } - if (insertionSlot == NO_SPACE) { + if (insertionSlot == SLOT_CAPACITY_REACHED) { mapData = expandMapData(mapData); newMapUncheckedInsert(mapData, numSlots(mapData), key, value); } else { @@ -645,36 +660,44 @@ public static final Object[] insertAt( return mapData; } - static final int findInsertionSlot(Object[] mapData, int numSlots, String key) { + static final int findInsertionSlot( + @Nonnull Object[] mapData, int numSlots, @Nonnull String key) { return findInsertionSlot(mapData, numSlots, key, preferredSlot(numSlots, key.hashCode())); } static final int findInsertionSlot( - Object[] mapData, int numSlots, String key, int preferredSlot) { - int availableIndex = NO_SPACE; + @Nonnull Object[] mapData, int numSlots, @Nonnull String key, int preferredSlot) { + int availableIndex = SLOT_CAPACITY_REACHED; for (int keyIndex = preferredSlot; keyIndex < numSlots; ++keyIndex) { Object curKey = mapData[keyIndex]; // A reclaimable tombstone seen earlier in the probe order beats this null: it is closer to // the home slot (shorter displacement, so less likely to trip the probe-bound grow) and // reusing it clears a tombstone. The key is confirmed absent either way (we reached a // null). - if (curKey == null) return (availableIndex != NO_SPACE) ? availableIndex : flip(keyIndex); + if (curKey == null) + return (availableIndex != SLOT_CAPACITY_REACHED) ? availableIndex : flip(keyIndex); if (curKey == key) return keyIndex; if (curKey == REMOVED) { - if (availableIndex == NO_SPACE) availableIndex = flip(keyIndex); + if (availableIndex == SLOT_CAPACITY_REACHED) availableIndex = flip(keyIndex); } else if (key.equals(curKey)) return keyIndex; } for (int keyIndex = 0; keyIndex < preferredSlot; ++keyIndex) { Object curKey = mapData[keyIndex]; - if (curKey == null) return (availableIndex != NO_SPACE) ? availableIndex : flip(keyIndex); + if (curKey == null) + return (availableIndex != SLOT_CAPACITY_REACHED) ? availableIndex : flip(keyIndex); if (curKey == key) return keyIndex; if (curKey == REMOVED) { - if (availableIndex == NO_SPACE) availableIndex = flip(keyIndex); + if (availableIndex == SLOT_CAPACITY_REACHED) availableIndex = flip(keyIndex); } else if (key.equals(curKey)) return keyIndex; } return availableIndex; } + // Encodes a free slot index as a negative number so it is distinguishable from a "key present + // at this slot" hit (which is >= 0), letting one int carry both outcomes without an out-param. + // This is the same convention java.util.Arrays.binarySearch uses to return an insertion point: + // slot i maps to -i-1, so slot 0 (which cannot be negated to a distinct value) becomes -1. + // Self-inverse: flip(flip(i)) == i. static int flip(int keyIndex) { return -keyIndex - 1; } @@ -700,12 +723,12 @@ public static final Object getAndRemoveAt(@Nullable Object[] mapData, int slot) } public static final int findSlot(@Nullable Object[] mapData, @Nonnull String key) { - if (mapData == null) return NOT_FOUND; + if (mapData == null) return SLOT_NOT_FOUND; return findSlot(mapData, numSlots(mapData), key); } - static final int findSlot(Object[] mapData, int numSlots, String key) { + static final int findSlot(@Nonnull Object[] mapData, int numSlots, @Nonnull String key) { int hash = key.hashCode(); int preferredSlot = preferredSlot(numSlots, hash); @@ -715,18 +738,20 @@ static final int findSlot(Object[] mapData, int numSlots, String key) { // pointer compare, while a miss touches only the probe chain (not the whole array). for (int keyIndex = preferredSlot; keyIndex < numSlots; ++keyIndex) { Object curKey = mapData[keyIndex]; - if (curKey == null) return -1; + if (curKey == null) return SLOT_NOT_FOUND; if (curKey != REMOVED && key.equals(curKey)) return keyIndex; } for (int keyIndex = 0; keyIndex < preferredSlot; ++keyIndex) { Object curKey = mapData[keyIndex]; - if (curKey == null) return -1; + if (curKey == null) return SLOT_NOT_FOUND; if (curKey != REMOVED && key.equals(curKey)) return keyIndex; } - return -1; + return SLOT_NOT_FOUND; } - static final Object[] newMapData(int initialCapacity, String key, Object value) { + @Nonnull + static final Object[] newMapData( + int initialCapacity, @Nonnull String key, @Nonnull Object value) { int numSlots = roundUpToPow2(initialCapacity); Object[] mapData = new Object[numSlots << 1]; @@ -779,7 +804,9 @@ public static void forEach( } public static void forEach( - @Nullable Object[] mapData, C ctx, @Nonnull TriConsumer entryConsumer) { + @Nullable Object[] mapData, + @Nullable C ctx, + @Nonnull TriConsumer entryConsumer) { if (mapData == null) return; int numSlots = numSlots(mapData); @@ -816,7 +843,8 @@ public static String toInternalString(@Nullable Object[] mapData) { return builder.toString(); } - static void newMapUncheckedInsert(Object[] mapData, int numSlots, String key, Object value) { + static void newMapUncheckedInsert( + @Nonnull Object[] mapData, int numSlots, @Nonnull String key, @Nonnull Object value) { int hash = key.hashCode(); int preferredSlot = preferredSlot(numSlots, hash); @@ -850,7 +878,8 @@ static int roundUpToPow2(int n) { return n <= 1 ? 1 : Integer.highestOneBit(n - 1) << 1; } - static final String str(Object key) { + @Nullable + static final String str(@Nullable Object key) { return (String) key; } } diff --git a/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java b/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java index 32cc7e627da..1af841f4265 100644 --- a/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java +++ b/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java @@ -307,7 +307,7 @@ void emptyMapProbes() { assertFalse(EmbeddingSupport.remove(null, "x")); assertFalse(EmbeddingSupport.containsKey(null, "x")); assertFalse(EmbeddingSupport.containsValue(null, "x")); - assertEquals(EmbeddingSupport.NOT_FOUND, EmbeddingSupport.findSlot(null, "x")); + assertEquals(EmbeddingSupport.SLOT_NOT_FOUND, EmbeddingSupport.findSlot(null, "x")); } @Test @@ -406,9 +406,13 @@ void keyAtValueAtAndPresence() { } @Test - void findSlotMissReturnsMinusOneNotSentinel() { + void findSlotReportsSameMissSentinelForNullMapAndAbsentKey() { + // findSlot follows the String.indexOf idiom: every miss returns SLOT_NOT_FOUND, whether the + // map is null or simply lacks the key. The two cases must not diverge (they used to). Object[] data = EmbeddingSupport.set(8, null, "a", "A"); - assertEquals(-1, EmbeddingSupport.findSlot(data, "absent")); + assertEquals(EmbeddingSupport.SLOT_NOT_FOUND, EmbeddingSupport.findSlot(data, "absent")); + assertEquals(EmbeddingSupport.SLOT_NOT_FOUND, EmbeddingSupport.findSlot(null, "absent")); + assertEquals(-1, EmbeddingSupport.SLOT_NOT_FOUND); } @Test @@ -436,7 +440,8 @@ void insertAtUsingFoundInsertionSlot() { @Test void insertAtFromNullBuildsNewMap() { - Object[] data = EmbeddingSupport.insertAt(4, null, EmbeddingSupport.NO_SPACE, "a", "A"); + Object[] data = + EmbeddingSupport.insertAt(4, null, EmbeddingSupport.SLOT_CAPACITY_REACHED, "a", "A"); assertEquals("A", EmbeddingSupport.get(data, "a")); } From b8799fb4e31dbea1f3ff840b6697cba5d475d831 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 19:02:03 -0400 Subject: [PATCH 26/43] Tighten AdaptiveSizingHint concurrency doc: lost/stale updates, not torn Plain int reads/writes are atomic (JLS 17.7), so the racy tuning state can never expose a half-written value -- "torn" was the wrong hazard to name. The real races are lost updates and stale reads, both benign (they only mis-size a future array by a class, self-correcting on the next grow). Reword the class doc and the seedSlots() inline comment accordingly. Doc-only. Co-Authored-By: Claude Opus 4.8 --- .../main/java/datadog/trace/util/LightStringMap.java | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java index ffece7ee6f8..448803e322e 100644 --- a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java +++ b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java @@ -284,9 +284,12 @@ Object[] dataForTesting() { * #recordSlots(int)}) -- so it is tier-agnostic: the same hint can drive both this object and the * static {@link EmbeddingSupport} spine. * - *

Tuning is racy by design: {@code slots}/{@code constructs} are plain ints, so a lost or torn - * update only mis-sizes a future array (over/under-provision) for an instance or two, never - * corrupts map data -- no synchronization. + *

Tuning is racy by design and needs no synchronization: {@code slots}/{@code constructs} are + * plain ints, whose reads and writes are atomic (JLS 17.7), so a reader never sees a half-written + * value. The only races are a lost update (an interleaved increment or grow dropped) and a stale + * read (a write not yet visible to another thread); either just mis-sizes a future array by a + * class (over/under-provision) for an instance or two, which the next grow corrects. Map data is + * never touched by this state, so there is no corruption path. */ public static final class AdaptiveSizingHint { // Learned seed capacity in slots (always a power of two). Additive-increase on grow (with one @@ -320,7 +323,7 @@ int maxSlots() { * back. */ int seedSlots() { - int n = ++this.constructs; // racy; a torn read only jitters the decay cadence + int n = ++this.constructs; // racy; a lost increment only jitters the decay cadence if ((n & (DECAY_INTERVAL - 1)) == 0) { int reduced = this.slots >> 1; this.slots = (reduced < MIN_HINT_SLOTS) ? MIN_HINT_SLOTS : reduced; From 09128e8d92489740a8a07174ede133a82002caa7 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 30 Jul 2026 08:04:47 -0400 Subject: [PATCH 27/43] Use a distinct RemovedTombstone sentinel type for deletion markers The deletion tombstone was a deliberately un-interned magic String (NUL bytes). Replace it with a private singleton class RemovedTombstone so it is unmistakable in a heap dump or debugger and can never collide with a real key of any type -- it is compared only by identity. Because the sentinel is no longer a String, every slot read that could land on a tombstone now reads Object and identity-checks REMOVED before any String cast (keyAt, containsKey, expandMapData, both forEach spine helpers, toInternalString, and the object-tier forEach). The str() cast helper is thus unused and removed, and isRemoved widens from String to Object (its callers all widen cleanly). Co-Authored-By: Claude Opus 4.8 --- .../datadog/trace/util/LightStringMap.java | 51 +++++++++++-------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java index 448803e322e..1a88898b669 100644 --- a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java +++ b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java @@ -261,9 +261,9 @@ public void forEach(@Nonnull BiConsumer consumer) { if (mapData == null) return; int numSlots = mapData.length >> 1; for (int slot = 0; slot < numSlots; slot++) { - String key = (String) mapData[slot]; + Object key = mapData[slot]; if (key == null || EmbeddingSupport.isRemoved(key)) continue; - consumer.accept(key, (V) mapData[slot + numSlots]); + consumer.accept((String) key, (V) mapData[slot + numSlots]); } } @@ -394,9 +394,21 @@ public static final class EmbeddingSupport { // catastrophically, only under genuine hashCode collisions. static final int MAX_SLOTS_PER_LIVE_ENTRY = 8; - // TODO: use of String constructor is deliberate, since this is - // an internal marker that we don't want intern-ed - static final String REMOVED = new String("\0D\0a\07\04\0\0d\00\0G"); + // The deletion tombstone. A dedicated singleton *type* rather than a magic String so it is + // unmistakable in a heap dump or debugger (a Tombstone instance, not a String of NUL bytes) and + // can never collide with a real key of any type. Compared only by identity (==). + private static final class RemovedTombstone { + static final RemovedTombstone INSTANCE = new RemovedTombstone(); + + private RemovedTombstone() {} + + @Override + public String toString() { + return "--REMOVED--"; + } + } + + static final Object REMOVED = RemovedTombstone.INSTANCE; @Nullable public static final Object[] EMPTY_DATA = null; @@ -437,7 +449,7 @@ public static boolean isPresent(int slotIndex) { return (slotIndex >= 0); } - public static boolean isRemoved(@Nonnull String key) { + public static boolean isRemoved(@Nullable Object key) { return (key == REMOVED); } @@ -446,8 +458,8 @@ public static String keyAt(@Nullable Object[] mapData, int slotIndex) { if (mapData == null) return null; if (slotIndex < 0) return null; - String key = str(mapData[slotIndex]); - return (key == REMOVED) ? null : key; + Object key = mapData[slotIndex]; + return (key == REMOVED) ? null : (String) key; } @SuppressWarnings("unchecked") @@ -469,12 +481,12 @@ public static final boolean containsKey(@Nullable Object[] mapData, @Nonnull Str // TODO: check whether fast literal search is worth it for (int slot = preferredSlot; slot < numSlots; ++slot) { - String curKey = str(mapData[slot]); + Object curKey = mapData[slot]; if (curKey == null) return false; if (curKey != REMOVED && key.equals(curKey)) return true; } for (int slot = 0; slot < preferredSlot; ++slot) { - String curKey = str(mapData[slot]); + Object curKey = mapData[slot]; if (curKey == null) return false; if (curKey != REMOVED && key.equals(curKey)) return true; } @@ -783,11 +795,11 @@ public static Object[] expandMapData(@Nonnull Object[] origMapData, int newCapac Object[] newMapData = new Object[newSize]; for (int slot = 0; slot < origNumSlots; ++slot) { - String key = str(origMapData[slot]); + Object key = origMapData[slot]; if (key == null || key == REMOVED) continue; Object value = origMapData[slot + origNumSlots]; - newMapUncheckedInsert(newMapData, newCapacity, key, value); + newMapUncheckedInsert(newMapData, newCapacity, (String) key, value); } return newMapData; } @@ -798,11 +810,11 @@ public static void forEach( int numSlots = numSlots(mapData); for (int slot = 0; slot < numSlots; ++slot) { - String key = str(mapData[slot]); + Object key = mapData[slot]; if (key == null || key == REMOVED) continue; Object value = mapData[slot + numSlots]; - entryConsumer.accept(key, value); + entryConsumer.accept((String) key, value); } } @@ -814,11 +826,11 @@ public static void forEach( int numSlots = numSlots(mapData); for (int slot = 0; slot < numSlots; ++slot) { - String key = str(mapData[slot]); + Object key = mapData[slot]; if (key == null || key == REMOVED) continue; Object value = mapData[slot + numSlots]; - entryConsumer.accept(ctx, key, value); + entryConsumer.accept(ctx, (String) key, value); } } @@ -829,7 +841,7 @@ public static String toInternalString(@Nullable Object[] mapData) { int numSlots = numSlots(mapData); for (int slot = 0; slot < numSlots; ++slot) { - String key = str(mapData[slot]); + Object key = mapData[slot]; if (key == null) continue; builder.append('[').append(slot).append("]="); @@ -880,10 +892,5 @@ static int preferredSlot(int numSlots, int hash) { static int roundUpToPow2(int n) { return n <= 1 ? 1 : Integer.highestOneBit(n - 1) << 1; } - - @Nullable - static final String str(@Nullable Object key) { - return (String) key; - } } } From 379c2693ce315b36eff440dcf651f39ba1245145 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 30 Jul 2026 08:06:16 -0400 Subject: [PATCH 28/43] Add an identity fast path before equals() in findSlot / containsKey Check curKey == key before falling back to key.equals(curKey) in the lookup probes (findInsertionSlot already did this). Interned or reused keys -- the common case for this String-keyed map -- then resolve on a pointer compare with no virtual equals() call. Per the EqGuardBenchmark result the guard is a wash when equals() inlines and a win when it does not; that "does not" case is now reachable because keys erase to Object, so equals() is not guaranteed monomorphic across callers of the shared spine. A live key is never the REMOVED sentinel, so the identity hit needs no tombstone check. This retires the stale "not bothering with literal checks" / "check whether fast literal search is worth it" notes. Co-Authored-By: Claude Opus 4.8 --- .../datadog/trace/util/LightStringMap.java | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java index 1a88898b669..24871056c87 100644 --- a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java +++ b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java @@ -473,21 +473,22 @@ public static V valueAt(@Nullable Object[] mapData, int slotIndex) { public static final boolean containsKey(@Nullable Object[] mapData, @Nonnull String key) { if (mapData == null) return false; - // not bothering with optimizing literal checks int numSlots = numSlots(mapData); int hash = key.hashCode(); int preferredSlot = preferredSlot(numSlots, hash); - // TODO: check whether fast literal search is worth it + // Identity fast path before equals(), same rationale as findSlot. for (int slot = preferredSlot; slot < numSlots; ++slot) { Object curKey = mapData[slot]; if (curKey == null) return false; + if (curKey == key) return true; if (curKey != REMOVED && key.equals(curKey)) return true; } for (int slot = 0; slot < preferredSlot; ++slot) { Object curKey = mapData[slot]; if (curKey == null) return false; + if (curKey == key) return true; if (curKey != REMOVED && key.equals(curKey)) return true; } return false; @@ -747,18 +748,24 @@ static final int findSlot(@Nonnull Object[] mapData, int numSlots, @Nonnull Stri int hash = key.hashCode(); int preferredSlot = preferredSlot(numSlots, hash); - // Single equals-based probe that terminates at the first null slot. We do not - // assume interned keys, so there is no separate ref-equality pre-pass: String.equals - // already short-circuits on `this == other`, so a literal-key hit still resolves on a - // pointer compare, while a miss touches only the probe chain (not the whole array). + // A single probe that terminates at the first null slot. Each live slot is checked by + // identity (curKey == key) before equals(): interned or reused keys -- the common case for a + // String-keyed map here -- hit without any virtual equals() call, and the guard is a wash + // when equals() inlines and a win when it does not (keys erase to Object, so equals() is not + // guaranteed monomorphic across callers of the shared spine). We compare key.equals(curKey), + // not curKey.equals(key), so the receiver stays the caller's key type and C2 can devirtualize + // it. This is a per-slot guard, not a whole-array pre-pass: a miss still touches only the + // probe chain. A live key is never REMOVED, so the identity hit needs no tombstone check. for (int keyIndex = preferredSlot; keyIndex < numSlots; ++keyIndex) { Object curKey = mapData[keyIndex]; if (curKey == null) return SLOT_NOT_FOUND; + if (curKey == key) return keyIndex; if (curKey != REMOVED && key.equals(curKey)) return keyIndex; } for (int keyIndex = 0; keyIndex < preferredSlot; ++keyIndex) { Object curKey = mapData[keyIndex]; if (curKey == null) return SLOT_NOT_FOUND; + if (curKey == key) return keyIndex; if (curKey != REMOVED && key.equals(curKey)) return keyIndex; } return SLOT_NOT_FOUND; From 69a7e2e62816172c08d7f3c0faf75b178c88ffca Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 30 Jul 2026 08:46:04 -0400 Subject: [PATCH 29/43] Parameterize LightStringMap on key type () The String key specialization only ever bought the interned-literal reference-equality fast path, which has already been removed. Since keys and values already erase to Object through the single backing array, generalizing to costs almost nothing and widens the applicable use cases. The object-tier facade carries the types for front-door safety; the EmbeddingSupport spine takes Object keys, since it is the type-agnostic sharp-edges tier. Spine forEach consumers are typed ? super K / ? super V. Co-Authored-By: Claude Opus 4.8 --- .../datadog/trace/util/LightStringMap.java | 79 ++++++++-------- .../trace/util/LightStringMapTest.java | 89 ++++++++++--------- 2 files changed, 86 insertions(+), 82 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java index 24871056c87..e2c9e3e0428 100644 --- a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java +++ b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java @@ -8,12 +8,13 @@ import javax.annotation.Nullable; /** - * A lightweight {@link String}-keyed map, designed to be small and fast for tiny maps. + * A lightweight map, keyed by any type with a stable {@code hashCode}/{@code equals} (typically + * {@link String}), designed to be small and fast for tiny maps. * *

Supports the common map operations -- {@code set}, {@code get}, {@code remove}, {@code * containsKey}, and {@code forEach} -- as an easy, largely footgun-free stand-in wherever a small - * {@code Map} is needed. It deliberately does not implement {@link - * java.util.Map}; the surface is intentionally small. + * {@code Map} is needed. It deliberately does not implement {@link java.util.Map}; + * the surface is intentionally small. * *

Neither null keys nor null values are supported: {@code set} rejects a null value, so a null * {@code get} result unambiguously means "absent". Use {@link #containsKey} if you need to probe @@ -38,7 +39,7 @@ * In the event of resizing, removal markers are discarded while assigning * slots in the new data array. */ -public final class LightStringMap { +public final class LightStringMap { public static final int DEFAULT_CAPACITY = 8; // Slots a fresh (un-tuned) hint seeds -- a reasonable default so a cold site behaves like a @@ -80,7 +81,7 @@ private LightStringMap(@Nonnull AdaptiveSizingHint hint) { /** A new, uncapped map seeded at the default capacity. The "just give me a map" front door. */ @Nonnull - public static LightStringMap createUncapped() { + public static LightStringMap createUncapped() { return new LightStringMap<>(DEFAULT_CAPACITY, NO_MAX_SLOTS); } @@ -90,7 +91,7 @@ public static LightStringMap createUncapped() { * a {@link #adaptiveSizingHint()}. */ @Nonnull - public static LightStringMap createUncapped(int capacity) { + public static LightStringMap createUncapped(int capacity) { return new LightStringMap<>(capacity, NO_MAX_SLOTS); } @@ -101,7 +102,7 @@ public static LightStringMap createUncapped(int capacity) { * thought-free way to bound worst-case memory without minting an {@link AdaptiveSizingHint}. */ @Nonnull - public static LightStringMap createCapped(int maxCapacity) { + public static LightStringMap createCapped(int maxCapacity) { int max = EmbeddingSupport.roundUpToPow2(maxCapacity); int seed = Math.min(DEFAULT_CAPACITY, max); return new LightStringMap<>(seed, max); @@ -113,7 +114,7 @@ public static LightStringMap createCapped(int maxCapacity) { * seed. Throws {@link IllegalArgumentException} if the rounded seed exceeds the rounded cap. */ @Nonnull - public static LightStringMap createCapped(int initialCapacity, int maxCapacity) { + public static LightStringMap createCapped(int initialCapacity, int maxCapacity) { int seed = EmbeddingSupport.roundUpToPow2(initialCapacity); int max = EmbeddingSupport.roundUpToPow2(maxCapacity); if (seed > max) { @@ -130,7 +131,7 @@ public static LightStringMap createCapped(int initialCapacity, int maxCap * at that site. */ @Nonnull - public static LightStringMap create(@Nonnull AdaptiveSizingHint hint) { + public static LightStringMap create(@Nonnull AdaptiveSizingHint hint) { return new LightStringMap<>(hint); } @@ -207,7 +208,7 @@ public AdaptiveSizingHint build() { * rejected rather than growing past the cap. The rejection is non-fatal -- the map is unchanged * and the caller may ignore the return. An uncapped map always returns {@code true}. */ - public boolean set(@Nonnull String key, @Nonnull V value) { + public boolean set(@Nonnull K key, @Nonnull V value) { // Null-value rejection is enforced centrally in EmbeddingSupport.setOrReject (the shared insert // core), so it holds for the spine entry points too -- not just this object-tier front door. // A thin delegate over the shared spine orchestration: it passes this map's cap (NO_MAX_SLOTS @@ -238,11 +239,11 @@ private void recordGrowth(int beforeSlots, @Nullable Object[] after) { } @Nullable - public V get(@Nonnull String key) { + public V get(@Nonnull K key) { return EmbeddingSupport.get(this.data, key); } - public void remove(@Nonnull String key) { + public void remove(@Nonnull K key) { EmbeddingSupport.remove(this.data, key); } @@ -251,19 +252,19 @@ public int size() { return EmbeddingSupport.size(this.data); } - public boolean containsKey(@Nonnull String key) { + public boolean containsKey(@Nonnull K key) { return EmbeddingSupport.containsKey(this.data, key); } @SuppressWarnings("unchecked") - public void forEach(@Nonnull BiConsumer consumer) { + public void forEach(@Nonnull BiConsumer consumer) { Object[] mapData = this.data; if (mapData == null) return; int numSlots = mapData.length >> 1; for (int slot = 0; slot < numSlots; slot++) { Object key = mapData[slot]; if (key == null || EmbeddingSupport.isRemoved(key)) continue; - consumer.accept((String) key, (V) mapData[slot + numSlots]); + consumer.accept((K) key, (V) mapData[slot + numSlots]); } } @@ -454,12 +455,12 @@ public static boolean isRemoved(@Nullable Object key) { } @Nullable - public static String keyAt(@Nullable Object[] mapData, int slotIndex) { + public static Object keyAt(@Nullable Object[] mapData, int slotIndex) { if (mapData == null) return null; if (slotIndex < 0) return null; Object key = mapData[slotIndex]; - return (key == REMOVED) ? null : (String) key; + return (key == REMOVED) ? null : key; } @SuppressWarnings("unchecked") @@ -470,7 +471,7 @@ public static V valueAt(@Nullable Object[] mapData, int slotIndex) { return (V) mapData[slotIndex + numSlots(mapData)]; } - public static final boolean containsKey(@Nullable Object[] mapData, @Nonnull String key) { + public static final boolean containsKey(@Nullable Object[] mapData, @Nonnull Object key) { if (mapData == null) return false; int numSlots = numSlots(mapData); @@ -506,13 +507,13 @@ public static final boolean containsValue(@Nullable Object[] mapData, @Nonnull O @Nonnull public static Object[] set( - @Nullable Object[] mapData, @Nonnull String key, @Nonnull V value) { + @Nullable Object[] mapData, @Nonnull Object key, @Nonnull V value) { return set(DEFAULT_CAPACITY, mapData, key, value); } @Nonnull public static Object[] set( - int initialCapacity, @Nullable Object[] mapData, @Nonnull String key, @Nonnull V value) { + int initialCapacity, @Nullable Object[] mapData, @Nonnull Object key, @Nonnull V value) { // Uncapped: NO_MAX_SLOTS makes the cap check inert, so setOrReject grows on demand and never // rejects -- the result is always non-null. return setOrReject(initialCapacity, NO_MAX_SLOTS, mapData, key, value); @@ -534,7 +535,7 @@ public static Object[] set( public static Object[] set( @Nonnull AdaptiveSizingHint hint, @Nullable Object[] mapData, - @Nonnull String key, + @Nonnull Object key, @Nonnull V value) { int beforeSlots = numSlots(mapData); // Seed a fresh table from the hint (mirrors LightStringMap(hint)); initialCapacity is only @@ -565,7 +566,7 @@ static Object[] setOrReject( int initialCapacity, int maxSlots, @Nullable Object[] mapData, - @Nonnull String key, + @Nonnull Object key, @Nonnull V value) { // The map contract forbids null values (a null get() unambiguously means "absent"), so reject // one here -- the single chokepoint every set path (object tier and spine) flows through. @@ -619,7 +620,7 @@ static Object[] setOrReject( @SuppressWarnings("unchecked") @Nullable - public static V get(@Nullable Object[] mapData, @Nonnull String key) { + public static V get(@Nullable Object[] mapData, @Nonnull Object key) { if (mapData == null) return null; int numSlots = numSlots(mapData); @@ -628,7 +629,7 @@ public static V get(@Nullable Object[] mapData, @Nonnull String key) { return (foundIndex >= 0) ? (V) mapData[numSlots + foundIndex] : null; } - public static boolean remove(@Nullable Object[] mapData, @Nonnull String key) { + public static boolean remove(@Nullable Object[] mapData, @Nonnull Object key) { if (mapData == null) return false; int numSlots = numSlots(mapData); @@ -643,7 +644,7 @@ public static boolean remove(@Nullable Object[] mapData, @Nonnull String key) { } } - public static int findInsertionSlot(@Nullable Object[] mapData, @Nonnull String key) { + public static int findInsertionSlot(@Nullable Object[] mapData, @Nonnull Object key) { if (mapData == null) return SLOT_CAPACITY_REACHED; return findInsertionSlot(mapData, numSlots(mapData), key); @@ -654,7 +655,7 @@ public static final Object[] insertAt( int initialCapacity, @Nullable Object[] mapData, int insertionSlot, - @Nonnull String key, + @Nonnull Object key, @Nonnull Object value) { // Same null-value invariant as setOrReject; insertAt is a separate spine write path that does // not flow through it, so it needs its own guard. @@ -677,12 +678,12 @@ public static final Object[] insertAt( } static final int findInsertionSlot( - @Nonnull Object[] mapData, int numSlots, @Nonnull String key) { + @Nonnull Object[] mapData, int numSlots, @Nonnull Object key) { return findInsertionSlot(mapData, numSlots, key, preferredSlot(numSlots, key.hashCode())); } static final int findInsertionSlot( - @Nonnull Object[] mapData, int numSlots, @Nonnull String key, int preferredSlot) { + @Nonnull Object[] mapData, int numSlots, @Nonnull Object key, int preferredSlot) { int availableIndex = SLOT_CAPACITY_REACHED; for (int keyIndex = preferredSlot; keyIndex < numSlots; ++keyIndex) { Object curKey = mapData[keyIndex]; @@ -738,13 +739,13 @@ public static final Object getAndRemoveAt(@Nullable Object[] mapData, int slot) return prev; } - public static final int findSlot(@Nullable Object[] mapData, @Nonnull String key) { + public static final int findSlot(@Nullable Object[] mapData, @Nonnull Object key) { if (mapData == null) return SLOT_NOT_FOUND; return findSlot(mapData, numSlots(mapData), key); } - static final int findSlot(@Nonnull Object[] mapData, int numSlots, @Nonnull String key) { + static final int findSlot(@Nonnull Object[] mapData, int numSlots, @Nonnull Object key) { int hash = key.hashCode(); int preferredSlot = preferredSlot(numSlots, hash); @@ -773,7 +774,7 @@ static final int findSlot(@Nonnull Object[] mapData, int numSlots, @Nonnull Stri @Nonnull static final Object[] newMapData( - int initialCapacity, @Nonnull String key, @Nonnull Object value) { + int initialCapacity, @Nonnull Object key, @Nonnull Object value) { int numSlots = roundUpToPow2(initialCapacity); Object[] mapData = new Object[numSlots << 1]; @@ -811,8 +812,9 @@ public static Object[] expandMapData(@Nonnull Object[] origMapData, int newCapac return newMapData; } - public static void forEach( - @Nullable Object[] mapData, @Nonnull BiConsumer entryConsumer) { + @SuppressWarnings("unchecked") + public static void forEach( + @Nullable Object[] mapData, @Nonnull BiConsumer entryConsumer) { if (mapData == null) return; int numSlots = numSlots(mapData); @@ -821,14 +823,15 @@ public static void forEach( if (key == null || key == REMOVED) continue; Object value = mapData[slot + numSlots]; - entryConsumer.accept((String) key, value); + entryConsumer.accept((K) key, (V) value); } } - public static void forEach( + @SuppressWarnings("unchecked") + public static void forEach( @Nullable Object[] mapData, @Nullable C ctx, - @Nonnull TriConsumer entryConsumer) { + @Nonnull TriConsumer entryConsumer) { if (mapData == null) return; int numSlots = numSlots(mapData); @@ -837,7 +840,7 @@ public static void forEach( if (key == null || key == REMOVED) continue; Object value = mapData[slot + numSlots]; - entryConsumer.accept(ctx, (String) key, value); + entryConsumer.accept(ctx, (K) key, (V) value); } } @@ -866,7 +869,7 @@ public static String toInternalString(@Nullable Object[] mapData) { } static void newMapUncheckedInsert( - @Nonnull Object[] mapData, int numSlots, @Nonnull String key, @Nonnull Object value) { + @Nonnull Object[] mapData, int numSlots, @Nonnull Object key, @Nonnull Object value) { int hash = key.hashCode(); int preferredSlot = preferredSlot(numSlots, hash); diff --git a/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java b/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java index 1af841f4265..81ed66ef47f 100644 --- a/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java +++ b/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java @@ -24,13 +24,14 @@ class InstanceTests { @Test void getOnEmptyMapReturnsNull() { - LightStringMap map = LightStringMap.createUncapped(LightStringMap.DEFAULT_CAPACITY); + LightStringMap map = + LightStringMap.createUncapped(LightStringMap.DEFAULT_CAPACITY); assertNull(map.get("absent")); } @Test void setThenGet() { - LightStringMap map = LightStringMap.createUncapped(8); + LightStringMap map = LightStringMap.createUncapped(8); map.set("a", 1); map.set("b", 2); assertEquals(1, map.get("a")); @@ -40,7 +41,7 @@ void setThenGet() { @Test void setOverwritesExistingKey() { - LightStringMap map = LightStringMap.createUncapped(8); + LightStringMap map = LightStringMap.createUncapped(8); map.set("a", 1); map.set("a", 42); assertEquals(42, map.get("a")); @@ -48,7 +49,7 @@ void setOverwritesExistingKey() { @Test void removeMakesKeyAbsent() { - LightStringMap map = LightStringMap.createUncapped(8); + LightStringMap map = LightStringMap.createUncapped(8); map.set("a", 1); map.set("b", 2); map.remove("a"); @@ -58,7 +59,7 @@ void removeMakesKeyAbsent() { @Test void containsKeyDistinguishesPresenceFromAbsence() { - LightStringMap map = LightStringMap.createUncapped(8); + LightStringMap map = LightStringMap.createUncapped(8); assertFalse(map.containsKey("a")); map.set("a", 1); assertTrue(map.containsKey("a")); @@ -69,14 +70,14 @@ void containsKeyDistinguishesPresenceFromAbsence() { @Test void setRejectsNullValue() { - LightStringMap map = LightStringMap.createUncapped(8); + LightStringMap map = LightStringMap.createUncapped(8); assertThrows(NullPointerException.class, () -> map.set("a", null)); assertFalse(map.containsKey("a")); } @Test void sizeTracksLiveEntries() { - LightStringMap map = LightStringMap.createUncapped(8); + LightStringMap map = LightStringMap.createUncapped(8); assertEquals(0, map.size()); map.set("a", 1); map.set("b", 2); @@ -95,7 +96,7 @@ void tinyMapGrowsOnlyWhenPhysicallyFull() { // can never travel 8 slots there -- so a seed-8 map fills all 8 slots before it grows, // exactly as it did before the trigger existed. This is the property that keeps the tiny, // miss-dominated consumer (springweb6 localAttributes) behaviorally unchanged. - LightStringMap map = LightStringMap.createUncapped(8); + LightStringMap map = LightStringMap.createUncapped(8); for (int i = 0; i < 8; i++) { map.set("k" + i, i); } @@ -117,12 +118,12 @@ void clusteredKeysGrowEarlierThanWellSpreadKeys() { // well before the table is anywhere near full. Both sets stay fully retrievable. int count = 32; - LightStringMap spread = LightStringMap.createUncapped(8); + LightStringMap spread = LightStringMap.createUncapped(8); for (int i = 0; i < count; i++) { spread.set("spread." + i, i); } - LightStringMap clustered = LightStringMap.createUncapped(8); + LightStringMap clustered = LightStringMap.createUncapped(8); List colliding = collidingKeys(count); for (int i = 0; i < count; i++) { clustered.set(colliding.get(i), i); @@ -154,7 +155,7 @@ void collidingHashCodesDoNotExplodeMemory() { assertEquals(sharedHash, key.hashCode(), "fixture keys must share one hashCode: " + key); } - LightStringMap map = LightStringMap.createUncapped(8); + LightStringMap map = LightStringMap.createUncapped(8); for (int i = 0; i < keys.size(); i++) { map.set(keys.get(i), i); } @@ -177,7 +178,7 @@ void insertReclaimsEarlierTombstoneInsteadOfExtendingChain() { // rather than walk past it to a fresh null -- keeping the chain short and avoiding a needless // grow. List colliding = collidingKeys(5); - LightStringMap map = LightStringMap.createUncapped(16); + LightStringMap map = LightStringMap.createUncapped(16); for (int i = 0; i < 4; i++) { map.set(colliding.get(i), i); } @@ -202,7 +203,7 @@ void insertReclaimsEarlierTombstoneInsteadOfExtendingChain() { @Test void growsAndPreservesAllEntries() { // initial capacity 2 forces several resizes as we insert well past it. - LightStringMap map = LightStringMap.createUncapped(2); + LightStringMap map = LightStringMap.createUncapped(2); int n = 100; for (int i = 0; i < n; i++) { map.set("key-" + i, i); @@ -214,7 +215,7 @@ void growsAndPreservesAllEntries() { @Test void forEachVisitsEveryLiveEntry() { - LightStringMap map = LightStringMap.createUncapped(4); + LightStringMap map = LightStringMap.createUncapped(4); for (int i = 0; i < 20; i++) { map.set("k" + i, i); } @@ -232,7 +233,7 @@ void forEachVisitsEveryLiveEntry() { @Test void nonLiteralKeyResolvesViaEqualsFallback() { - LightStringMap map = LightStringMap.createUncapped(8); + LightStringMap map = LightStringMap.createUncapped(8); map.set("hello", 1); // Distinct String instance with the same content -- must be found via the equals pass. String lookup = new String("hello"); @@ -282,7 +283,7 @@ private int slotOf(Object[] data, int numSlots, String key) { @Test void removeThenReinsertSameKey() { - LightStringMap map = LightStringMap.createUncapped(4); + LightStringMap map = LightStringMap.createUncapped(4); for (int i = 0; i < 4; i++) { map.set("k" + i, i); } @@ -465,7 +466,7 @@ void forEachStaticWithContext() { for (int i = 0; i < 4; i++) { data = EmbeddingSupport.set(4, data, "k" + i, i); } - Map ctx = new HashMap<>(); + Map ctx = new HashMap<>(); EmbeddingSupport.forEach(data, ctx, (c, k, v) -> c.put(k, v)); assertEquals(4, ctx.size()); assertEquals(2, ctx.get("k2")); @@ -526,7 +527,7 @@ void freshHintSeedsAtDefault() { @Test void hintSeedsAFreshMapAtItsLearnedCapacity() { LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHint(); - LightStringMap map = LightStringMap.create(hint); + LightStringMap map = LightStringMap.create(hint); // A hint-seeded map allocates its backing array lazily, sized to the hint. map.set("a", 1); Object[] data = map.dataForTesting(); @@ -538,7 +539,7 @@ void growthRaisesSeedWithOneClassOfHeadroom() { LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHint(); // Fill a hint-seeded map past its seed so it grows; the hint should learn the new size // PLUS one power-of-two class of headroom (so the steady-state load factor stays <= 0.5). - LightStringMap map = LightStringMap.create(hint); + LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < LightStringMap.DEFAULT_HINT_SLOTS + 1; i++) { map.set("k" + i, i); } @@ -550,14 +551,14 @@ void growthRaisesSeedWithOneClassOfHeadroom() { void seedIsMonotonicMaxAcrossMaps() { LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHint(); // A big map ratchets the hint up. - LightStringMap big = LightStringMap.create(hint); + LightStringMap big = LightStringMap.create(hint); for (int i = 0; i < 20; i++) { big.set("k" + i, i); } int learned = hint.currentSeedSlots(); assertTrue(learned > LightStringMap.DEFAULT_HINT_SLOTS); // A subsequent tiny map does not lower the learned seed. - LightStringMap small = LightStringMap.create(hint); + LightStringMap small = LightStringMap.create(hint); small.set("a", 1); assertEquals(learned, hint.currentSeedSlots()); } @@ -566,14 +567,14 @@ void seedIsMonotonicMaxAcrossMaps() { void decayStepsSeedDownAfterInterval() { LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHint(); // Ratchet the hint above the default so a step-down is observable. - LightStringMap big = LightStringMap.create(hint); + LightStringMap big = LightStringMap.create(hint); for (int i = 0; i < 20; i++) { big.set("k" + i, i); } int learned = hint.currentSeedSlots(); // One full decay interval of constructions steps the seed down exactly one class. for (int i = 0; i < LightStringMap.DECAY_INTERVAL; i++) { - LightStringMap.create(hint); + LightStringMap.create(hint); } assertEquals(learned / 2, hint.currentSeedSlots()); } @@ -584,7 +585,7 @@ void decayFloorsAtMinimum() { // Enough decay intervals to drive an un-ratcheted hint to the floor and hold there. int intervals = 32; for (int i = 0; i < intervals * LightStringMap.DECAY_INTERVAL; i++) { - LightStringMap.create(hint); + LightStringMap.create(hint); } assertEquals(LightStringMap.MIN_HINT_SLOTS, hint.currentSeedSlots()); } @@ -593,7 +594,7 @@ void decayFloorsAtMinimum() { void seedIsCappedAtMax() { LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHint(); // A very large map cannot push the learned seed past the pre-provisioning ceiling. - LightStringMap big = LightStringMap.create(hint); + LightStringMap big = LightStringMap.create(hint); for (int i = 0; i < LightStringMap.MAX_HINT_SLOTS * 4; i++) { big.set("k" + i, i); } @@ -606,7 +607,7 @@ void oneDecayStepStaysSafeThenSecondDecayRepins() { LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHint(); // Learn a large size. With one class of headroom, `learned` is 2x the array the workload // physically grew into. - LightStringMap big = LightStringMap.create(hint); + LightStringMap big = LightStringMap.create(hint); for (int i = 0; i < 20; i++) { big.set("k" + i, i); } @@ -615,10 +616,10 @@ void oneDecayStepStaysSafeThenSecondDecayRepins() { // First decay lands the seed exactly on the physical high-water: the same workload now fits // without regrowing, so the seed holds (the headroom step-down is "free"). for (int i = 0; i < LightStringMap.DECAY_INTERVAL; i++) { - LightStringMap.create(hint); + LightStringMap.create(hint); } assertEquals(learned / 2, hint.currentSeedSlots()); - LightStringMap stillFits = LightStringMap.create(hint); + LightStringMap stillFits = LightStringMap.create(hint); for (int i = 0; i < 20; i++) { stillFits.set("k" + i, i); } @@ -626,10 +627,10 @@ void oneDecayStepStaysSafeThenSecondDecayRepins() { // Second decay probes below the need: the workload now regrows and snaps the seed back up. for (int i = 0; i < LightStringMap.DECAY_INTERVAL; i++) { - LightStringMap.create(hint); + LightStringMap.create(hint); } assertEquals(learned / 4, hint.currentSeedSlots()); - LightStringMap recovered = LightStringMap.create(hint); + LightStringMap recovered = LightStringMap.create(hint); for (int i = 0; i < 20; i++) { recovered.set("k" + i, i); } @@ -639,7 +640,7 @@ void oneDecayStepStaysSafeThenSecondDecayRepins() { @Test void hintSeededMapStoresAndReadsBackCorrectly() { LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHint(); - LightStringMap map = LightStringMap.create(hint); + LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 50; i++) { map.set("k" + i, i); } @@ -721,7 +722,7 @@ class CapTests { void uncappedMapAlwaysReturnsTrueFromSet() { // The plain capacity constructor is uncapped: set stores unconditionally and never rejects, // even well past the initial capacity. - LightStringMap map = LightStringMap.createUncapped(2); + LightStringMap map = LightStringMap.createUncapped(2); for (int i = 0; i < 100; i++) { assertTrue(map.set("k" + i, i), "uncapped set should always store"); } @@ -734,7 +735,7 @@ void hintWithoutMaxCapacityIsUncapped() { // adaptiveSizingHint(): no cap, so // set always stores. LightStringMap.AdaptiveSizingHint hint = LightStringMap.buildAdaptiveSizingHint().build(); - LightStringMap map = LightStringMap.create(hint); + LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 100; i++) { assertTrue(map.set("k" + i, i)); } @@ -748,7 +749,7 @@ void cappedSetStoresUntilPhysicallyFullThenRejects() { // genuinely new key cannot grow past the cap, so set rejects it and leaves the map unchanged. LightStringMap.AdaptiveSizingHint hint = LightStringMap.buildAdaptiveSizingHint().maxCapacity(8).build(); - LightStringMap map = LightStringMap.create(hint); + LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 8; i++) { assertTrue(map.set("k" + i, i), "slot " + i + " should store"); } @@ -770,7 +771,7 @@ void cappedSetOverwritesExistingKeyEvenWhenFull() { // full at the cap still succeeds -- no new slot is needed. LightStringMap.AdaptiveSizingHint hint = LightStringMap.buildAdaptiveSizingHint().maxCapacity(8).build(); - LightStringMap map = LightStringMap.create(hint); + LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 8; i++) { map.set("k" + i, i); } @@ -785,7 +786,7 @@ void cappedMapGrowsUpToTheCap() { // power-of-two classes up to the cap, retaining every entry along the way. LightStringMap.AdaptiveSizingHint hint = LightStringMap.buildAdaptiveSizingHint().initCapacity(2).maxCapacity(16).build(); - LightStringMap map = LightStringMap.create(hint); + LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 16; i++) { assertTrue(map.set("k" + i, i), "slot " + i + " should store below the cap"); } @@ -804,7 +805,7 @@ void afterRejectionRemovingThenReinsertingSucceeds() { // new key succeeds via the reclaimed tombstone. LightStringMap.AdaptiveSizingHint hint = LightStringMap.buildAdaptiveSizingHint().maxCapacity(8).build(); - LightStringMap map = LightStringMap.create(hint); + LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 8; i++) { map.set("k" + i, i); } @@ -819,7 +820,7 @@ void maxCapacityRoundsUpToPowerOfTwo() { // A non-power-of-two cap rounds up: maxCapacity(5) becomes an 8-slot cap. LightStringMap.AdaptiveSizingHint hint = LightStringMap.buildAdaptiveSizingHint().maxCapacity(5).build(); - LightStringMap map = LightStringMap.create(hint); + LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 8; i++) { assertTrue(map.set("k" + i, i)); } @@ -840,7 +841,7 @@ void cappedHintNeverSeedsAMapLargerThanTheCap() { // normally reserves a class of headroom) cannot push the seed past maxSlots. LightStringMap.AdaptiveSizingHint hint = LightStringMap.buildAdaptiveSizingHint().initCapacity(2).maxCapacity(16).build(); - LightStringMap map = LightStringMap.create(hint); + LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 16; i++) { map.set("k" + i, i); } @@ -859,7 +860,7 @@ class CreateCappedTests { void createCappedRejectsNewKeyOncePhysicallyFullAtCap() { // createCapped(8) hard-bounds the table at 8 slots with no hint: eight distinct keys fill it, // a ninth genuinely new key is rejected and leaves the map unchanged. - LightStringMap map = LightStringMap.createCapped(8); + LightStringMap map = LightStringMap.createCapped(8); for (int i = 0; i < 8; i++) { assertTrue(map.set("k" + i, i), "slot " + i + " should store"); } @@ -874,7 +875,7 @@ void createCappedRejectsNewKeyOncePhysicallyFullAtCap() { @Test void createCappedRoundsCapUpToPowerOfTwo() { // A non-power-of-two cap rounds up: createCapped(5) becomes an 8-slot cap. - LightStringMap map = LightStringMap.createCapped(5); + LightStringMap map = LightStringMap.createCapped(5); for (int i = 0; i < 8; i++) { assertTrue(map.set("k" + i, i)); } @@ -885,7 +886,7 @@ void createCappedRoundsCapUpToPowerOfTwo() { void createCappedSeedClampedToCapSmallerThanDefault() { // With a cap below the default seed, the seed is clamped down to the cap: createCapped(4) // seeds and caps at 4 slots, so the fifth new key is rejected. - LightStringMap map = LightStringMap.createCapped(4); + LightStringMap map = LightStringMap.createCapped(4); for (int i = 0; i < 4; i++) { assertTrue(map.set("k" + i, i)); } @@ -897,7 +898,7 @@ void createCappedSeedClampedToCapSmallerThanDefault() { void createCappedTwoArgSeedsSmallAndGrowsToTheCap() { // createCapped(2, 16) seeds at 2 slots and grows through the power-of-two classes up to the // 16-slot cap, retaining every entry; the 17th new key is rejected. - LightStringMap map = LightStringMap.createCapped(2, 16); + LightStringMap map = LightStringMap.createCapped(2, 16); for (int i = 0; i < 16; i++) { assertTrue(map.set("k" + i, i), "slot " + i + " should store below the cap"); } @@ -911,7 +912,7 @@ void createCappedTwoArgSeedsSmallAndGrowsToTheCap() { @Test void createCappedTwoArgRoundsBothToPowerOfTwo() { // Both arguments round up independently: createCapped(3, 5) seeds at 4 slots, caps at 8. - LightStringMap map = LightStringMap.createCapped(3, 5); + LightStringMap map = LightStringMap.createCapped(3, 5); for (int i = 0; i < 8; i++) { assertTrue(map.set("k" + i, i)); } From 168eb2115db99a6a4a42bd518dfa5a232f51ec9e Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 30 Jul 2026 08:46:57 -0400 Subject: [PATCH 30/43] Rename buildAdaptiveSizingHint to adaptiveSizingHintBuilder Noun-returns-builder naming, consistent with TagMap.ledger and the future SpanPrototype.builder. Co-Authored-By: Claude Opus 4.8 --- .../datadog/trace/util/LightStringMap.java | 6 ++--- .../trace/util/LightStringMapTest.java | 22 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java index e2c9e3e0428..769a2a73b53 100644 --- a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java +++ b/internal-api/src/main/java/datadog/trace/util/LightStringMap.java @@ -127,7 +127,7 @@ public static LightStringMap createCapped(int initialCapacity, int /** * A new map sized (and, if the hint carries a {@code maxCapacity}, capped) from {@code hint}. * Mint the hint once per construction site via {@link #adaptiveSizingHint()} or {@link - * #buildAdaptiveSizingHint()}, hold it in a {@code static final} field, and pass it to every map + * #adaptiveSizingHintBuilder()}, hold it in a {@code static final} field, and pass it to every map * at that site. */ @Nonnull @@ -154,7 +154,7 @@ public static AdaptiveSizingHint adaptiveSizingHint() { * cap. The hint still self-tunes its seed capacity within the cap. */ @Nonnull - public static AdaptiveSizingHintBuilder buildAdaptiveSizingHint() { + public static AdaptiveSizingHintBuilder adaptiveSizingHintBuilder() { return new AdaptiveSizingHintBuilder(); } @@ -203,7 +203,7 @@ public AdaptiveSizingHint build() { * trigger fires. Returns {@code true} if the mapping was stored (or overwrote an existing one). * *

Returns {@code false} only for a capped map (one built via {@link #createCapped(int)} / - * {@link #createCapped(int, int)}, or from a {@link #buildAdaptiveSizingHint()}.{@code + * {@link #createCapped(int, int)}, or from a {@link #adaptiveSizingHintBuilder()}.{@code * maxCapacity(...)} hint): once the table is physically full at its cap, a genuinely new key is * rejected rather than growing past the cap. The rejection is non-fatal -- the map is unchanged * and the caller may ignore the return. An uncapped map always returns {@code true}. diff --git a/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java b/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java index 81ed66ef47f..98440bd40fd 100644 --- a/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java +++ b/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java @@ -660,7 +660,7 @@ void spineSetSeedsAFreshTableFromTheHint() { // Dropping to the spine keeps the hint's sizing: a fresh table seeds from seedSlots(), just // like LightStringMap.create(hint) does through the object tier. LightStringMap.AdaptiveSizingHint hint = - LightStringMap.buildAdaptiveSizingHint().initCapacity(4).build(); + LightStringMap.adaptiveSizingHintBuilder().initCapacity(4).build(); Object[] data = EmbeddingSupport.set(hint, null, "a", 1); assertEquals(4, EmbeddingSupport.numSlots(data)); assertEquals(1, (Object) EmbeddingSupport.get(data, "a")); @@ -686,7 +686,7 @@ void spineSetDoesNotEnforceTheHintCap() { // so a capped hint used at the spine grows past its cap and always stores. (Contrast the // object tier, which rejects at the cap -- see CapTests.) LightStringMap.AdaptiveSizingHint hint = - LightStringMap.buildAdaptiveSizingHint().initCapacity(2).maxCapacity(4).build(); + LightStringMap.adaptiveSizingHintBuilder().initCapacity(2).maxCapacity(4).build(); Object[] data = null; for (int i = 0; i < 12; i++) { data = EmbeddingSupport.set(hint, data, "k" + i, i); @@ -731,10 +731,10 @@ void uncappedMapAlwaysReturnsTrueFromSet() { @Test void hintWithoutMaxCapacityIsUncapped() { - // buildAdaptiveSizingHint() with no maxCapacity behaves like the zero-config + // adaptiveSizingHintBuilder() with no maxCapacity behaves like the zero-config // adaptiveSizingHint(): no cap, so // set always stores. - LightStringMap.AdaptiveSizingHint hint = LightStringMap.buildAdaptiveSizingHint().build(); + LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHintBuilder().build(); LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 100; i++) { assertTrue(map.set("k" + i, i)); @@ -748,7 +748,7 @@ void cappedSetStoresUntilPhysicallyFullThenRejects() { // 8-slot table never trips the probe bound, so it fills before it would grow); a ninth, // genuinely new key cannot grow past the cap, so set rejects it and leaves the map unchanged. LightStringMap.AdaptiveSizingHint hint = - LightStringMap.buildAdaptiveSizingHint().maxCapacity(8).build(); + LightStringMap.adaptiveSizingHintBuilder().maxCapacity(8).build(); LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 8; i++) { assertTrue(map.set("k" + i, i), "slot " + i + " should store"); @@ -770,7 +770,7 @@ void cappedSetOverwritesExistingKeyEvenWhenFull() { // Rejection only applies to a genuinely new key. Overwriting a present key when the table is // full at the cap still succeeds -- no new slot is needed. LightStringMap.AdaptiveSizingHint hint = - LightStringMap.buildAdaptiveSizingHint().maxCapacity(8).build(); + LightStringMap.adaptiveSizingHintBuilder().maxCapacity(8).build(); LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 8; i++) { map.set("k" + i, i); @@ -785,7 +785,7 @@ void cappedMapGrowsUpToTheCap() { // A cap does not pin the seed size: a map started small still grows through the // power-of-two classes up to the cap, retaining every entry along the way. LightStringMap.AdaptiveSizingHint hint = - LightStringMap.buildAdaptiveSizingHint().initCapacity(2).maxCapacity(16).build(); + LightStringMap.adaptiveSizingHintBuilder().initCapacity(2).maxCapacity(16).build(); LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 16; i++) { assertTrue(map.set("k" + i, i), "slot " + i + " should store below the cap"); @@ -804,7 +804,7 @@ void afterRejectionRemovingThenReinsertingSucceeds() { // a // new key succeeds via the reclaimed tombstone. LightStringMap.AdaptiveSizingHint hint = - LightStringMap.buildAdaptiveSizingHint().maxCapacity(8).build(); + LightStringMap.adaptiveSizingHintBuilder().maxCapacity(8).build(); LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 8; i++) { map.set("k" + i, i); @@ -819,7 +819,7 @@ void afterRejectionRemovingThenReinsertingSucceeds() { void maxCapacityRoundsUpToPowerOfTwo() { // A non-power-of-two cap rounds up: maxCapacity(5) becomes an 8-slot cap. LightStringMap.AdaptiveSizingHint hint = - LightStringMap.buildAdaptiveSizingHint().maxCapacity(5).build(); + LightStringMap.adaptiveSizingHintBuilder().maxCapacity(5).build(); LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 8; i++) { assertTrue(map.set("k" + i, i)); @@ -831,7 +831,7 @@ void maxCapacityRoundsUpToPowerOfTwo() { void buildThrowsWhenInitCapacityExceedsMaxCapacity() { assertThrows( IllegalArgumentException.class, - () -> LightStringMap.buildAdaptiveSizingHint().initCapacity(16).maxCapacity(8).build()); + () -> LightStringMap.adaptiveSizingHintBuilder().initCapacity(16).maxCapacity(8).build()); } @Test @@ -840,7 +840,7 @@ void cappedHintNeverSeedsAMapLargerThanTheCap() { // (which // normally reserves a class of headroom) cannot push the seed past maxSlots. LightStringMap.AdaptiveSizingHint hint = - LightStringMap.buildAdaptiveSizingHint().initCapacity(2).maxCapacity(16).build(); + LightStringMap.adaptiveSizingHintBuilder().initCapacity(2).maxCapacity(16).build(); LightStringMap map = LightStringMap.create(hint); for (int i = 0; i < 16; i++) { map.set("k" + i, i); From 66b27b7a07587a500b57b1fc0e3658a3c4754486 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 30 Jul 2026 08:48:48 -0400 Subject: [PATCH 31/43] Rename LightStringMap to LightMap Now that the map is parameterized on key type, the "String" in the name is misleading. Also documents why the backing store is a single Object[] rather than two typed arrays. Co-Authored-By: Claude Opus 4.8 --- ...chmark.java => LightMapGrowBenchmark.java} | 8 +- .../{LightStringMap.java => LightMap.java} | 63 +++--- ...htStringMapTest.java => LightMapTest.java} | 181 +++++++++--------- 3 files changed, 130 insertions(+), 122 deletions(-) rename internal-api/src/jmh/java/datadog/trace/util/{LightStringMapGrowBenchmark.java => LightMapGrowBenchmark.java} (97%) rename internal-api/src/main/java/datadog/trace/util/{LightStringMap.java => LightMap.java} (94%) rename internal-api/src/test/java/datadog/trace/util/{LightStringMapTest.java => LightMapTest.java} (81%) diff --git a/internal-api/src/jmh/java/datadog/trace/util/LightStringMapGrowBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/LightMapGrowBenchmark.java similarity index 97% rename from internal-api/src/jmh/java/datadog/trace/util/LightStringMapGrowBenchmark.java rename to internal-api/src/jmh/java/datadog/trace/util/LightMapGrowBenchmark.java index 9d293e7245b..1c4ddf36593 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/LightStringMapGrowBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/LightMapGrowBenchmark.java @@ -1,6 +1,6 @@ package datadog.trace.util; -import datadog.trace.util.LightStringMap.EmbeddingSupport; +import datadog.trace.util.LightMap.EmbeddingSupport; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -20,8 +20,8 @@ import org.openjdk.jmh.infra.Blackhole; /** - * Measures three candidate grow triggers for {@code LightStringMap}, to decide whether to reframe - * "when to grow" from a load-factor threshold to a maximum-probe bound. + * Measures three candidate grow triggers for {@code LightMap}, to decide whether to reframe "when + * to grow" from a load-factor threshold to a maximum-probe bound. * *

    *
  • {@code FULL} -- grow only when the table is physically full (the pre-existing spine @@ -48,7 +48,7 @@ @Warmup(iterations = 3, time = 1) @Measurement(iterations = 3, time = 1) @State(Scope.Thread) -public class LightStringMapGrowBenchmark { +public class LightMapGrowBenchmark { static final int SEED_SLOTS = 8; static final int LOAD_FACTOR_RESERVE_SHIFT = 2; // 1/4 reserve => 0.75 trigger diff --git a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java b/internal-api/src/main/java/datadog/trace/util/LightMap.java similarity index 94% rename from internal-api/src/main/java/datadog/trace/util/LightStringMap.java rename to internal-api/src/main/java/datadog/trace/util/LightMap.java index 769a2a73b53..61730a623b8 100644 --- a/internal-api/src/main/java/datadog/trace/util/LightStringMap.java +++ b/internal-api/src/main/java/datadog/trace/util/LightMap.java @@ -38,12 +38,21 @@ * Insertions after the a removal can fill the emptied slot. * In the event of resizing, removal markers are discarded while assigning * slots in the new data array. + * + * Why a single Object[] and not two typed arrays (K[] keys, V[] values)? + * Under erasure "properly typed" arrays would still be (K[]) new Object[] and + * (V[]) new Object[], so no real type safety is gained. Meanwhile the single + * array buys two concrete things: + * - The removal tombstone (a non-K sentinel) can live directly in the array. + * A reified K[] could not hold it (ArrayStoreException), forcing a separate + * parallel deleted-marker structure. + * - Embedding is one Object[] field in the host object instead of two. */ -public final class LightStringMap { +public final class LightMap { public static final int DEFAULT_CAPACITY = 8; // Slots a fresh (un-tuned) hint seeds -- a reasonable default so a cold site behaves like a - // plain LightStringMap.createUncapped(); it then self-tunes up or down from here. + // plain LightMap.createUncapped(); it then self-tunes up or down from here. static final int DEFAULT_HINT_SLOTS = DEFAULT_CAPACITY; // Floor step-down never drops a hint below (in slots), so a genuinely tiny site can still tune // below the default. Must stay >= 1 (a zero-slot table is degenerate). @@ -67,13 +76,13 @@ public final class LightStringMap { @Nullable private final AdaptiveSizingHint sizingHint; private Object[] data = EmbeddingSupport.EMPTY_DATA; - private LightStringMap(int capacity, int maxSlots) { + private LightMap(int capacity, int maxSlots) { this.initialCapacity = capacity; this.sizingHint = null; this.maxSlots = maxSlots; } - private LightStringMap(@Nonnull AdaptiveSizingHint hint) { + private LightMap(@Nonnull AdaptiveSizingHint hint) { this.sizingHint = hint; this.initialCapacity = hint.seedSlots(); this.maxSlots = hint.maxSlots(); @@ -81,8 +90,8 @@ private LightStringMap(@Nonnull AdaptiveSizingHint hint) { /** A new, uncapped map seeded at the default capacity. The "just give me a map" front door. */ @Nonnull - public static LightStringMap createUncapped() { - return new LightStringMap<>(DEFAULT_CAPACITY, NO_MAX_SLOTS); + public static LightMap createUncapped() { + return new LightMap<>(DEFAULT_CAPACITY, NO_MAX_SLOTS); } /** @@ -91,8 +100,8 @@ public static LightStringMap createUncapped() { * a {@link #adaptiveSizingHint()}. */ @Nonnull - public static LightStringMap createUncapped(int capacity) { - return new LightStringMap<>(capacity, NO_MAX_SLOTS); + public static LightMap createUncapped(int capacity) { + return new LightMap<>(capacity, NO_MAX_SLOTS); } /** @@ -102,10 +111,10 @@ public static LightStringMap createUncapped(int capacity) { * thought-free way to bound worst-case memory without minting an {@link AdaptiveSizingHint}. */ @Nonnull - public static LightStringMap createCapped(int maxCapacity) { + public static LightMap createCapped(int maxCapacity) { int max = EmbeddingSupport.roundUpToPow2(maxCapacity); int seed = Math.min(DEFAULT_CAPACITY, max); - return new LightStringMap<>(seed, max); + return new LightMap<>(seed, max); } /** @@ -114,25 +123,25 @@ public static LightStringMap createCapped(int maxCapacity) { * seed. Throws {@link IllegalArgumentException} if the rounded seed exceeds the rounded cap. */ @Nonnull - public static LightStringMap createCapped(int initialCapacity, int maxCapacity) { + public static LightMap createCapped(int initialCapacity, int maxCapacity) { int seed = EmbeddingSupport.roundUpToPow2(initialCapacity); int max = EmbeddingSupport.roundUpToPow2(maxCapacity); if (seed > max) { throw new IllegalArgumentException( "initialCapacity (" + seed + " slots) exceeds maxCapacity (" + max + " slots)"); } - return new LightStringMap<>(seed, max); + return new LightMap<>(seed, max); } /** * A new map sized (and, if the hint carries a {@code maxCapacity}, capped) from {@code hint}. * Mint the hint once per construction site via {@link #adaptiveSizingHint()} or {@link - * #adaptiveSizingHintBuilder()}, hold it in a {@code static final} field, and pass it to every map - * at that site. + * #adaptiveSizingHintBuilder()}, hold it in a {@code static final} field, and pass it to every + * map at that site. */ @Nonnull - public static LightStringMap create(@Nonnull AdaptiveSizingHint hint) { - return new LightStringMap<>(hint); + public static LightMap create(@Nonnull AdaptiveSizingHint hint) { + return new LightMap<>(hint); } /** @@ -276,9 +285,9 @@ Object[] dataForTesting() { /** * A self-tuning, per-construction-site sizing estimate. Mint one via {@link - * LightStringMap#adaptiveSizingHint()}, hold it in a {@code static final} field, and pass it to - * {@link LightStringMap#create(AdaptiveSizingHint)}; the map reads it to size itself and tunes it - * back as it grows. The caller never updates it. + * LightMap#adaptiveSizingHint()}, hold it in a {@code static final} field, and pass it to {@link + * LightMap#create(AdaptiveSizingHint)}; the map reads it to size itself and tunes it back as it + * grows. The caller never updates it. * *

    Opaque by design (no public members). The estimate self-tunes on two events the map already * observes -- a new map is started ({@link #seedSlots()}) and a map grows ({@link @@ -375,7 +384,7 @@ public static final class EmbeddingSupport { // occupancy is high). The check is derived entirely from the probe walk, so it is stateless and // lives here in the spine rather than needing a maintained live count in the object tier. // - // Chosen as a power-of-two-friendly 8 from measurement (LightStringMapGrowBenchmark): it caps + // Chosen as a power-of-two-friendly 8 from measurement (LightMapGrowBenchmark): it caps // the worst-case probe at 8 while keeping the memory over-provision modest on well-spread keys. // Because a table never has more than (numSlots - 1) probe distance, this is inert for tables // of @@ -520,10 +529,10 @@ public static Object[] set( } /** - * Hint-aware spine insert: the migration counterpart of {@link LightStringMap#set} for a map - * that has been dropped to the embedded spine but wants to keep the self-tuning it had as an - * object. Seeds a fresh table from {@code hint.seedSlots()} and teaches the hint back on a - * genuine grow, exactly as the object tier does -- so graduating a map to the spine is a strict + * Hint-aware spine insert: the migration counterpart of {@link LightMap#set} for a map that has + * been dropped to the embedded spine but wants to keep the self-tuning it had as an object. + * Seeds a fresh table from {@code hint.seedSlots()} and teaches the hint back on a genuine + * grow, exactly as the object tier does -- so graduating a map to the spine is a strict * superset (it gains the embedding win without losing its sizing). * *

    Unlike the object tier, the hint's {@code maxCapacity} cap does not bound growth @@ -538,12 +547,12 @@ public static Object[] set( @Nonnull Object key, @Nonnull V value) { int beforeSlots = numSlots(mapData); - // Seed a fresh table from the hint (mirrors LightStringMap(hint)); initialCapacity is only + // Seed a fresh table from the hint (mirrors LightMap(hint)); initialCapacity is only // read on the null-array branch, so the 0 below is never consumed when mapData is non-null. int seedCapacity = (mapData == null) ? hint.seedSlots() : 0; Object[] after = setOrReject(seedCapacity, NO_MAX_SLOTS, mapData, key, value); // Teach the hint on a genuine grow only (not the lazy first allocation, which already seeded - // from the hint) -- mirrors LightStringMap.recordGrowth. + // from the hint) -- mirrors LightMap.recordGrowth. if (beforeSlots != 0) { int afterSlots = numSlots(after); if (afterSlots > beforeSlots) { @@ -554,7 +563,7 @@ public static Object[] set( } // The single insert orchestration shared by the uncapped spine set() above and the capped - // object-tier LightStringMap.set(): probe, then either overwrite in place, fill a free slot, or + // object-tier LightMap.set(): probe, then either overwrite in place, fill a free slot, or // grow. Stores {@code key -> value} and returns the (possibly new) backing array. // // Returns null ONLY when {@code maxSlots} is a finite cap, the table is physically full at it, diff --git a/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java b/internal-api/src/test/java/datadog/trace/util/LightMapTest.java similarity index 81% rename from internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java rename to internal-api/src/test/java/datadog/trace/util/LightMapTest.java index 98440bd40fd..4b976373fb0 100644 --- a/internal-api/src/test/java/datadog/trace/util/LightStringMapTest.java +++ b/internal-api/src/test/java/datadog/trace/util/LightMapTest.java @@ -1,6 +1,6 @@ package datadog.trace.util; -import static datadog.trace.util.LightStringMap.EmbeddingSupport; +import static datadog.trace.util.LightMap.EmbeddingSupport; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; @@ -15,7 +15,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -class LightStringMapTest { +class LightMapTest { // ============ Instance API ============ @@ -24,14 +24,13 @@ class InstanceTests { @Test void getOnEmptyMapReturnsNull() { - LightStringMap map = - LightStringMap.createUncapped(LightStringMap.DEFAULT_CAPACITY); + LightMap map = LightMap.createUncapped(LightMap.DEFAULT_CAPACITY); assertNull(map.get("absent")); } @Test void setThenGet() { - LightStringMap map = LightStringMap.createUncapped(8); + LightMap map = LightMap.createUncapped(8); map.set("a", 1); map.set("b", 2); assertEquals(1, map.get("a")); @@ -41,7 +40,7 @@ void setThenGet() { @Test void setOverwritesExistingKey() { - LightStringMap map = LightStringMap.createUncapped(8); + LightMap map = LightMap.createUncapped(8); map.set("a", 1); map.set("a", 42); assertEquals(42, map.get("a")); @@ -49,7 +48,7 @@ void setOverwritesExistingKey() { @Test void removeMakesKeyAbsent() { - LightStringMap map = LightStringMap.createUncapped(8); + LightMap map = LightMap.createUncapped(8); map.set("a", 1); map.set("b", 2); map.remove("a"); @@ -59,7 +58,7 @@ void removeMakesKeyAbsent() { @Test void containsKeyDistinguishesPresenceFromAbsence() { - LightStringMap map = LightStringMap.createUncapped(8); + LightMap map = LightMap.createUncapped(8); assertFalse(map.containsKey("a")); map.set("a", 1); assertTrue(map.containsKey("a")); @@ -70,14 +69,14 @@ void containsKeyDistinguishesPresenceFromAbsence() { @Test void setRejectsNullValue() { - LightStringMap map = LightStringMap.createUncapped(8); + LightMap map = LightMap.createUncapped(8); assertThrows(NullPointerException.class, () -> map.set("a", null)); assertFalse(map.containsKey("a")); } @Test void sizeTracksLiveEntries() { - LightStringMap map = LightStringMap.createUncapped(8); + LightMap map = LightMap.createUncapped(8); assertEquals(0, map.size()); map.set("a", 1); map.set("b", 2); @@ -96,7 +95,7 @@ void tinyMapGrowsOnlyWhenPhysicallyFull() { // can never travel 8 slots there -- so a seed-8 map fills all 8 slots before it grows, // exactly as it did before the trigger existed. This is the property that keeps the tiny, // miss-dominated consumer (springweb6 localAttributes) behaviorally unchanged. - LightStringMap map = LightStringMap.createUncapped(8); + LightMap map = LightMap.createUncapped(8); for (int i = 0; i < 8; i++) { map.set("k" + i, i); } @@ -118,12 +117,12 @@ void clusteredKeysGrowEarlierThanWellSpreadKeys() { // well before the table is anywhere near full. Both sets stay fully retrievable. int count = 32; - LightStringMap spread = LightStringMap.createUncapped(8); + LightMap spread = LightMap.createUncapped(8); for (int i = 0; i < count; i++) { spread.set("spread." + i, i); } - LightStringMap clustered = LightStringMap.createUncapped(8); + LightMap clustered = LightMap.createUncapped(8); List colliding = collidingKeys(count); for (int i = 0; i < count; i++) { clustered.set(colliding.get(i), i); @@ -155,7 +154,7 @@ void collidingHashCodesDoNotExplodeMemory() { assertEquals(sharedHash, key.hashCode(), "fixture keys must share one hashCode: " + key); } - LightStringMap map = LightStringMap.createUncapped(8); + LightMap map = LightMap.createUncapped(8); for (int i = 0; i < keys.size(); i++) { map.set(keys.get(i), i); } @@ -178,7 +177,7 @@ void insertReclaimsEarlierTombstoneInsteadOfExtendingChain() { // rather than walk past it to a fresh null -- keeping the chain short and avoiding a needless // grow. List colliding = collidingKeys(5); - LightStringMap map = LightStringMap.createUncapped(16); + LightMap map = LightMap.createUncapped(16); for (int i = 0; i < 4; i++) { map.set(colliding.get(i), i); } @@ -203,7 +202,7 @@ void insertReclaimsEarlierTombstoneInsteadOfExtendingChain() { @Test void growsAndPreservesAllEntries() { // initial capacity 2 forces several resizes as we insert well past it. - LightStringMap map = LightStringMap.createUncapped(2); + LightMap map = LightMap.createUncapped(2); int n = 100; for (int i = 0; i < n; i++) { map.set("key-" + i, i); @@ -215,7 +214,7 @@ void growsAndPreservesAllEntries() { @Test void forEachVisitsEveryLiveEntry() { - LightStringMap map = LightStringMap.createUncapped(4); + LightMap map = LightMap.createUncapped(4); for (int i = 0; i < 20; i++) { map.set("k" + i, i); } @@ -233,7 +232,7 @@ void forEachVisitsEveryLiveEntry() { @Test void nonLiteralKeyResolvesViaEqualsFallback() { - LightStringMap map = LightStringMap.createUncapped(8); + LightMap map = LightMap.createUncapped(8); map.set("hello", 1); // Distinct String instance with the same content -- must be found via the equals pass. String lookup = new String("hello"); @@ -283,7 +282,7 @@ private int slotOf(Object[] data, int numSlots, String key) { @Test void removeThenReinsertSameKey() { - LightStringMap map = LightStringMap.createUncapped(4); + LightMap map = LightMap.createUncapped(4); for (int i = 0; i < 4; i++) { map.set("k" + i, i); } @@ -324,7 +323,7 @@ void setGrowsFromNullAndReadsBack() { @Test void defaultCapacitySetOverload() { Object[] data = EmbeddingSupport.set(null, "a", "A"); - assertEquals(LightStringMap.DEFAULT_CAPACITY, EmbeddingSupport.numSlots(data)); + assertEquals(LightMap.DEFAULT_CAPACITY, EmbeddingSupport.numSlots(data)); assertEquals("A", EmbeddingSupport.get(data, "a")); } @@ -520,27 +519,27 @@ class AdaptiveSizingHintTests { @Test void freshHintSeedsAtDefault() { - LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHint(); - assertEquals(LightStringMap.DEFAULT_HINT_SLOTS, hint.currentSeedSlots()); + LightMap.AdaptiveSizingHint hint = LightMap.adaptiveSizingHint(); + assertEquals(LightMap.DEFAULT_HINT_SLOTS, hint.currentSeedSlots()); } @Test void hintSeedsAFreshMapAtItsLearnedCapacity() { - LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHint(); - LightStringMap map = LightStringMap.create(hint); + LightMap.AdaptiveSizingHint hint = LightMap.adaptiveSizingHint(); + LightMap map = LightMap.create(hint); // A hint-seeded map allocates its backing array lazily, sized to the hint. map.set("a", 1); Object[] data = map.dataForTesting(); - assertEquals(LightStringMap.DEFAULT_HINT_SLOTS, EmbeddingSupport.numSlots(data)); + assertEquals(LightMap.DEFAULT_HINT_SLOTS, EmbeddingSupport.numSlots(data)); } @Test void growthRaisesSeedWithOneClassOfHeadroom() { - LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHint(); + LightMap.AdaptiveSizingHint hint = LightMap.adaptiveSizingHint(); // Fill a hint-seeded map past its seed so it grows; the hint should learn the new size // PLUS one power-of-two class of headroom (so the steady-state load factor stays <= 0.5). - LightStringMap map = LightStringMap.create(hint); - for (int i = 0; i < LightStringMap.DEFAULT_HINT_SLOTS + 1; i++) { + LightMap map = LightMap.create(hint); + for (int i = 0; i < LightMap.DEFAULT_HINT_SLOTS + 1; i++) { map.set("k" + i, i); } int grownSlots = EmbeddingSupport.numSlots(map.dataForTesting()); @@ -549,65 +548,65 @@ void growthRaisesSeedWithOneClassOfHeadroom() { @Test void seedIsMonotonicMaxAcrossMaps() { - LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHint(); + LightMap.AdaptiveSizingHint hint = LightMap.adaptiveSizingHint(); // A big map ratchets the hint up. - LightStringMap big = LightStringMap.create(hint); + LightMap big = LightMap.create(hint); for (int i = 0; i < 20; i++) { big.set("k" + i, i); } int learned = hint.currentSeedSlots(); - assertTrue(learned > LightStringMap.DEFAULT_HINT_SLOTS); + assertTrue(learned > LightMap.DEFAULT_HINT_SLOTS); // A subsequent tiny map does not lower the learned seed. - LightStringMap small = LightStringMap.create(hint); + LightMap small = LightMap.create(hint); small.set("a", 1); assertEquals(learned, hint.currentSeedSlots()); } @Test void decayStepsSeedDownAfterInterval() { - LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHint(); + LightMap.AdaptiveSizingHint hint = LightMap.adaptiveSizingHint(); // Ratchet the hint above the default so a step-down is observable. - LightStringMap big = LightStringMap.create(hint); + LightMap big = LightMap.create(hint); for (int i = 0; i < 20; i++) { big.set("k" + i, i); } int learned = hint.currentSeedSlots(); // One full decay interval of constructions steps the seed down exactly one class. - for (int i = 0; i < LightStringMap.DECAY_INTERVAL; i++) { - LightStringMap.create(hint); + for (int i = 0; i < LightMap.DECAY_INTERVAL; i++) { + LightMap.create(hint); } assertEquals(learned / 2, hint.currentSeedSlots()); } @Test void decayFloorsAtMinimum() { - LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHint(); + LightMap.AdaptiveSizingHint hint = LightMap.adaptiveSizingHint(); // Enough decay intervals to drive an un-ratcheted hint to the floor and hold there. int intervals = 32; - for (int i = 0; i < intervals * LightStringMap.DECAY_INTERVAL; i++) { - LightStringMap.create(hint); + for (int i = 0; i < intervals * LightMap.DECAY_INTERVAL; i++) { + LightMap.create(hint); } - assertEquals(LightStringMap.MIN_HINT_SLOTS, hint.currentSeedSlots()); + assertEquals(LightMap.MIN_HINT_SLOTS, hint.currentSeedSlots()); } @Test void seedIsCappedAtMax() { - LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHint(); + LightMap.AdaptiveSizingHint hint = LightMap.adaptiveSizingHint(); // A very large map cannot push the learned seed past the pre-provisioning ceiling. - LightStringMap big = LightStringMap.create(hint); - for (int i = 0; i < LightStringMap.MAX_HINT_SLOTS * 4; i++) { + LightMap big = LightMap.create(hint); + for (int i = 0; i < LightMap.MAX_HINT_SLOTS * 4; i++) { big.set("k" + i, i); } - assertTrue(hint.currentSeedSlots() <= LightStringMap.MAX_HINT_SLOTS); - assertEquals(LightStringMap.MAX_HINT_SLOTS, hint.currentSeedSlots()); + assertTrue(hint.currentSeedSlots() <= LightMap.MAX_HINT_SLOTS); + assertEquals(LightMap.MAX_HINT_SLOTS, hint.currentSeedSlots()); } @Test void oneDecayStepStaysSafeThenSecondDecayRepins() { - LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHint(); + LightMap.AdaptiveSizingHint hint = LightMap.adaptiveSizingHint(); // Learn a large size. With one class of headroom, `learned` is 2x the array the workload // physically grew into. - LightStringMap big = LightStringMap.create(hint); + LightMap big = LightMap.create(hint); for (int i = 0; i < 20; i++) { big.set("k" + i, i); } @@ -615,22 +614,22 @@ void oneDecayStepStaysSafeThenSecondDecayRepins() { // First decay lands the seed exactly on the physical high-water: the same workload now fits // without regrowing, so the seed holds (the headroom step-down is "free"). - for (int i = 0; i < LightStringMap.DECAY_INTERVAL; i++) { - LightStringMap.create(hint); + for (int i = 0; i < LightMap.DECAY_INTERVAL; i++) { + LightMap.create(hint); } assertEquals(learned / 2, hint.currentSeedSlots()); - LightStringMap stillFits = LightStringMap.create(hint); + LightMap stillFits = LightMap.create(hint); for (int i = 0; i < 20; i++) { stillFits.set("k" + i, i); } assertEquals(learned / 2, hint.currentSeedSlots()); // Second decay probes below the need: the workload now regrows and snaps the seed back up. - for (int i = 0; i < LightStringMap.DECAY_INTERVAL; i++) { - LightStringMap.create(hint); + for (int i = 0; i < LightMap.DECAY_INTERVAL; i++) { + LightMap.create(hint); } assertEquals(learned / 4, hint.currentSeedSlots()); - LightStringMap recovered = LightStringMap.create(hint); + LightMap recovered = LightMap.create(hint); for (int i = 0; i < 20; i++) { recovered.set("k" + i, i); } @@ -639,8 +638,8 @@ void oneDecayStepStaysSafeThenSecondDecayRepins() { @Test void hintSeededMapStoresAndReadsBackCorrectly() { - LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHint(); - LightStringMap map = LightStringMap.create(hint); + LightMap.AdaptiveSizingHint hint = LightMap.adaptiveSizingHint(); + LightMap map = LightMap.create(hint); for (int i = 0; i < 50; i++) { map.set("k" + i, i); } @@ -658,9 +657,9 @@ class SpineHintTests { @Test void spineSetSeedsAFreshTableFromTheHint() { // Dropping to the spine keeps the hint's sizing: a fresh table seeds from seedSlots(), just - // like LightStringMap.create(hint) does through the object tier. - LightStringMap.AdaptiveSizingHint hint = - LightStringMap.adaptiveSizingHintBuilder().initCapacity(4).build(); + // like LightMap.create(hint) does through the object tier. + LightMap.AdaptiveSizingHint hint = + LightMap.adaptiveSizingHintBuilder().initCapacity(4).build(); Object[] data = EmbeddingSupport.set(hint, null, "a", 1); assertEquals(4, EmbeddingSupport.numSlots(data)); assertEquals(1, (Object) EmbeddingSupport.get(data, "a")); @@ -671,9 +670,9 @@ void spineSetTeachesTheHintOnGrow() { // A grow through the spine ratchets the hint up with one class of headroom -- identical to // the // object tier's growthRaisesSeedWithOneClassOfHeadroom. - LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHint(); + LightMap.AdaptiveSizingHint hint = LightMap.adaptiveSizingHint(); Object[] data = null; - for (int i = 0; i < LightStringMap.DEFAULT_HINT_SLOTS + 1; i++) { + for (int i = 0; i < LightMap.DEFAULT_HINT_SLOTS + 1; i++) { data = EmbeddingSupport.set(hint, data, "k" + i, i); } int grownSlots = EmbeddingSupport.numSlots(data); @@ -685,8 +684,8 @@ void spineSetDoesNotEnforceTheHintCap() { // The cap guardrail does NOT follow to the spine: an Object[] return cannot signal rejection, // so a capped hint used at the spine grows past its cap and always stores. (Contrast the // object tier, which rejects at the cap -- see CapTests.) - LightStringMap.AdaptiveSizingHint hint = - LightStringMap.adaptiveSizingHintBuilder().initCapacity(2).maxCapacity(4).build(); + LightMap.AdaptiveSizingHint hint = + LightMap.adaptiveSizingHintBuilder().initCapacity(2).maxCapacity(4).build(); Object[] data = null; for (int i = 0; i < 12; i++) { data = EmbeddingSupport.set(hint, data, "k" + i, i); @@ -702,7 +701,7 @@ void spineSetDoesNotEnforceTheHintCap() { void spineSetOverwriteInPlaceDoesNotTeachTheHint() { // An in-place overwrite neither grows nor teaches the hint -- same array back, seed // unchanged. - LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHint(); + LightMap.AdaptiveSizingHint hint = LightMap.adaptiveSizingHint(); Object[] data = EmbeddingSupport.set(hint, null, "a", 1); int seedAfterFirstInsert = hint.currentSeedSlots(); @@ -722,7 +721,7 @@ class CapTests { void uncappedMapAlwaysReturnsTrueFromSet() { // The plain capacity constructor is uncapped: set stores unconditionally and never rejects, // even well past the initial capacity. - LightStringMap map = LightStringMap.createUncapped(2); + LightMap map = LightMap.createUncapped(2); for (int i = 0; i < 100; i++) { assertTrue(map.set("k" + i, i), "uncapped set should always store"); } @@ -734,8 +733,8 @@ void hintWithoutMaxCapacityIsUncapped() { // adaptiveSizingHintBuilder() with no maxCapacity behaves like the zero-config // adaptiveSizingHint(): no cap, so // set always stores. - LightStringMap.AdaptiveSizingHint hint = LightStringMap.adaptiveSizingHintBuilder().build(); - LightStringMap map = LightStringMap.create(hint); + LightMap.AdaptiveSizingHint hint = LightMap.adaptiveSizingHintBuilder().build(); + LightMap map = LightMap.create(hint); for (int i = 0; i < 100; i++) { assertTrue(map.set("k" + i, i)); } @@ -747,9 +746,9 @@ void cappedSetStoresUntilPhysicallyFullThenRejects() { // maxCapacity(8) bounds the table at 8 slots. Eight distinct keys fill every slot (a // 8-slot table never trips the probe bound, so it fills before it would grow); a ninth, // genuinely new key cannot grow past the cap, so set rejects it and leaves the map unchanged. - LightStringMap.AdaptiveSizingHint hint = - LightStringMap.adaptiveSizingHintBuilder().maxCapacity(8).build(); - LightStringMap map = LightStringMap.create(hint); + LightMap.AdaptiveSizingHint hint = + LightMap.adaptiveSizingHintBuilder().maxCapacity(8).build(); + LightMap map = LightMap.create(hint); for (int i = 0; i < 8; i++) { assertTrue(map.set("k" + i, i), "slot " + i + " should store"); } @@ -769,9 +768,9 @@ void cappedSetStoresUntilPhysicallyFullThenRejects() { void cappedSetOverwritesExistingKeyEvenWhenFull() { // Rejection only applies to a genuinely new key. Overwriting a present key when the table is // full at the cap still succeeds -- no new slot is needed. - LightStringMap.AdaptiveSizingHint hint = - LightStringMap.adaptiveSizingHintBuilder().maxCapacity(8).build(); - LightStringMap map = LightStringMap.create(hint); + LightMap.AdaptiveSizingHint hint = + LightMap.adaptiveSizingHintBuilder().maxCapacity(8).build(); + LightMap map = LightMap.create(hint); for (int i = 0; i < 8; i++) { map.set("k" + i, i); } @@ -784,9 +783,9 @@ void cappedSetOverwritesExistingKeyEvenWhenFull() { void cappedMapGrowsUpToTheCap() { // A cap does not pin the seed size: a map started small still grows through the // power-of-two classes up to the cap, retaining every entry along the way. - LightStringMap.AdaptiveSizingHint hint = - LightStringMap.adaptiveSizingHintBuilder().initCapacity(2).maxCapacity(16).build(); - LightStringMap map = LightStringMap.create(hint); + LightMap.AdaptiveSizingHint hint = + LightMap.adaptiveSizingHintBuilder().initCapacity(2).maxCapacity(16).build(); + LightMap map = LightMap.create(hint); for (int i = 0; i < 16; i++) { assertTrue(map.set("k" + i, i), "slot " + i + " should store below the cap"); } @@ -803,9 +802,9 @@ void afterRejectionRemovingThenReinsertingSucceeds() { // A rejection is non-fatal: freeing a slot (remove) makes room again, so a subsequent set of // a // new key succeeds via the reclaimed tombstone. - LightStringMap.AdaptiveSizingHint hint = - LightStringMap.adaptiveSizingHintBuilder().maxCapacity(8).build(); - LightStringMap map = LightStringMap.create(hint); + LightMap.AdaptiveSizingHint hint = + LightMap.adaptiveSizingHintBuilder().maxCapacity(8).build(); + LightMap map = LightMap.create(hint); for (int i = 0; i < 8; i++) { map.set("k" + i, i); } @@ -818,9 +817,9 @@ void afterRejectionRemovingThenReinsertingSucceeds() { @Test void maxCapacityRoundsUpToPowerOfTwo() { // A non-power-of-two cap rounds up: maxCapacity(5) becomes an 8-slot cap. - LightStringMap.AdaptiveSizingHint hint = - LightStringMap.adaptiveSizingHintBuilder().maxCapacity(5).build(); - LightStringMap map = LightStringMap.create(hint); + LightMap.AdaptiveSizingHint hint = + LightMap.adaptiveSizingHintBuilder().maxCapacity(5).build(); + LightMap map = LightMap.create(hint); for (int i = 0; i < 8; i++) { assertTrue(map.set("k" + i, i)); } @@ -831,7 +830,7 @@ void maxCapacityRoundsUpToPowerOfTwo() { void buildThrowsWhenInitCapacityExceedsMaxCapacity() { assertThrows( IllegalArgumentException.class, - () -> LightStringMap.adaptiveSizingHintBuilder().initCapacity(16).maxCapacity(8).build()); + () -> LightMap.adaptiveSizingHintBuilder().initCapacity(16).maxCapacity(8).build()); } @Test @@ -839,9 +838,9 @@ void cappedHintNeverSeedsAMapLargerThanTheCap() { // The learned seed is clamped to the cap: even after a map grows to the cap, recordSlots // (which // normally reserves a class of headroom) cannot push the seed past maxSlots. - LightStringMap.AdaptiveSizingHint hint = - LightStringMap.adaptiveSizingHintBuilder().initCapacity(2).maxCapacity(16).build(); - LightStringMap map = LightStringMap.create(hint); + LightMap.AdaptiveSizingHint hint = + LightMap.adaptiveSizingHintBuilder().initCapacity(2).maxCapacity(16).build(); + LightMap map = LightMap.create(hint); for (int i = 0; i < 16; i++) { map.set("k" + i, i); } @@ -860,7 +859,7 @@ class CreateCappedTests { void createCappedRejectsNewKeyOncePhysicallyFullAtCap() { // createCapped(8) hard-bounds the table at 8 slots with no hint: eight distinct keys fill it, // a ninth genuinely new key is rejected and leaves the map unchanged. - LightStringMap map = LightStringMap.createCapped(8); + LightMap map = LightMap.createCapped(8); for (int i = 0; i < 8; i++) { assertTrue(map.set("k" + i, i), "slot " + i + " should store"); } @@ -875,7 +874,7 @@ void createCappedRejectsNewKeyOncePhysicallyFullAtCap() { @Test void createCappedRoundsCapUpToPowerOfTwo() { // A non-power-of-two cap rounds up: createCapped(5) becomes an 8-slot cap. - LightStringMap map = LightStringMap.createCapped(5); + LightMap map = LightMap.createCapped(5); for (int i = 0; i < 8; i++) { assertTrue(map.set("k" + i, i)); } @@ -886,7 +885,7 @@ void createCappedRoundsCapUpToPowerOfTwo() { void createCappedSeedClampedToCapSmallerThanDefault() { // With a cap below the default seed, the seed is clamped down to the cap: createCapped(4) // seeds and caps at 4 slots, so the fifth new key is rejected. - LightStringMap map = LightStringMap.createCapped(4); + LightMap map = LightMap.createCapped(4); for (int i = 0; i < 4; i++) { assertTrue(map.set("k" + i, i)); } @@ -898,7 +897,7 @@ void createCappedSeedClampedToCapSmallerThanDefault() { void createCappedTwoArgSeedsSmallAndGrowsToTheCap() { // createCapped(2, 16) seeds at 2 slots and grows through the power-of-two classes up to the // 16-slot cap, retaining every entry; the 17th new key is rejected. - LightStringMap map = LightStringMap.createCapped(2, 16); + LightMap map = LightMap.createCapped(2, 16); for (int i = 0; i < 16; i++) { assertTrue(map.set("k" + i, i), "slot " + i + " should store below the cap"); } @@ -912,7 +911,7 @@ void createCappedTwoArgSeedsSmallAndGrowsToTheCap() { @Test void createCappedTwoArgRoundsBothToPowerOfTwo() { // Both arguments round up independently: createCapped(3, 5) seeds at 4 slots, caps at 8. - LightStringMap map = LightStringMap.createCapped(3, 5); + LightMap map = LightMap.createCapped(3, 5); for (int i = 0; i < 8; i++) { assertTrue(map.set("k" + i, i)); } @@ -921,7 +920,7 @@ void createCappedTwoArgRoundsBothToPowerOfTwo() { @Test void createCappedTwoArgThrowsWhenSeedExceedsCap() { - assertThrows(IllegalArgumentException.class, () -> LightStringMap.createCapped(16, 8)); + assertThrows(IllegalArgumentException.class, () -> LightMap.createCapped(16, 8)); } } } From 7625c26deec6076c22cc00c3eeb07da1ab79699d Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 30 Jul 2026 08:51:00 -0400 Subject: [PATCH 32/43] Move the sizing-hint builder onto AdaptiveSizingHint AdaptiveSizingHint.builder() returns a nested AdaptiveSizingHint.Builder, replacing LightMap.adaptiveSizingHintBuilder(). The builder belongs with the type it builds. Co-Authored-By: Claude Opus 4.8 --- .../java/datadog/trace/util/LightMap.java | 109 +++++++++--------- .../java/datadog/trace/util/LightMapTest.java | 22 ++-- 2 files changed, 66 insertions(+), 65 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/LightMap.java b/internal-api/src/main/java/datadog/trace/util/LightMap.java index 61730a623b8..5c424f016ab 100644 --- a/internal-api/src/main/java/datadog/trace/util/LightMap.java +++ b/internal-api/src/main/java/datadog/trace/util/LightMap.java @@ -136,7 +136,7 @@ public static LightMap createCapped(int initialCapacity, int maxCap /** * A new map sized (and, if the hint carries a {@code maxCapacity}, capped) from {@code hint}. * Mint the hint once per construction site via {@link #adaptiveSizingHint()} or {@link - * #adaptiveSizingHintBuilder()}, hold it in a {@code static final} field, and pass it to every + * AdaptiveSizingHint#builder()}, hold it in a {@code static final} field, and pass it to every * map at that site. */ @Nonnull @@ -155,64 +155,12 @@ public static AdaptiveSizingHint adaptiveSizingHint() { return new AdaptiveSizingHint(); } - /** - * Opens a builder for an {@link AdaptiveSizingHint} that carries an initial capacity and/or a - * hard {@code maxCapacity}. Use this instead of {@link #adaptiveSizingHint()} when a site wants - * to bound its maps' worst-case memory: every map built from the returned hint shares the same - * cap, and {@link #set} rejects (returns {@code false}) once a map is physically full at that - * cap. The hint still self-tunes its seed capacity within the cap. - */ - @Nonnull - public static AdaptiveSizingHintBuilder adaptiveSizingHintBuilder() { - return new AdaptiveSizingHintBuilder(); - } - - /** Builds an {@link AdaptiveSizingHint} with an initial and/or maximum capacity. */ - public static final class AdaptiveSizingHintBuilder { - private int initCapacity = DEFAULT_HINT_SLOTS; - private int maxCapacity = NO_MAX_SLOTS; - - private AdaptiveSizingHintBuilder() {} - - /** Seed capacity in slots for a cold map (rounded up to a power of two). */ - @Nonnull - public AdaptiveSizingHintBuilder initCapacity(int slots) { - this.initCapacity = slots; - return this; - } - - /** - * Hard cap, in slots (rounded up to a power of two), on how large any map built from this hint - * may grow. Once a map is physically full at this many slots, {@link #set} rejects a new key - * (returns {@code false}) instead of growing further -- bounding worst-case memory. - */ - @Nonnull - public AdaptiveSizingHintBuilder maxCapacity(int slots) { - this.maxCapacity = slots; - return this; - } - - @Nonnull - public AdaptiveSizingHint build() { - int seed = EmbeddingSupport.roundUpToPow2(this.initCapacity); - int max = - (this.maxCapacity == NO_MAX_SLOTS) - ? NO_MAX_SLOTS - : EmbeddingSupport.roundUpToPow2(this.maxCapacity); - if (max != NO_MAX_SLOTS && seed > max) { - throw new IllegalArgumentException( - "initCapacity (" + seed + " slots) exceeds maxCapacity (" + max + " slots)"); - } - return new AdaptiveSizingHint(seed, max); - } - } - /** * Stores {@code value} under {@code key}, growing the backing table if the probe-bound grow * trigger fires. Returns {@code true} if the mapping was stored (or overwrote an existing one). * *

    Returns {@code false} only for a capped map (one built via {@link #createCapped(int)} / - * {@link #createCapped(int, int)}, or from a {@link #adaptiveSizingHintBuilder()}.{@code + * {@link #createCapped(int, int)}, or from a {@link AdaptiveSizingHint#builder()}.{@code * maxCapacity(...)} hint): once the table is physically full at its cap, a genuinely new key is * rejected rather than growing past the cap. The rejection is non-fatal -- the map is unchanged * and the caller may ignore the return. An uncapped map always returns {@code true}. @@ -321,6 +269,18 @@ private AdaptiveSizingHint(int seedSlots, int maxSlots) { this.maxSlots = maxSlots; } + /** + * Opens a builder for an {@link AdaptiveSizingHint} that carries an initial capacity and/or a + * hard {@code maxCapacity}. Use this instead of {@link LightMap#adaptiveSizingHint()} when a + * site wants to bound its maps' worst-case memory: every map built from the returned hint + * shares the same cap, and {@link LightMap#set} rejects (returns {@code false}) once a map is + * physically full at that cap. The hint still self-tunes its seed capacity within the cap. + */ + @Nonnull + public static Builder builder() { + return new Builder(); + } + // The hard slot cap for maps built from this hint (NO_MAX_SLOTS when uncapped). int maxSlots() { return this.maxSlots; @@ -362,6 +322,47 @@ void recordSlots(int grownSlots) { int currentSeedSlots() { return this.slots; } + + /** Builds an {@link AdaptiveSizingHint} with an initial and/or maximum capacity. */ + public static final class Builder { + private int initCapacity = DEFAULT_HINT_SLOTS; + private int maxCapacity = NO_MAX_SLOTS; + + private Builder() {} + + /** Seed capacity in slots for a cold map (rounded up to a power of two). */ + @Nonnull + public Builder initCapacity(int slots) { + this.initCapacity = slots; + return this; + } + + /** + * Hard cap, in slots (rounded up to a power of two), on how large any map built from this + * hint may grow. Once a map is physically full at this many slots, {@link LightMap#set} + * rejects a new key (returns {@code false}) instead of growing further -- bounding worst-case + * memory. + */ + @Nonnull + public Builder maxCapacity(int slots) { + this.maxCapacity = slots; + return this; + } + + @Nonnull + public AdaptiveSizingHint build() { + int seed = EmbeddingSupport.roundUpToPow2(this.initCapacity); + int max = + (this.maxCapacity == NO_MAX_SLOTS) + ? NO_MAX_SLOTS + : EmbeddingSupport.roundUpToPow2(this.maxCapacity); + if (max != NO_MAX_SLOTS && seed > max) { + throw new IllegalArgumentException( + "initCapacity (" + seed + " slots) exceeds maxCapacity (" + max + " slots)"); + } + return new AdaptiveSizingHint(seed, max); + } + } } public static final class EmbeddingSupport { diff --git a/internal-api/src/test/java/datadog/trace/util/LightMapTest.java b/internal-api/src/test/java/datadog/trace/util/LightMapTest.java index 4b976373fb0..c06f06ff5fc 100644 --- a/internal-api/src/test/java/datadog/trace/util/LightMapTest.java +++ b/internal-api/src/test/java/datadog/trace/util/LightMapTest.java @@ -659,7 +659,7 @@ void spineSetSeedsAFreshTableFromTheHint() { // Dropping to the spine keeps the hint's sizing: a fresh table seeds from seedSlots(), just // like LightMap.create(hint) does through the object tier. LightMap.AdaptiveSizingHint hint = - LightMap.adaptiveSizingHintBuilder().initCapacity(4).build(); + LightMap.AdaptiveSizingHint.builder().initCapacity(4).build(); Object[] data = EmbeddingSupport.set(hint, null, "a", 1); assertEquals(4, EmbeddingSupport.numSlots(data)); assertEquals(1, (Object) EmbeddingSupport.get(data, "a")); @@ -685,7 +685,7 @@ void spineSetDoesNotEnforceTheHintCap() { // so a capped hint used at the spine grows past its cap and always stores. (Contrast the // object tier, which rejects at the cap -- see CapTests.) LightMap.AdaptiveSizingHint hint = - LightMap.adaptiveSizingHintBuilder().initCapacity(2).maxCapacity(4).build(); + LightMap.AdaptiveSizingHint.builder().initCapacity(2).maxCapacity(4).build(); Object[] data = null; for (int i = 0; i < 12; i++) { data = EmbeddingSupport.set(hint, data, "k" + i, i); @@ -730,10 +730,10 @@ void uncappedMapAlwaysReturnsTrueFromSet() { @Test void hintWithoutMaxCapacityIsUncapped() { - // adaptiveSizingHintBuilder() with no maxCapacity behaves like the zero-config + // AdaptiveSizingHint.builder() with no maxCapacity behaves like the zero-config // adaptiveSizingHint(): no cap, so // set always stores. - LightMap.AdaptiveSizingHint hint = LightMap.adaptiveSizingHintBuilder().build(); + LightMap.AdaptiveSizingHint hint = LightMap.AdaptiveSizingHint.builder().build(); LightMap map = LightMap.create(hint); for (int i = 0; i < 100; i++) { assertTrue(map.set("k" + i, i)); @@ -747,7 +747,7 @@ void cappedSetStoresUntilPhysicallyFullThenRejects() { // 8-slot table never trips the probe bound, so it fills before it would grow); a ninth, // genuinely new key cannot grow past the cap, so set rejects it and leaves the map unchanged. LightMap.AdaptiveSizingHint hint = - LightMap.adaptiveSizingHintBuilder().maxCapacity(8).build(); + LightMap.AdaptiveSizingHint.builder().maxCapacity(8).build(); LightMap map = LightMap.create(hint); for (int i = 0; i < 8; i++) { assertTrue(map.set("k" + i, i), "slot " + i + " should store"); @@ -769,7 +769,7 @@ void cappedSetOverwritesExistingKeyEvenWhenFull() { // Rejection only applies to a genuinely new key. Overwriting a present key when the table is // full at the cap still succeeds -- no new slot is needed. LightMap.AdaptiveSizingHint hint = - LightMap.adaptiveSizingHintBuilder().maxCapacity(8).build(); + LightMap.AdaptiveSizingHint.builder().maxCapacity(8).build(); LightMap map = LightMap.create(hint); for (int i = 0; i < 8; i++) { map.set("k" + i, i); @@ -784,7 +784,7 @@ void cappedMapGrowsUpToTheCap() { // A cap does not pin the seed size: a map started small still grows through the // power-of-two classes up to the cap, retaining every entry along the way. LightMap.AdaptiveSizingHint hint = - LightMap.adaptiveSizingHintBuilder().initCapacity(2).maxCapacity(16).build(); + LightMap.AdaptiveSizingHint.builder().initCapacity(2).maxCapacity(16).build(); LightMap map = LightMap.create(hint); for (int i = 0; i < 16; i++) { assertTrue(map.set("k" + i, i), "slot " + i + " should store below the cap"); @@ -803,7 +803,7 @@ void afterRejectionRemovingThenReinsertingSucceeds() { // a // new key succeeds via the reclaimed tombstone. LightMap.AdaptiveSizingHint hint = - LightMap.adaptiveSizingHintBuilder().maxCapacity(8).build(); + LightMap.AdaptiveSizingHint.builder().maxCapacity(8).build(); LightMap map = LightMap.create(hint); for (int i = 0; i < 8; i++) { map.set("k" + i, i); @@ -818,7 +818,7 @@ void afterRejectionRemovingThenReinsertingSucceeds() { void maxCapacityRoundsUpToPowerOfTwo() { // A non-power-of-two cap rounds up: maxCapacity(5) becomes an 8-slot cap. LightMap.AdaptiveSizingHint hint = - LightMap.adaptiveSizingHintBuilder().maxCapacity(5).build(); + LightMap.AdaptiveSizingHint.builder().maxCapacity(5).build(); LightMap map = LightMap.create(hint); for (int i = 0; i < 8; i++) { assertTrue(map.set("k" + i, i)); @@ -830,7 +830,7 @@ void maxCapacityRoundsUpToPowerOfTwo() { void buildThrowsWhenInitCapacityExceedsMaxCapacity() { assertThrows( IllegalArgumentException.class, - () -> LightMap.adaptiveSizingHintBuilder().initCapacity(16).maxCapacity(8).build()); + () -> LightMap.AdaptiveSizingHint.builder().initCapacity(16).maxCapacity(8).build()); } @Test @@ -839,7 +839,7 @@ void cappedHintNeverSeedsAMapLargerThanTheCap() { // (which // normally reserves a class of headroom) cannot push the seed past maxSlots. LightMap.AdaptiveSizingHint hint = - LightMap.adaptiveSizingHintBuilder().initCapacity(2).maxCapacity(16).build(); + LightMap.AdaptiveSizingHint.builder().initCapacity(2).maxCapacity(16).build(); LightMap map = LightMap.create(hint); for (int i = 0; i < 16; i++) { map.set("k" + i, i); From 1d9a80ff28f732a0ebe68736bc34093ed66854f6 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 30 Jul 2026 08:57:22 -0400 Subject: [PATCH 33/43] Add createUncapped/CappedAdaptiveSizingHint convenience factories Replaces the no-arg LightMap.adaptiveSizingHint() with two descriptive factories that mirror the map-side createUncapped / createCapped naming: LightMap.createUncappedAdaptiveSizingHint() LightMap.createCappedAdaptiveSizingHint(maxCapacity) Both delegate to AdaptiveSizingHint.builder(), which stays for the case that also needs an explicit initial capacity. A little verbose, but the common capped/uncapped choice reads at the call site without a builder. Co-Authored-By: Claude Opus 4.8 --- .../java/datadog/trace/util/LightMap.java | 55 +++++++++++-------- .../java/datadog/trace/util/LightMapTest.java | 40 ++++++-------- 2 files changed, 50 insertions(+), 45 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/LightMap.java b/internal-api/src/main/java/datadog/trace/util/LightMap.java index 5c424f016ab..801b8a8e89a 100644 --- a/internal-api/src/main/java/datadog/trace/util/LightMap.java +++ b/internal-api/src/main/java/datadog/trace/util/LightMap.java @@ -97,7 +97,7 @@ public static LightMap createUncapped() { /** * A new, uncapped map seeded at {@code capacity} (rounded up to a power of two on first write). * Use when the caller already knows the rough size; otherwise prefer {@link #createUncapped()} or - * a {@link #adaptiveSizingHint()}. + * a {@link #createUncappedAdaptiveSizingHint()}. */ @Nonnull public static LightMap createUncapped(int capacity) { @@ -135,9 +135,9 @@ public static LightMap createCapped(int initialCapacity, int maxCap /** * A new map sized (and, if the hint carries a {@code maxCapacity}, capped) from {@code hint}. - * Mint the hint once per construction site via {@link #adaptiveSizingHint()} or {@link - * AdaptiveSizingHint#builder()}, hold it in a {@code static final} field, and pass it to every - * map at that site. + * Mint the hint once per construction site via {@link #createUncappedAdaptiveSizingHint()}, + * {@link #createCappedAdaptiveSizingHint(int)}, or {@link AdaptiveSizingHint#builder()}, hold it + * in a {@code static final} field, and pass it to every map at that site. */ @Nonnull public static LightMap create(@Nonnull AdaptiveSizingHint hint) { @@ -145,14 +145,26 @@ public static LightMap create(@Nonnull AdaptiveSizingHint hint) { } /** - * Mints a self-tuning {@link AdaptiveSizingHint} for a single construction site. Hold it in a - * {@code static final} field and pass it to every {@link #create(AdaptiveSizingHint)} at that - * site; the map sizes itself from the hint and tunes the hint back on its own. The caller never - * touches the hint again. + * Mints an uncapped self-tuning {@link AdaptiveSizingHint} for a single construction site. Hold + * it in a {@code static final} field and pass it to every {@link #create(AdaptiveSizingHint)} at + * that site; the map sizes itself from the hint and tunes the hint back on its own. The caller + * never touches the hint again. */ @Nonnull - public static AdaptiveSizingHint adaptiveSizingHint() { - return new AdaptiveSizingHint(); + public static AdaptiveSizingHint createUncappedAdaptiveSizingHint() { + return AdaptiveSizingHint.builder().build(); + } + + /** + * Mints a self-tuning {@link AdaptiveSizingHint} hard-capped at {@code maxCapacity} slots + * (rounded up to a power of two). Like {@link #createUncappedAdaptiveSizingHint()} but every map + * built from the hint is bounded: once a map is physically full at the cap, {@link #set} rejects + * a new key (returns {@code false}) instead of growing past it. To also set an explicit initial + * capacity, use {@link AdaptiveSizingHint#builder()}. + */ + @Nonnull + public static AdaptiveSizingHint createCappedAdaptiveSizingHint(int maxCapacity) { + return AdaptiveSizingHint.builder().maxCapacity(maxCapacity).build(); } /** @@ -233,9 +245,10 @@ Object[] dataForTesting() { /** * A self-tuning, per-construction-site sizing estimate. Mint one via {@link - * LightMap#adaptiveSizingHint()}, hold it in a {@code static final} field, and pass it to {@link - * LightMap#create(AdaptiveSizingHint)}; the map reads it to size itself and tunes it back as it - * grows. The caller never updates it. + * LightMap#createUncappedAdaptiveSizingHint()}, {@link + * LightMap#createCappedAdaptiveSizingHint(int)}, or {@link #builder()}, hold it in a {@code + * static final} field, and pass it to {@link LightMap#create(AdaptiveSizingHint)}; the map reads + * it to size itself and tunes it back as it grows. The caller never updates it. * *

    Opaque by design (no public members). The estimate self-tunes on two events the map already * observes -- a new map is started ({@link #seedSlots()}) and a map grows ({@link @@ -260,21 +273,19 @@ public static final class AdaptiveSizingHint { // the cap. private final int maxSlots; - private AdaptiveSizingHint() { - this(DEFAULT_HINT_SLOTS, NO_MAX_SLOTS); - } - private AdaptiveSizingHint(int seedSlots, int maxSlots) { this.slots = seedSlots; this.maxSlots = maxSlots; } /** - * Opens a builder for an {@link AdaptiveSizingHint} that carries an initial capacity and/or a - * hard {@code maxCapacity}. Use this instead of {@link LightMap#adaptiveSizingHint()} when a - * site wants to bound its maps' worst-case memory: every map built from the returned hint - * shares the same cap, and {@link LightMap#set} rejects (returns {@code false}) once a map is - * physically full at that cap. The hint still self-tunes its seed capacity within the cap. + * Opens a builder for an {@link AdaptiveSizingHint} that carries an explicit initial capacity + * and/or a hard {@code maxCapacity}. Use this instead of {@link + * LightMap#createUncappedAdaptiveSizingHint()} / {@link + * LightMap#createCappedAdaptiveSizingHint(int)} when a site wants to seed the initial capacity + * as well. A {@code maxCapacity} bounds worst-case memory: every map built from the returned + * hint shares the same cap, and {@link LightMap#set} rejects (returns {@code false}) once a map + * is physically full at that cap. The hint still self-tunes its seed capacity within the cap. */ @Nonnull public static Builder builder() { diff --git a/internal-api/src/test/java/datadog/trace/util/LightMapTest.java b/internal-api/src/test/java/datadog/trace/util/LightMapTest.java index c06f06ff5fc..e1af730a9a7 100644 --- a/internal-api/src/test/java/datadog/trace/util/LightMapTest.java +++ b/internal-api/src/test/java/datadog/trace/util/LightMapTest.java @@ -519,13 +519,13 @@ class AdaptiveSizingHintTests { @Test void freshHintSeedsAtDefault() { - LightMap.AdaptiveSizingHint hint = LightMap.adaptiveSizingHint(); + LightMap.AdaptiveSizingHint hint = LightMap.createUncappedAdaptiveSizingHint(); assertEquals(LightMap.DEFAULT_HINT_SLOTS, hint.currentSeedSlots()); } @Test void hintSeedsAFreshMapAtItsLearnedCapacity() { - LightMap.AdaptiveSizingHint hint = LightMap.adaptiveSizingHint(); + LightMap.AdaptiveSizingHint hint = LightMap.createUncappedAdaptiveSizingHint(); LightMap map = LightMap.create(hint); // A hint-seeded map allocates its backing array lazily, sized to the hint. map.set("a", 1); @@ -535,7 +535,7 @@ void hintSeedsAFreshMapAtItsLearnedCapacity() { @Test void growthRaisesSeedWithOneClassOfHeadroom() { - LightMap.AdaptiveSizingHint hint = LightMap.adaptiveSizingHint(); + LightMap.AdaptiveSizingHint hint = LightMap.createUncappedAdaptiveSizingHint(); // Fill a hint-seeded map past its seed so it grows; the hint should learn the new size // PLUS one power-of-two class of headroom (so the steady-state load factor stays <= 0.5). LightMap map = LightMap.create(hint); @@ -548,7 +548,7 @@ void growthRaisesSeedWithOneClassOfHeadroom() { @Test void seedIsMonotonicMaxAcrossMaps() { - LightMap.AdaptiveSizingHint hint = LightMap.adaptiveSizingHint(); + LightMap.AdaptiveSizingHint hint = LightMap.createUncappedAdaptiveSizingHint(); // A big map ratchets the hint up. LightMap big = LightMap.create(hint); for (int i = 0; i < 20; i++) { @@ -564,7 +564,7 @@ void seedIsMonotonicMaxAcrossMaps() { @Test void decayStepsSeedDownAfterInterval() { - LightMap.AdaptiveSizingHint hint = LightMap.adaptiveSizingHint(); + LightMap.AdaptiveSizingHint hint = LightMap.createUncappedAdaptiveSizingHint(); // Ratchet the hint above the default so a step-down is observable. LightMap big = LightMap.create(hint); for (int i = 0; i < 20; i++) { @@ -580,7 +580,7 @@ void decayStepsSeedDownAfterInterval() { @Test void decayFloorsAtMinimum() { - LightMap.AdaptiveSizingHint hint = LightMap.adaptiveSizingHint(); + LightMap.AdaptiveSizingHint hint = LightMap.createUncappedAdaptiveSizingHint(); // Enough decay intervals to drive an un-ratcheted hint to the floor and hold there. int intervals = 32; for (int i = 0; i < intervals * LightMap.DECAY_INTERVAL; i++) { @@ -591,7 +591,7 @@ void decayFloorsAtMinimum() { @Test void seedIsCappedAtMax() { - LightMap.AdaptiveSizingHint hint = LightMap.adaptiveSizingHint(); + LightMap.AdaptiveSizingHint hint = LightMap.createUncappedAdaptiveSizingHint(); // A very large map cannot push the learned seed past the pre-provisioning ceiling. LightMap big = LightMap.create(hint); for (int i = 0; i < LightMap.MAX_HINT_SLOTS * 4; i++) { @@ -603,7 +603,7 @@ void seedIsCappedAtMax() { @Test void oneDecayStepStaysSafeThenSecondDecayRepins() { - LightMap.AdaptiveSizingHint hint = LightMap.adaptiveSizingHint(); + LightMap.AdaptiveSizingHint hint = LightMap.createUncappedAdaptiveSizingHint(); // Learn a large size. With one class of headroom, `learned` is 2x the array the workload // physically grew into. LightMap big = LightMap.create(hint); @@ -638,7 +638,7 @@ void oneDecayStepStaysSafeThenSecondDecayRepins() { @Test void hintSeededMapStoresAndReadsBackCorrectly() { - LightMap.AdaptiveSizingHint hint = LightMap.adaptiveSizingHint(); + LightMap.AdaptiveSizingHint hint = LightMap.createUncappedAdaptiveSizingHint(); LightMap map = LightMap.create(hint); for (int i = 0; i < 50; i++) { map.set("k" + i, i); @@ -670,7 +670,7 @@ void spineSetTeachesTheHintOnGrow() { // A grow through the spine ratchets the hint up with one class of headroom -- identical to // the // object tier's growthRaisesSeedWithOneClassOfHeadroom. - LightMap.AdaptiveSizingHint hint = LightMap.adaptiveSizingHint(); + LightMap.AdaptiveSizingHint hint = LightMap.createUncappedAdaptiveSizingHint(); Object[] data = null; for (int i = 0; i < LightMap.DEFAULT_HINT_SLOTS + 1; i++) { data = EmbeddingSupport.set(hint, data, "k" + i, i); @@ -701,7 +701,7 @@ void spineSetDoesNotEnforceTheHintCap() { void spineSetOverwriteInPlaceDoesNotTeachTheHint() { // An in-place overwrite neither grows nor teaches the hint -- same array back, seed // unchanged. - LightMap.AdaptiveSizingHint hint = LightMap.adaptiveSizingHint(); + LightMap.AdaptiveSizingHint hint = LightMap.createUncappedAdaptiveSizingHint(); Object[] data = EmbeddingSupport.set(hint, null, "a", 1); int seedAfterFirstInsert = hint.currentSeedSlots(); @@ -730,10 +730,8 @@ void uncappedMapAlwaysReturnsTrueFromSet() { @Test void hintWithoutMaxCapacityIsUncapped() { - // AdaptiveSizingHint.builder() with no maxCapacity behaves like the zero-config - // adaptiveSizingHint(): no cap, so - // set always stores. - LightMap.AdaptiveSizingHint hint = LightMap.AdaptiveSizingHint.builder().build(); + // createUncappedAdaptiveSizingHint() carries no cap, so set always stores. + LightMap.AdaptiveSizingHint hint = LightMap.createUncappedAdaptiveSizingHint(); LightMap map = LightMap.create(hint); for (int i = 0; i < 100; i++) { assertTrue(map.set("k" + i, i)); @@ -746,8 +744,7 @@ void cappedSetStoresUntilPhysicallyFullThenRejects() { // maxCapacity(8) bounds the table at 8 slots. Eight distinct keys fill every slot (a // 8-slot table never trips the probe bound, so it fills before it would grow); a ninth, // genuinely new key cannot grow past the cap, so set rejects it and leaves the map unchanged. - LightMap.AdaptiveSizingHint hint = - LightMap.AdaptiveSizingHint.builder().maxCapacity(8).build(); + LightMap.AdaptiveSizingHint hint = LightMap.createCappedAdaptiveSizingHint(8); LightMap map = LightMap.create(hint); for (int i = 0; i < 8; i++) { assertTrue(map.set("k" + i, i), "slot " + i + " should store"); @@ -768,8 +765,7 @@ void cappedSetStoresUntilPhysicallyFullThenRejects() { void cappedSetOverwritesExistingKeyEvenWhenFull() { // Rejection only applies to a genuinely new key. Overwriting a present key when the table is // full at the cap still succeeds -- no new slot is needed. - LightMap.AdaptiveSizingHint hint = - LightMap.AdaptiveSizingHint.builder().maxCapacity(8).build(); + LightMap.AdaptiveSizingHint hint = LightMap.createCappedAdaptiveSizingHint(8); LightMap map = LightMap.create(hint); for (int i = 0; i < 8; i++) { map.set("k" + i, i); @@ -802,8 +798,7 @@ void afterRejectionRemovingThenReinsertingSucceeds() { // A rejection is non-fatal: freeing a slot (remove) makes room again, so a subsequent set of // a // new key succeeds via the reclaimed tombstone. - LightMap.AdaptiveSizingHint hint = - LightMap.AdaptiveSizingHint.builder().maxCapacity(8).build(); + LightMap.AdaptiveSizingHint hint = LightMap.createCappedAdaptiveSizingHint(8); LightMap map = LightMap.create(hint); for (int i = 0; i < 8; i++) { map.set("k" + i, i); @@ -817,8 +812,7 @@ void afterRejectionRemovingThenReinsertingSucceeds() { @Test void maxCapacityRoundsUpToPowerOfTwo() { // A non-power-of-two cap rounds up: maxCapacity(5) becomes an 8-slot cap. - LightMap.AdaptiveSizingHint hint = - LightMap.AdaptiveSizingHint.builder().maxCapacity(5).build(); + LightMap.AdaptiveSizingHint hint = LightMap.createCappedAdaptiveSizingHint(5); LightMap map = LightMap.create(hint); for (int i = 0; i < 8; i++) { assertTrue(map.set("k" + i, i)); From 7839f9c4bf5dcd85d5d56ede608aba69ca92002e Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 30 Jul 2026 10:12:38 -0400 Subject: [PATCH 34/43] Fix ClassCastException resizing a non-String-keyed LightMap expandMapData copied keys back with a leftover (String) cast that the parameterization to missed. newMapUncheckedInsert already takes an Object key, so the cast was both unnecessary and a latent ClassCastException for any non-String key type once the table grows. The existing grow/resize tests all used String keys, so it never surfaced. Adds growsAndPreservesAllEntriesWithNonStringKeys (Integer keys, several forced resizes), which throws pre-fix and passes post-fix. Co-Authored-By: Claude Opus 4.8 --- .../src/main/java/datadog/trace/util/LightMap.java | 2 +- .../test/java/datadog/trace/util/LightMapTest.java | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/internal-api/src/main/java/datadog/trace/util/LightMap.java b/internal-api/src/main/java/datadog/trace/util/LightMap.java index 801b8a8e89a..e5bb12bc8d5 100644 --- a/internal-api/src/main/java/datadog/trace/util/LightMap.java +++ b/internal-api/src/main/java/datadog/trace/util/LightMap.java @@ -828,7 +828,7 @@ public static Object[] expandMapData(@Nonnull Object[] origMapData, int newCapac if (key == null || key == REMOVED) continue; Object value = origMapData[slot + origNumSlots]; - newMapUncheckedInsert(newMapData, newCapacity, (String) key, value); + newMapUncheckedInsert(newMapData, newCapacity, key, value); } return newMapData; } diff --git a/internal-api/src/test/java/datadog/trace/util/LightMapTest.java b/internal-api/src/test/java/datadog/trace/util/LightMapTest.java index e1af730a9a7..f883f38882d 100644 --- a/internal-api/src/test/java/datadog/trace/util/LightMapTest.java +++ b/internal-api/src/test/java/datadog/trace/util/LightMapTest.java @@ -212,6 +212,20 @@ void growsAndPreservesAllEntries() { } } + @Test + void growsAndPreservesAllEntriesWithNonStringKeys() { + // A non-String key type must survive resizing: expandMapData copies keys back generically, + // so it must not assume String. initial capacity 2 forces several resizes. + LightMap map = LightMap.createUncapped(2); + int n = 100; + for (int i = 0; i < n; i++) { + map.set(i, "value-" + i); + } + for (int i = 0; i < n; i++) { + assertEquals("value-" + i, map.get(i), "key " + i); + } + } + @Test void forEachVisitsEveryLiveEntry() { LightMap map = LightMap.createUncapped(4); From 6e3d9e489dc863bebd8dcc29f388ab1b49e0cafe Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 30 Jul 2026 10:22:22 -0400 Subject: [PATCH 35/43] Cover no-arg createUncapped() and RemovedTombstone rendering Two coverage-gap tests, no production change: - noArgCreateUncappedProducesUsableMap exercises the parameterless front door, which no other test touched. - removedTombstoneRendersDistinctlyForHeapInspection pins the deletion sentinel's "--REMOVED--" toString(). That method has no code-path caller (it exists purely for heap-dump/debugger inspection), so it was the one class below the 80% per-class instruction gate; the test both lifts it to 100% and locks in the debug rendering. Co-Authored-By: Claude Opus 4.8 --- .../java/datadog/trace/util/LightMapTest.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/internal-api/src/test/java/datadog/trace/util/LightMapTest.java b/internal-api/src/test/java/datadog/trace/util/LightMapTest.java index f883f38882d..559e6cea7a5 100644 --- a/internal-api/src/test/java/datadog/trace/util/LightMapTest.java +++ b/internal-api/src/test/java/datadog/trace/util/LightMapTest.java @@ -38,6 +38,18 @@ void setThenGet() { assertNull(map.get("c")); } + @Test + void noArgCreateUncappedProducesUsableMap() { + // The parameterless front door seeds the default capacity; exercise it end-to-end so the + // convenience factory doesn't rot uncovered. + LightMap map = LightMap.createUncapped(); + map.set("a", 1); + map.set("b", 2); + assertEquals(1, map.get("a")); + assertEquals(2, map.get("b")); + assertEquals(2, map.size()); + } + @Test void setOverwritesExistingKey() { LightMap map = LightMap.createUncapped(8); @@ -324,6 +336,14 @@ void emptyMapProbes() { assertEquals(EmbeddingSupport.SLOT_NOT_FOUND, EmbeddingSupport.findSlot(null, "x")); } + @Test + void removedTombstoneRendersDistinctlyForHeapInspection() { + // The deletion sentinel is only ever observed by a human reading a heap dump / debugger, + // never by production code, so its toString() has no code-path caller. Pin the rendering + // here so the debug aid can't silently regress. + assertEquals("--REMOVED--", EmbeddingSupport.REMOVED.toString()); + } + @Test void setGrowsFromNullAndReadsBack() { Object[] data = null; From 7c6bb90a176ba30e9877d9ea9980d60f54f0b375 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 30 Jul 2026 11:22:02 -0400 Subject: [PATCH 36/43] Cover the spine wraparound probe loops The second (wraparound) loop in findSlot/containsKey/findInsertionSlot -- the path where a probe runs off the end of the table and resumes at slot 0 -- was untested; it was also where the resize (String)-cast CCE hid. Add three EmbeddingSupportTests that force wraparound with Integer keys whose home slot is the last slot: a hit located after wrap, a miss that terminates at a null in the wraparound loop, and a tombstone reclaimed in the wraparound loop on insert. Test-only; lifts EmbeddingSupport branch coverage 78% -> 85%. Co-Authored-By: Claude Opus 4.8 --- .../java/datadog/trace/util/LightMapTest.java | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/internal-api/src/test/java/datadog/trace/util/LightMapTest.java b/internal-api/src/test/java/datadog/trace/util/LightMapTest.java index 559e6cea7a5..41db7236a83 100644 --- a/internal-api/src/test/java/datadog/trace/util/LightMapTest.java +++ b/internal-api/src/test/java/datadog/trace/util/LightMapTest.java @@ -388,6 +388,54 @@ void removeLeavesTombstoneButKeepsProbeChainIntact() { assertEquals(15, EmbeddingSupport.size(data)); } + // The following three tests drive the wraparound (second) probe loop in the spine, the path + // where a probe runs off the end of the table and resumes at slot 0. Integer keys make the home + // slot deterministic: for a value v < 2^16, key.hashCode() == v and its high half is zero, so + // preferredSlot(8, v) == (v & 7). Keys 7, 15, 23 all home to slot 7 (the last slot), so a probe + // starting there must wrap. Distances stay under MAX_PROBES, so an 8-slot table never resizes. + @Test + void findSlotWrapsPastEndToLocateKey() { + Object[] data = null; + data = EmbeddingSupport.set(8, data, 7, "A"); // home slot 7 + data = EmbeddingSupport.set(8, data, 15, "B"); // home slot 7 taken -> wraps to slot 0 + assertEquals(8, EmbeddingSupport.numSlots(data)); // no resize + + // 15's home (7) is occupied by 7, so the lookup must run off the end and resume the scan at + // slot 0 -- the wraparound loop -- to find it. get()/remove() both route through findSlot. + assertEquals(0, EmbeddingSupport.findSlot(data, 15)); + assertTrue(EmbeddingSupport.containsKey(data, 15)); + assertEquals("B", EmbeddingSupport.get(data, 15)); + } + + @Test + void missTerminatesInWraparoundLoopAtNull() { + Object[] data = null; + data = EmbeddingSupport.set(8, data, 7, "A"); // home 7 -> slot 7 + data = EmbeddingSupport.set(8, data, 15, "B"); // home 7 -> wraps to slot 0 + + // 23 also homes to slot 7; the probe wraps (slot 7 occupied, no match) and terminates at the + // first null in the second loop -- an absent-key miss reached only via wraparound. + assertEquals(EmbeddingSupport.SLOT_NOT_FOUND, EmbeddingSupport.findSlot(data, 23)); + assertFalse(EmbeddingSupport.containsKey(data, 23)); + assertNull(EmbeddingSupport.get(data, 23)); + } + + @Test + void insertReusesTombstoneFoundAfterWraparound() { + Object[] data = null; + data = EmbeddingSupport.set(8, data, 7, "A"); // home 7 -> slot 7 + data = EmbeddingSupport.set(8, data, 15, "B"); // home 7 -> wraps to slot 0 + assertTrue(EmbeddingSupport.remove(data, 15)); // slot 0 becomes a tombstone + + // 23 homes to slot 7 (occupied), wraps, and meets the tombstone at slot 0 before any null. + // findInsertionSlot must reclaim that tombstone in the wraparound loop rather than walk on. + data = EmbeddingSupport.set(8, data, 23, "C"); + assertEquals(8, EmbeddingSupport.numSlots(data)); // reclaimed in place, no resize + assertEquals(0, EmbeddingSupport.findSlot(data, 23)); // reused the very slot 15 vacated + assertEquals("C", EmbeddingSupport.get(data, 23)); + assertEquals(2, EmbeddingSupport.size(data)); + } + @Test void expandDiscardsTombstones() { Object[] data = null; From 9407982e2a5a3b2b01a6b6c15506622907de072e Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 30 Jul 2026 12:29:11 -0400 Subject: [PATCH 37/43] Add read-only for-each iteration to LightMap and its spine LightMap now implements Iterable> so it works in a for-each loop, with the spine exposing the same capability as static functions over a caller-owned array: EmbeddingSupport.iterator(data) and iterable(data). The iterator is a flyweight that is *both* the Iterator and the EntryReader it yields -- next() advances to the next live slot (skipping nulls and tombstones) and returns this. There is no per-entry object, so a for-each loop that does not let the cursor escape scalar-replaces the whole thing away; its fields are one array reference plus two ints for exactly that reason. This preserves the entry-less, allocation-light intent rather than leaving a gap a naive Iterator would later fill with a per-entry allocation. Read-only by design: Iterator.remove() stays unsupported (the JDK 8 default throws); mutate via remove(Object). The returned EntryReader is a reused cursor valid only until the next next() -- documented on the interface so it is not retained or collected. Co-Authored-By: Claude Opus 4.8 --- .../java/datadog/trace/util/LightMap.java | 125 +++++++++++++++++- 1 file changed, 121 insertions(+), 4 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/LightMap.java b/internal-api/src/main/java/datadog/trace/util/LightMap.java index e5bb12bc8d5..2b18c439429 100644 --- a/internal-api/src/main/java/datadog/trace/util/LightMap.java +++ b/internal-api/src/main/java/datadog/trace/util/LightMap.java @@ -2,6 +2,8 @@ import datadog.trace.api.function.TriConsumer; import java.util.Arrays; +import java.util.Iterator; +import java.util.NoSuchElementException; import java.util.Objects; import java.util.function.BiConsumer; import javax.annotation.Nonnull; @@ -12,9 +14,10 @@ * {@link String}), designed to be small and fast for tiny maps. * *

    Supports the common map operations -- {@code set}, {@code get}, {@code remove}, {@code - * containsKey}, and {@code forEach} -- as an easy, largely footgun-free stand-in wherever a small - * {@code Map} is needed. It deliberately does not implement {@link java.util.Map}; - * the surface is intentionally small. + * containsKey}, {@code forEach}, and for-each iteration ({@link Iterable} of {@link EntryReader}) + * -- as an easy, largely footgun-free stand-in wherever a small {@code Map} is needed. It + * deliberately does not implement {@link java.util.Map}; the surface is intentionally + * small. * *

    Neither null keys nor null values are supported: {@code set} rejects a null value, so a null * {@code get} result unambiguously means "absent". Use {@link #containsKey} if you need to probe @@ -48,7 +51,7 @@ * parallel deleted-marker structure. * - Embedding is one Object[] field in the host object instead of two. */ -public final class LightMap { +public final class LightMap implements Iterable> { public static final int DEFAULT_CAPACITY = 8; // Slots a fresh (un-tuned) hint seeds -- a reasonable default so a cold site behaves like a @@ -237,6 +240,38 @@ public void forEach(@Nonnull BiConsumer consumer) { } } + /** + * Returns an iterator over the live entries, for use with a for-each loop. Delegates to the spine + * ({@link EmbeddingSupport#iterator(Object[])}); the returned object is a reused flyweight -- see + * {@link EntryReader} for the retention caveat. Read-only: {@link Iterator#remove()} is + * unsupported (throws {@link UnsupportedOperationException}) -- mutate via {@link + * #remove(Object)} instead. Not thread-safe; behavior is undefined if the map is structurally + * modified during iteration. + */ + @Override + @Nonnull + public Iterator> iterator() { + return EmbeddingSupport.iterator(this.data); + } + + /** + * A read-only view of one entry, yielded by {@link #iterator()} (and the spine's {@link + * EmbeddingSupport#iterator(Object[])} / {@link EmbeddingSupport#iterable(Object[])}). + * + *

    Reused cursor -- do not retain. To stay entry-less and allocation-free the + * iterator hands back itself, repositioned onto each entry in turn, rather than a fresh + * object per entry. A returned reader is therefore valid only until the next {@code next()}: + * never stash one or collect the readers into a collection -- every reference would point at the + * same flyweight showing the last entry visited. Read {@link #key()}/{@link #value()} into your + * own fields if you need a durable copy. The upside of the single reused object is that escape + * analysis can eliminate it entirely when a for-each loop does not let it escape. + */ + public interface EntryReader { + K key(); + + V value(); + } + // Visible for testing: the backing spine (null until the first set). @Nullable Object[] dataForTesting() { @@ -865,6 +900,88 @@ public static void forEach( } } + /** + * A read-only iterator over the live entries of a caller-owned spine array -- the spine + * counterpart of {@link LightMap#iterator()}, for a caller that embeds the {@code Object[]} + * directly. The returned object is a reused flyweight; see {@link EntryReader} for the + * retention caveat. {@link Iterator#remove()} is unsupported. + */ + @Nonnull + public static Iterator> iterator(@Nullable Object[] mapData) { + return new SlotIterator<>(mapData); + } + + /** + * An {@link Iterable} view of a caller-owned spine array so the spine can be driven by a + * for-each loop directly: {@code for (EntryReader e : EmbeddingSupport.iterable(data))}. + * Each {@link Iterable#iterator()} call mints a fresh flyweight, so the view is re-iterable. + * The wrapper is a tiny object escape analysis can eliminate when the loop does not let it + * escape. + */ + @Nonnull + public static Iterable> iterable(@Nullable Object[] mapData) { + return () -> iterator(mapData); + } + + // Flyweight that is both the Iterator and the EntryReader it yields: next() advances to the + // next + // live slot and returns this. No per-entry object, so a for-each loop that does not let it + // escape scalar-replaces the whole thing away. Fields are one array reference plus two ints for + // exactly that reason. Read-only: the inherited Iterator.remove() default throws. + private static final class SlotIterator + implements Iterator>, EntryReader { + private final Object[] mapData; + private final int numSlots; + // Index of the next live key slot (== numSlots once exhausted), and the slot the reader + // currently points at (set by next()). + private int nextSlot; + private int slot; + + SlotIterator(@Nullable Object[] mapData) { + this.mapData = mapData; + this.numSlots = numSlots(mapData); + this.nextSlot = nextLiveSlot(0); + } + + // The first live slot at or after `from`, skipping empty (null) and tombstone (REMOVED) + // slots. + private int nextLiveSlot(int from) { + Object[] data = this.mapData; + int n = this.numSlots; + for (int i = from; i < n; ++i) { + Object key = data[i]; + if (key != null && key != REMOVED) return i; + } + return n; + } + + @Override + public boolean hasNext() { + return this.nextSlot < this.numSlots; + } + + @Override + public EntryReader next() { + int cur = this.nextSlot; + if (cur >= this.numSlots) throw new NoSuchElementException(); + this.slot = cur; + this.nextSlot = nextLiveSlot(cur + 1); + return this; + } + + @SuppressWarnings("unchecked") + @Override + public K key() { + return (K) this.mapData[this.slot]; + } + + @SuppressWarnings("unchecked") + @Override + public V value() { + return (V) this.mapData[this.slot + this.numSlots]; + } + } + @Nonnull public static String toInternalString(@Nullable Object[] mapData) { StringBuilder builder = new StringBuilder(128); From aea961961475098ff3010489dc02830bb81538ae Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 30 Jul 2026 12:29:14 -0400 Subject: [PATCH 38/43] Test LightMap for-each iteration (instance + spine) Covers both tiers: the instance Iterable and the spine EmbeddingSupport.iterator(data) / iterable(data). Asserts live entries are visited, tombstones and empty slots are skipped, empty/null data yields nothing (NoSuchElementException on next()), the spine iterable is re-iterable, remove() throws UnsupportedOperationException, and -- pinning the zero-allocation contract -- that next() hands back the one reused flyweight (the iterator itself) each step. Co-Authored-By: Claude Opus 4.8 --- .../java/datadog/trace/util/LightMapTest.java | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/internal-api/src/test/java/datadog/trace/util/LightMapTest.java b/internal-api/src/test/java/datadog/trace/util/LightMapTest.java index 41db7236a83..208fb5d9174 100644 --- a/internal-api/src/test/java/datadog/trace/util/LightMapTest.java +++ b/internal-api/src/test/java/datadog/trace/util/LightMapTest.java @@ -8,10 +8,13 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import datadog.trace.util.LightMap.EntryReader; import java.util.ArrayList; import java.util.HashMap; +import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.NoSuchElementException; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -317,6 +320,75 @@ void removeThenReinsertSameKey() { map.set("k1", 99); assertEquals(99, map.get("k1")); } + + @Test + void forEachLoopVisitsAllLiveEntries() { + LightMap map = LightMap.createUncapped(8); + map.set("a", 1); + map.set("b", 2); + map.set("c", 3); + + Map seen = new HashMap<>(); + for (EntryReader e : map) { + seen.put(e.key(), e.value()); + } + assertEquals(3, seen.size()); + assertEquals(1, seen.get("a")); + assertEquals(2, seen.get("b")); + assertEquals(3, seen.get("c")); + } + + @Test + void iterationSkipsRemovedAndEmptySlots() { + LightMap map = LightMap.createUncapped(8); + for (int i = 0; i < 5; i++) { + map.set("k" + i, i); + } + map.remove("k2"); // leaves a tombstone the iterator must skip + + Map seen = new HashMap<>(); + for (EntryReader e : map) { + seen.put(e.key(), e.value()); + } + assertEquals(4, seen.size()); + assertNull(seen.get("k2")); + assertEquals(0, seen.get("k0")); + assertEquals(4, seen.get("k4")); + } + + @Test + void iterationOverEmptyMapYieldsNothing() { + LightMap map = LightMap.createUncapped(8); + Iterator> it = map.iterator(); + assertFalse(it.hasNext()); + assertThrows(NoSuchElementException.class, it::next); + } + + @Test + void iteratorReusesASingleFlyweight() { + // The reader handed back is the iterator itself, repositioned in place -- the same object + // each + // step. This pins the deliberate zero-allocation contract (and the "do not retain" caveat). + LightMap map = LightMap.createUncapped(8); + map.set("a", 1); + map.set("b", 2); + + Iterator> it = map.iterator(); + EntryReader first = it.next(); + EntryReader second = it.next(); + assertSame(first, second); + assertSame(it, first); + assertFalse(it.hasNext()); + } + + @Test + void iteratorRemoveIsUnsupported() { + LightMap map = LightMap.createUncapped(8); + map.set("a", 1); + Iterator> it = map.iterator(); + it.next(); + assertThrows(UnsupportedOperationException.class, it::remove); + } } // ============ EmbeddingSupport spine ============ @@ -436,6 +508,51 @@ void insertReusesTombstoneFoundAfterWraparound() { assertEquals(2, EmbeddingSupport.size(data)); } + @Test + void spineIteratorVisitsAllLiveEntriesAndSkipsTombstones() { + Object[] data = null; + for (int i = 0; i < 5; i++) { + data = EmbeddingSupport.set(8, data, "k" + i, i); + } + EmbeddingSupport.remove(data, "k3"); // tombstone to skip + + Map seen = new HashMap<>(); + Iterator> it = EmbeddingSupport.iterator(data); + while (it.hasNext()) { + EntryReader e = it.next(); + seen.put(e.key(), e.value()); + } + assertEquals(4, seen.size()); + assertNull(seen.get("k3")); + assertEquals(0, seen.get("k0")); + } + + @Test + void spineIterableDrivesForEachLoopAndIsReIterable() { + Object[] data = null; + data = EmbeddingSupport.set(8, data, "a", 1); + data = EmbeddingSupport.set(8, data, "b", 2); + + Iterable> view = EmbeddingSupport.iterable(data); + // A fresh flyweight per iterator() call, so the view survives a second pass. + for (int pass = 0; pass < 2; pass++) { + Map seen = new HashMap<>(); + for (EntryReader e : view) { + seen.put(e.key(), e.value()); + } + assertEquals(2, seen.size(), "pass " + pass); + assertEquals(1, seen.get("a")); + assertEquals(2, seen.get("b")); + } + } + + @Test + void spineIteratorOverNullDataYieldsNothing() { + Iterator> it = EmbeddingSupport.iterator(null); + assertFalse(it.hasNext()); + assertThrows(NoSuchElementException.class, it::next); + } + @Test void expandDiscardsTombstones() { Object[] data = null; From 4a98c15ea3e8d1def926cbb666dae0825ed41c31 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 30 Jul 2026 12:36:20 -0400 Subject: [PATCH 39/43] Add LightMap arms to the single-threaded map comparison benchmark Adds create_lightMap, get_lightMap, and iterate_lightMap alongside the HashMap/TagMap/TreeMap arms. iterate_lightMap exercises the entry-less for-each: the flyweight iterator (Iterator + EntryReader in one object) stays non-escaping, so escape analysis scalar-replaces it -- measured ~0 B/op under -prof gc, matching iterate_hashMap's zero-alloc traversal rather than paying to materialize per-entry objects. Co-Authored-By: Claude Opus 4.8 --- .../util/SingleThreadedMapBenchmark.java | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java index 11572aa923d..d61a899e127 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java @@ -33,11 +33,25 @@ *

  • (RECOMMENDED) HashMap — fastest general-purpose lookups *
  • (RECOMMENDED) TagMap — preferred for storing tags; excels at primitives, copying, and * builder idioms + *
  • LightMap — a tiny, entry-less open-addressed map for short-lived, miss-dominated maps that + * do not need the {@code java.util.Map} interface; allocation-light, and its for-each + * iteration is shaped so escape analysis eliminates the iterator (see {@code iterate_lightMap}) *
  • TreeMap — when a custom Comparator is needed (see CaseInsensitiveMapBenchmark) *
  • LinkedHashMap — only when insertion-order iteration is required; cost is paid at * construction and in per-entry memory *
* + *

Allocation-free iteration. {@code iterate_lightMap} exercises the entry-less + * {@link LightMap} for-each: the flyweight iterator is both the {@code Iterator} and the + * {@code EntryReader} it yields, so with a concretely-typed map that lets it stay non-escaping, + * escape analysis scalar-replaces it. Measured (JDK 17, {@code -prof gc}, {@code -t 1}) at + * {@code gc.alloc.rate.norm ≈ 10^-5 B/op} -- i.e. zero. The point is not that this beats + * {@code iterate_hashMap}: HashMap's iterator likewise scalar-replaces and reuses its stored + * {@code Node} as the entry, so it is also ~0 B/op. The point is that LightMap's entry-less layout + * (no per-entry object to hand out) costs nothing at iteration time -- the flyweight matches + * HashMap's zero-alloc traversal rather than paying to materialize entries. Re-run with + * {@code -prof gc} to confirm both arms stay at ~0 B/op. + * *

Uncontended synchronization tax. A {@link Collections#synchronizedMap} case is included * to measure what synchronization costs when there is no contention: because each thread * owns its synchronized map, the monitor is only ever locked by one thread. On JVMs with biased @@ -81,12 +95,20 @@ static TagMap fillTagMap(TagMap map) { return map; } + static LightMap fillLightMap(LightMap map) { + for (int i = 0; i < INSERTION_KEYS.length; ++i) { + map.set(INSERTION_KEYS[i], i); + } + return map; + } + // Per-thread prebuilt maps for the read + clone benchmarks (built once per trial, per thread). HashMap hashMap; Map synchronizedHashMap; TreeMap treeMap; LinkedHashMap linkedHashMap; TagMap tagMap; + LightMap lightMap; int index = 0; @Setup(Level.Trial) @@ -99,6 +121,7 @@ public void setUp() { linkedHashMap = new LinkedHashMap<>(); fill(linkedHashMap); tagMap = fillTagMap(TagMap.create()); + lightMap = fillLightMap(LightMap.createUncapped()); } String nextLookupKey() { @@ -159,6 +182,11 @@ public TagMap create_tagMap_via_ledger() { return ledger.build(); } + @Benchmark + public LightMap create_lightMap() { + return fillLightMap(LightMap.createUncapped()); + } + // ---- copy ---- @Benchmark @@ -200,6 +228,11 @@ public Integer get_synchronizedHashMap() { return synchronizedHashMap.get(nextLookupKey()); } + @Benchmark + public Integer get_lightMap() { + return lightMap.get(nextLookupKey()); + } + @Benchmark public void iterate_hashMap(Blackhole blackhole) { for (Map.Entry entry : hashMap.entrySet()) { @@ -208,6 +241,19 @@ public void iterate_hashMap(Blackhole blackhole) { } } + @Benchmark + public void iterate_lightMap(Blackhole blackhole) { + // LightMap is concretely typed here so the for-each call sites devirtualize and inline; the + // flyweight iterator never escapes this method, so escape analysis scalar-replaces it entirely + // -- measured ~0 B/op under -prof gc. iterate_hashMap is the peer: it too is ~0 B/op (its + // iterator scalar-replaces and reuses the stored Node as the entry), so this arm confirms the + // entry-less flyweight matches that zero-alloc traversal rather than paying to materialize. + for (LightMap.EntryReader entry : lightMap) { + blackhole.consume(entry.key()); + blackhole.consume(entry.value()); + } + } + @Benchmark public void iterate_synchronizedHashMap(Blackhole blackhole) { // Collections.synchronizedMap requires the caller to synchronize during iteration; this is the From 209fdc1960124439ce10c0b7e6ddeca380e06563 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 30 Jul 2026 12:48:39 -0400 Subject: [PATCH 40/43] Add adaptive-sizing and embedded-spine LightMap benchmark arms Extends the LightMap comparison with: - create_lightMap_adaptive: create seeded from a held self-tuning hint, isolating resize churn from the small createUncapped() seed. - create/get/iterate _embedded (+ create _embedded_adaptive): the raw Object[] spine driven via EmbeddingSupport, no wrapper object, to measure the LightMap wrapper's own overhead. Measured (JDK 17, -prof gc, -t 1): create: adaptive sizing lifts throughput ~8.9M -> ~18.5M ops/s (wrapper) and ~8.0M -> ~19.5M (embedded) -- the uncapped-seed deficit was all resize churn. Allocation: 256 (wrapper) / 224 (embedded) uncapped; 240 / 208 adaptive -- entry-less stays well under HashMap's 448 B/op, and the wrapper itself costs ~32 B/op. get: all ~0 B/op; embedded ~190M, wrapper ~178M, HashMap ~185M ops/s. iterate: all ~0 B/op, ~18.4M ops/s -- the embedded iterable() lambda scalar-replaces alongside the flyweight, so even that indirection is free under escape analysis. Co-Authored-By: Claude Opus 4.8 --- .../util/SingleThreadedMapBenchmark.java | 93 ++++++++++++++++--- 1 file changed, 82 insertions(+), 11 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java index d61a899e127..b80792221c8 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java @@ -1,6 +1,9 @@ package datadog.trace.util; import datadog.trace.api.TagMap; +import datadog.trace.util.LightMap.AdaptiveSizingHint; +import datadog.trace.util.LightMap.EmbeddingSupport; +import datadog.trace.util.LightMap.EntryReader; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; @@ -35,22 +38,23 @@ * builder idioms *

  • LightMap — a tiny, entry-less open-addressed map for short-lived, miss-dominated maps that * do not need the {@code java.util.Map} interface; allocation-light, and its for-each - * iteration is shaped so escape analysis eliminates the iterator (see {@code iterate_lightMap}) + * iteration is shaped so escape analysis eliminates the iterator (see {@code + * iterate_lightMap}) *
  • TreeMap — when a custom Comparator is needed (see CaseInsensitiveMapBenchmark) *
  • LinkedHashMap — only when insertion-order iteration is required; cost is paid at * construction and in per-entry memory * * - *

    Allocation-free iteration. {@code iterate_lightMap} exercises the entry-less - * {@link LightMap} for-each: the flyweight iterator is both the {@code Iterator} and the - * {@code EntryReader} it yields, so with a concretely-typed map that lets it stay non-escaping, - * escape analysis scalar-replaces it. Measured (JDK 17, {@code -prof gc}, {@code -t 1}) at - * {@code gc.alloc.rate.norm ≈ 10^-5 B/op} -- i.e. zero. The point is not that this beats - * {@code iterate_hashMap}: HashMap's iterator likewise scalar-replaces and reuses its stored - * {@code Node} as the entry, so it is also ~0 B/op. The point is that LightMap's entry-less layout - * (no per-entry object to hand out) costs nothing at iteration time -- the flyweight matches - * HashMap's zero-alloc traversal rather than paying to materialize entries. Re-run with - * {@code -prof gc} to confirm both arms stay at ~0 B/op. + *

    Allocation-free iteration. {@code iterate_lightMap} exercises the entry-less {@link + * LightMap} for-each: the flyweight iterator is both the {@code Iterator} and the {@code + * EntryReader} it yields, so with a concretely-typed map that lets it stay non-escaping, escape + * analysis scalar-replaces it. Measured (JDK 17, {@code -prof gc}, {@code -t 1}) at {@code + * gc.alloc.rate.norm ≈ 10^-5 B/op} -- i.e. zero. The point is not that this beats {@code + * iterate_hashMap}: HashMap's iterator likewise scalar-replaces and reuses its stored {@code Node} + * as the entry, so it is also ~0 B/op. The point is that LightMap's entry-less layout (no per-entry + * object to hand out) costs nothing at iteration time -- the flyweight matches HashMap's zero-alloc + * traversal rather than paying to materialize entries. Re-run with {@code -prof gc} to confirm both + * arms stay at ~0 B/op. * *

    Uncontended synchronization tax. A {@link Collections#synchronizedMap} case is included * to measure what synchronization costs when there is no contention: because each thread @@ -102,6 +106,26 @@ static LightMap fillLightMap(LightMap map) { return map; } + // The "embedded" analog of fillLightMap: no LightMap wrapper object at all -- the caller owns a + // raw Object[] spine and drives EmbeddingSupport static functions over it. set() returns the + // (possibly grown) array, mirroring how a consumer that has "dropped a level" would hold it. + static Object[] fillLightMapEmbedded() { + Object[] data = null; + for (int i = 0; i < INSERTION_KEYS.length; ++i) { + data = EmbeddingSupport.set(data, INSERTION_KEYS[i], i); + } + return data; + } + + // Embedded fill seeded from a self-tuning hint (spine counterpart of LightMap.create(hint)). + static Object[] fillLightMapEmbedded(AdaptiveSizingHint hint) { + Object[] data = null; + for (int i = 0; i < INSERTION_KEYS.length; ++i) { + data = EmbeddingSupport.set(hint, data, INSERTION_KEYS[i], i); + } + return data; + } + // Per-thread prebuilt maps for the read + clone benchmarks (built once per trial, per thread). HashMap hashMap; Map synchronizedHashMap; @@ -109,6 +133,13 @@ static LightMap fillLightMap(LightMap map) { LinkedHashMap linkedHashMap; TagMap tagMap; LightMap lightMap; + // Minted once and reused across every create_lightMap_adaptive invocation, mirroring the intended + // static-final-per-site usage. Warmup iterations let it converge to the fill size, so the + // measured + // creates seed a right-sized table instead of resizing up from the small createUncapped() seed. + AdaptiveSizingHint lightMapSizingHint; + // Prebuilt raw spine (no wrapper) for the embedded get / iterate arms. + Object[] lightMapData; int index = 0; @Setup(Level.Trial) @@ -122,6 +153,8 @@ public void setUp() { fill(linkedHashMap); tagMap = fillTagMap(TagMap.create()); lightMap = fillLightMap(LightMap.createUncapped()); + lightMapSizingHint = LightMap.createUncappedAdaptiveSizingHint(); + lightMapData = fillLightMapEmbedded(); } String nextLookupKey() { @@ -187,6 +220,27 @@ public LightMap create_lightMap() { return fillLightMap(LightMap.createUncapped()); } + @Benchmark + public LightMap create_lightMap_adaptive() { + // Same fill, but seeded from the self-tuning hint held across invocations -- isolates how much + // of create_lightMap's cost is resize churn from the small createUncapped() seed. + return fillLightMap(LightMap.create(lightMapSizingHint)); + } + + @Benchmark + public Object[] create_lightMap_embedded() { + // No wrapper object -- build straight over a raw Object[] spine. The delta to create_lightMap + // is + // the LightMap wrapper's own allocation/overhead. + return fillLightMapEmbedded(); + } + + @Benchmark + public Object[] create_lightMap_embedded_adaptive() { + // Embedded + hint-seeded: the leanest create path (no wrapper, right-sized first table). + return fillLightMapEmbedded(lightMapSizingHint); + } + // ---- copy ---- @Benchmark @@ -233,6 +287,11 @@ public Integer get_lightMap() { return lightMap.get(nextLookupKey()); } + @Benchmark + public Object get_lightMap_embedded() { + return EmbeddingSupport.get(lightMapData, nextLookupKey()); + } + @Benchmark public void iterate_hashMap(Blackhole blackhole) { for (Map.Entry entry : hashMap.entrySet()) { @@ -254,6 +313,18 @@ public void iterate_lightMap(Blackhole blackhole) { } } + @Benchmark + public void iterate_lightMap_embedded(Blackhole blackhole) { + // Embedded for-each over the raw spine via EmbeddingSupport.iterable(). This adds an Iterable + // lambda on top of the flyweight iterator versus the object tier's direct iterator(); -prof gc + // shows whether escape analysis still folds both away to ~0 B/op. + for (EntryReader entry : + EmbeddingSupport.iterable(lightMapData)) { + blackhole.consume(entry.key()); + blackhole.consume(entry.value()); + } + } + @Benchmark public void iterate_synchronizedHashMap(Blackhole blackhole) { // Collections.synchronizedMap requires the caller to synchronize during iteration; this is the From ee09c377fdeea184689297ce49f31dd04a3bdd58 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 30 Jul 2026 12:59:51 -0400 Subject: [PATCH 41/43] Steer LightMap factory javadoc toward explicit sizing / hints The plain createUncapped()/createCapped(max) seed is sized for a handful of entries; a larger map takes one resize on the way up, which roughly halves build throughput (createUncapped) with no allocation win. Point callers who will hold more than the default toward an explicit capacity or a reused AdaptiveSizingHint, and cite SingleThreadedMapBenchmark (create_lightMap vs create_lightMap_adaptive) as the proof. Also document that the EmbeddingSupport hatch buys back only the wrapper object (~32 B/op) and nothing else -- lookup and iteration are identical -- so it is rarely worth reaching for (the *_embedded benchmark arms). Co-Authored-By: Claude Opus 4.8 --- .../java/datadog/trace/util/LightMap.java | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/LightMap.java b/internal-api/src/main/java/datadog/trace/util/LightMap.java index 2b18c439429..b11d58edf40 100644 --- a/internal-api/src/main/java/datadog/trace/util/LightMap.java +++ b/internal-api/src/main/java/datadog/trace/util/LightMap.java @@ -26,7 +26,11 @@ *

    This class is not thread-safe. * *

    Map data is stored in a single flat array that can be embedded into another object via {@link - * EmbeddingSupport} as a further optimization. + * EmbeddingSupport} as a further optimization -- an expert hatch that drops the {@code LightMap} + * wrapper and drives the caller-owned array directly. It buys only the wrapper object back (about + * one object header, ~32 B per map) and nothing else: lookup and iteration are otherwise identical. + * Reach for it only when that per-map word matters at scale -- see the {@code *_embedded} arms in + * {@code SingleThreadedMapBenchmark} for the (marginal) delta. */ /* * Keys are stored in the first half of the array and values in the second half. @@ -91,7 +95,16 @@ private LightMap(@Nonnull AdaptiveSizingHint hint) { this.maxSlots = hint.maxSlots(); } - /** A new, uncapped map seeded at the default capacity. The "just give me a map" front door. */ + /** + * A new, uncapped map seeded at the default capacity ({@value #DEFAULT_CAPACITY} slots). The + * "just give me a map" front door, sized for a handful of entries. + * + *

    If the map will routinely hold more than about {@value #DEFAULT_CAPACITY} entries, prefer + * {@link #createUncapped(int)} with a rough size, or better a reused {@link + * #createUncappedAdaptiveSizingHint() AdaptiveSizingHint}. Left at the default seed, a larger map + * takes a resize as it grows, which roughly halves build throughput -- see {@code + * SingleThreadedMapBenchmark} ({@code create_lightMap} vs {@code create_lightMap_adaptive}). + */ @Nonnull public static LightMap createUncapped() { return new LightMap<>(DEFAULT_CAPACITY, NO_MAX_SLOTS); @@ -112,6 +125,10 @@ public static LightMap createUncapped(int capacity) { * the default capacity (clamped to the cap). Once the table is physically full at the cap, {@link * #set} rejects a genuinely new key (returns {@code false}) instead of growing further -- a * thought-free way to bound worst-case memory without minting an {@link AdaptiveSizingHint}. + * + *

    Like {@link #createUncapped()}, this seeds for a handful of entries; if the map will + * routinely hold more, give an explicit seed via {@link #createCapped(int, int)} or reuse a + * {@link #createCappedAdaptiveSizingHint(int)} to avoid a resize on the way up. */ @Nonnull public static LightMap createCapped(int maxCapacity) { From 507e49e0cac94acafa0ce64cda386b10b34227db Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 30 Jul 2026 13:13:28 -0400 Subject: [PATCH 42/43] Drop vestigial type parameter from findInsertionSlot overloads All three findInsertionSlot overloads declared a that appears nowhere in their signature or body -- no caller binds it. Pure leftover cruft; removing it is behavior-neutral and tidies the spine's public surface. Co-Authored-By: Claude Opus 4.8 --- internal-api/src/main/java/datadog/trace/util/LightMap.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/LightMap.java b/internal-api/src/main/java/datadog/trace/util/LightMap.java index b11d58edf40..dbc1d1ba121 100644 --- a/internal-api/src/main/java/datadog/trace/util/LightMap.java +++ b/internal-api/src/main/java/datadog/trace/util/LightMap.java @@ -717,7 +717,7 @@ public static boolean remove(@Nullable Object[] mapData, @Nonnull Object key) { } } - public static int findInsertionSlot(@Nullable Object[] mapData, @Nonnull Object key) { + public static int findInsertionSlot(@Nullable Object[] mapData, @Nonnull Object key) { if (mapData == null) return SLOT_CAPACITY_REACHED; return findInsertionSlot(mapData, numSlots(mapData), key); @@ -750,12 +750,12 @@ public static final Object[] insertAt( return mapData; } - static final int findInsertionSlot( + static final int findInsertionSlot( @Nonnull Object[] mapData, int numSlots, @Nonnull Object key) { return findInsertionSlot(mapData, numSlots, key, preferredSlot(numSlots, key.hashCode())); } - static final int findInsertionSlot( + static final int findInsertionSlot( @Nonnull Object[] mapData, int numSlots, @Nonnull Object key, int preferredSlot) { int availableIndex = SLOT_CAPACITY_REACHED; for (int keyIndex = preferredSlot; keyIndex < numSlots; ++keyIndex) { From 34741d0fcdf092aeecfda40d63316efab88bb3c3 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 30 Jul 2026 14:27:21 -0400 Subject: [PATCH 43/43] Suppress forbidden System.out in LightMapGrowBenchmark CLI report forbiddenApisJmh flags System.out on the benchmark's main()/reportRow -- but those are the deterministic CLI analysis report (grow frequency and probe-length distribution), not agent logging. Annotate the two methods with @SuppressForbidden, matching the check's own guidance. Co-Authored-By: Claude Opus 4.8 --- .../src/jmh/java/datadog/trace/util/LightMapGrowBenchmark.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal-api/src/jmh/java/datadog/trace/util/LightMapGrowBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/LightMapGrowBenchmark.java index 1c4ddf36593..10cd0a0faea 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/LightMapGrowBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/LightMapGrowBenchmark.java @@ -1,6 +1,7 @@ package datadog.trace.util; import datadog.trace.util.LightMap.EmbeddingSupport; +import de.thetaphi.forbiddenapis.SuppressForbidden; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -270,6 +271,7 @@ static double mean(int[] xs) { return (double) sum / xs.length; } + @SuppressForbidden // System.out: this is a CLI analysis report, not agent logging. public static void main(String[] args) { int[] ks = {4, 8}; System.out.printf( @@ -287,6 +289,7 @@ public static void main(String[] args) { } } + @SuppressForbidden // System.out: this is a CLI analysis report, not agent logging. static void reportRow(Regime regime, Trigger trigger, int k, String[] keys) { Cursor c = new Cursor(); for (int i = 0; i < keys.length; i++) {