diff --git a/internal-api/src/jmh/java/datadog/trace/util/LightMapGrowBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/LightMapGrowBenchmark.java new file mode 100644 index 00000000000..10cd0a0faea --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/util/LightMapGrowBenchmark.java @@ -0,0 +1,314 @@ +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; +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 LightMap}, to decide whether to reframe "when + * to grow" from a load-factor threshold to a maximum-probe bound. + * + * + * + *

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 LightMapGrowBenchmark { + + 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.SLOT_CAPACITY_REACHED) { + 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; + } + + @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( + "%-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(); + } + } + + @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++) { + 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]); + } +} 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..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; @@ -33,11 +36,26 @@ *

  • (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 +99,47 @@ 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; + } + + // 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; TreeMap treeMap; 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) @@ -99,6 +152,9 @@ public void setUp() { linkedHashMap = new LinkedHashMap<>(); fill(linkedHashMap); tagMap = fillTagMap(TagMap.create()); + lightMap = fillLightMap(LightMap.createUncapped()); + lightMapSizingHint = LightMap.createUncappedAdaptiveSizingHint(); + lightMapData = fillLightMapEmbedded(); } String nextLookupKey() { @@ -159,6 +215,32 @@ public TagMap create_tagMap_via_ledger() { return ledger.build(); } + @Benchmark + 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 @@ -200,6 +282,16 @@ public Integer get_synchronizedHashMap() { return synchronizedHashMap.get(nextLookupKey()); } + @Benchmark + 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()) { @@ -208,6 +300,31 @@ 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_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 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..dbc1d1ba121 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/util/LightMap.java @@ -0,0 +1,1061 @@ +package datadog.trace.util; + +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; +import javax.annotation.Nullable; + +/** + * 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}, {@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 + * 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 -- 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. + * Key collisions are resolved via linear probing. + * + * 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 + * 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. + * + * 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 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 + // 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). + 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; 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 AdaptiveSizingHint sizingHint; + private Object[] data = EmbeddingSupport.EMPTY_DATA; + + private LightMap(int capacity, int maxSlots) { + this.initialCapacity = capacity; + this.sizingHint = null; + this.maxSlots = maxSlots; + } + + private LightMap(@Nonnull AdaptiveSizingHint hint) { + this.sizingHint = hint; + this.initialCapacity = hint.seedSlots(); + this.maxSlots = hint.maxSlots(); + } + + /** + * 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); + } + + /** + * 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 #createUncappedAdaptiveSizingHint()}. + */ + @Nonnull + public static LightMap createUncapped(int capacity) { + return new LightMap<>(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}. + * + *

    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) { + int max = EmbeddingSupport.roundUpToPow2(maxCapacity); + int seed = Math.min(DEFAULT_CAPACITY, max); + return new LightMap<>(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 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 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 #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) { + return new LightMap<>(hint); + } + + /** + * 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 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(); + } + + /** + * 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 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}. + */ + 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 + // 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); + 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 + // 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) { + this.sizingHint.recordSlots(afterSlots); + } + } + } + + @Nullable + public V get(@Nonnull K key) { + return EmbeddingSupport.get(this.data, key); + } + + public void remove(@Nonnull K key) { + EmbeddingSupport.remove(this.data, key); + } + + /** The number of live entries in this map (tombstones excluded). */ + public int size() { + return EmbeddingSupport.size(this.data); + } + + public boolean containsKey(@Nonnull K key) { + return EmbeddingSupport.containsKey(this.data, key); + } + + @SuppressWarnings("unchecked") + 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((K) key, (V) mapData[slot + numSlots]); + } + } + + /** + * 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() { + return this.data; + } + + /** + * A self-tuning, per-construction-site sizing estimate. Mint one via {@link + * 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 + * #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 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 + // class of headroom), multiplicative-decrease on the decay tick. + 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 AdaptiveSizingHint(int seedSlots, int maxSlots) { + this.slots = seedSlots; + this.maxSlots = maxSlots; + } + + /** + * 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() { + return new Builder(); + } + + // 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 + * 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 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; + } + return this.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} 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; + int ceiling = Math.min(MAX_HINT_SLOTS, this.maxSlots); + if (candidate > ceiling) { + candidate = ceiling; + } + if (candidate > this.slots) { + this.slots = candidate; + } + } + + // Visible for testing: the current learned seed capacity in slots. + 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 { + // 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 + // 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 (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 + // 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; + + // 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; + + public static boolean isDefinitelyEmpty(@Nullable Object[] mapData) { + return (mapData == null); + } + + public static int numSlots(@Nullable Object[] mapData) { + return mapData == null ? 0 : mapData.length >> 1; + } + + public static int size(@Nullable 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; + } + + @Nullable + public static Object[] clear(@Nullable Object[] mapData) { + if (mapData != null) { + Arrays.fill(mapData, null); + } + return mapData; + } + + @Nullable + public static Object[] copy(@Nullable Object[] mapData) { + return mapData == null ? null : mapData.clone(); + } + + public static boolean isPresent(int slotIndex) { + return (slotIndex >= 0); + } + + public static boolean isRemoved(@Nullable Object key) { + return (key == REMOVED); + } + + @Nullable + 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 : key; + } + + @SuppressWarnings("unchecked") + @Nullable + public static V valueAt(@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 Object key) { + if (mapData == null) return false; + + int numSlots = numSlots(mapData); + + int hash = key.hashCode(); + int preferredSlot = preferredSlot(numSlots, hash); + + // 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; + } + + 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) { + Object curValue = mapData[valueIndex]; + if (value.equals(curValue)) return true; + } + return false; + } + + @Nonnull + public static Object[] set( + @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 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); + } + + /** + * 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 + * 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 AdaptiveSizingHint hint, + @Nullable Object[] mapData, + @Nonnull Object key, + @Nonnull V value) { + int beforeSlots = numSlots(mapData); + // 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 LightMap.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 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, + // 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 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. + Objects.requireNonNull(value, "value"); + if (mapData == null) { + return newMapData(initialCapacity, key, value); + } + + int numSlots = numSlots(mapData); + // 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; + } + + 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) { + 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; + } + mapData[availableSlot] = key; + mapData[availableSlot + numSlots] = value; + return mapData; + } + + @SuppressWarnings("unchecked") + @Nullable + public static V get(@Nullable Object[] mapData, @Nonnull Object 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(@Nullable Object[] mapData, @Nonnull Object 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 int findInsertionSlot(@Nullable Object[] mapData, @Nonnull Object key) { + if (mapData == null) return SLOT_CAPACITY_REACHED; + + return findInsertionSlot(mapData, numSlots(mapData), key); + } + + @Nonnull + public static final Object[] insertAt( + int initialCapacity, + @Nullable Object[] mapData, + int insertionSlot, + @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. + Objects.requireNonNull(value, "value"); + if (mapData == null) { + return newMapData(initialCapacity, key, value); + } + + if (insertionSlot == SLOT_CAPACITY_REACHED) { + 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( + @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 Object 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 != SLOT_CAPACITY_REACHED) ? availableIndex : flip(keyIndex); + if (curKey == key) return keyIndex; + if (curKey == REMOVED) { + 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 != SLOT_CAPACITY_REACHED) ? availableIndex : flip(keyIndex); + if (curKey == key) return keyIndex; + if (curKey == REMOVED) { + 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; + } + + public static final void removeAt(@Nullable Object[] mapData, int slot) { + if (mapData == null || slot < 0) return; + + mapData[slot] = REMOVED; + mapData[slot + numSlots(mapData)] = null; + } + + @Nullable + public static final Object getAndRemoveAt(@Nullable 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(@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 Object key) { + int hash = key.hashCode(); + int preferredSlot = preferredSlot(numSlots, hash); + + // 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; + } + + @Nonnull + static final Object[] newMapData( + int initialCapacity, @Nonnull Object key, @Nonnull 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; + } + + @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); + } + + @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 + // 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) { + Object key = origMapData[slot]; + if (key == null || key == REMOVED) continue; + + Object value = origMapData[slot + origNumSlots]; + newMapUncheckedInsert(newMapData, newCapacity, key, value); + } + return newMapData; + } + + @SuppressWarnings("unchecked") + public static void forEach( + @Nullable Object[] mapData, @Nonnull BiConsumer entryConsumer) { + if (mapData == null) return; + + int numSlots = numSlots(mapData); + for (int slot = 0; slot < numSlots; ++slot) { + Object key = mapData[slot]; + if (key == null || key == REMOVED) continue; + + Object value = mapData[slot + numSlots]; + entryConsumer.accept((K) key, (V) value); + } + } + + @SuppressWarnings("unchecked") + public static void forEach( + @Nullable Object[] mapData, + @Nullable C ctx, + @Nonnull TriConsumer entryConsumer) { + if (mapData == null) return; + + int numSlots = numSlots(mapData); + for (int slot = 0; slot < numSlots; ++slot) { + Object key = mapData[slot]; + if (key == null || key == REMOVED) continue; + + Object value = mapData[slot + numSlots]; + entryConsumer.accept(ctx, (K) key, (V) value); + } + } + + /** + * 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); + builder.append('{'); + + int numSlots = numSlots(mapData); + for (int slot = 0; slot < numSlots; ++slot) { + Object key = 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( + @Nonnull Object[] mapData, int numSlots, @Nonnull Object key, @Nonnull 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; + } + } +} 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..208fb5d9174 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/util/LightMapTest.java @@ -0,0 +1,1119 @@ +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.assertSame; +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; + +class LightMapTest { + + // ============ Instance API ============ + + @Nested + class InstanceTests { + + @Test + void getOnEmptyMapReturnsNull() { + LightMap map = LightMap.createUncapped(LightMap.DEFAULT_CAPACITY); + assertNull(map.get("absent")); + } + + @Test + void setThenGet() { + LightMap map = LightMap.createUncapped(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 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); + map.set("a", 1); + map.set("a", 42); + assertEquals(42, map.get("a")); + } + + @Test + void removeMakesKeyAbsent() { + LightMap map = LightMap.createUncapped(8); + map.set("a", 1); + map.set("b", 2); + map.remove("a"); + assertNull(map.get("a")); + assertEquals(2, map.get("b")); + } + + @Test + void containsKeyDistinguishesPresenceFromAbsence() { + LightMap map = LightMap.createUncapped(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() { + LightMap map = LightMap.createUncapped(8); + assertThrows(NullPointerException.class, () -> map.set("a", null)); + assertFalse(map.containsKey("a")); + } + + @Test + void sizeTracksLiveEntries() { + LightMap map = LightMap.createUncapped(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 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. + LightMap map = LightMap.createUncapped(8); + for (int i = 0; i < 8; i++) { + map.set("k" + i, 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; + + LightMap spread = LightMap.createUncapped(8); + for (int i = 0; i < count; i++) { + spread.set("spread." + i, i); + } + + LightMap clustered = LightMap.createUncapped(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 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); + } + + LightMap map = LightMap.createUncapped(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); + LightMap map = LightMap.createUncapped(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. + LightMap map = LightMap.createUncapped(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 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); + 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 = 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"); + 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; + } + + // 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() { + LightMap map = LightMap.createUncapped(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")); + } + + @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 ============ + + @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.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; + 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)); + } + + // 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 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; + 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)); + // negative slot -> absent + assertNull(EmbeddingSupport.keyAt(data, -1)); + assertFalse(EmbeddingSupport.isPresent(-1)); + } + + @Test + 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(EmbeddingSupport.SLOT_NOT_FOUND, EmbeddingSupport.findSlot(data, "absent")); + assertEquals(EmbeddingSupport.SLOT_NOT_FOUND, EmbeddingSupport.findSlot(null, "absent")); + assertEquals(-1, EmbeddingSupport.SLOT_NOT_FOUND); + } + + @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.SLOT_CAPACITY_REACHED, "a", "A"); + assertEquals("A", EmbeddingSupport.get(data, "a")); + } + + @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"))); + } + } + + @Nested + class AdaptiveSizingHintTests { + + @Test + void freshHintSeedsAtDefault() { + LightMap.AdaptiveSizingHint hint = LightMap.createUncappedAdaptiveSizingHint(); + assertEquals(LightMap.DEFAULT_HINT_SLOTS, hint.currentSeedSlots()); + } + + @Test + void hintSeedsAFreshMapAtItsLearnedCapacity() { + 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); + Object[] data = map.dataForTesting(); + assertEquals(LightMap.DEFAULT_HINT_SLOTS, EmbeddingSupport.numSlots(data)); + } + + @Test + void growthRaisesSeedWithOneClassOfHeadroom() { + 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); + for (int i = 0; i < LightMap.DEFAULT_HINT_SLOTS + 1; i++) { + map.set("k" + i, i); + } + int grownSlots = EmbeddingSupport.numSlots(map.dataForTesting()); + assertEquals(grownSlots * 2, hint.currentSeedSlots()); + } + + @Test + void seedIsMonotonicMaxAcrossMaps() { + LightMap.AdaptiveSizingHint hint = LightMap.createUncappedAdaptiveSizingHint(); + // A big map ratchets the hint up. + LightMap big = LightMap.create(hint); + for (int i = 0; i < 20; i++) { + big.set("k" + i, i); + } + int learned = hint.currentSeedSlots(); + assertTrue(learned > LightMap.DEFAULT_HINT_SLOTS); + // A subsequent tiny map does not lower the learned seed. + LightMap small = LightMap.create(hint); + small.set("a", 1); + assertEquals(learned, hint.currentSeedSlots()); + } + + @Test + void decayStepsSeedDownAfterInterval() { + 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++) { + 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 < LightMap.DECAY_INTERVAL; i++) { + LightMap.create(hint); + } + assertEquals(learned / 2, hint.currentSeedSlots()); + } + + @Test + void decayFloorsAtMinimum() { + 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++) { + LightMap.create(hint); + } + assertEquals(LightMap.MIN_HINT_SLOTS, hint.currentSeedSlots()); + } + + @Test + void seedIsCappedAtMax() { + 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++) { + big.set("k" + i, i); + } + assertTrue(hint.currentSeedSlots() <= LightMap.MAX_HINT_SLOTS); + assertEquals(LightMap.MAX_HINT_SLOTS, hint.currentSeedSlots()); + } + + @Test + void oneDecayStepStaysSafeThenSecondDecayRepins() { + 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); + 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 < LightMap.DECAY_INTERVAL; i++) { + LightMap.create(hint); + } + assertEquals(learned / 2, hint.currentSeedSlots()); + 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 < LightMap.DECAY_INTERVAL; i++) { + LightMap.create(hint); + } + assertEquals(learned / 4, hint.currentSeedSlots()); + LightMap recovered = LightMap.create(hint); + for (int i = 0; i < 20; i++) { + recovered.set("k" + i, i); + } + assertEquals(learned, hint.currentSeedSlots()); + } + + @Test + void hintSeededMapStoresAndReadsBackCorrectly() { + LightMap.AdaptiveSizingHint hint = LightMap.createUncappedAdaptiveSizingHint(); + LightMap map = LightMap.create(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)); + } + } + } + + // ============ hint-aware spine set(AdaptiveSizingHint, ...) overload ============ + + @Nested + class SpineHintTests { + + @Test + 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.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")); + } + + @Test + 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.createUncappedAdaptiveSizingHint(); + Object[] data = null; + for (int i = 0; i < LightMap.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.) + LightMap.AdaptiveSizingHint hint = + 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); + } + 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. + LightMap.AdaptiveSizingHint hint = LightMap.createUncappedAdaptiveSizingHint(); + 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 + class CapTests { + + @Test + void uncappedMapAlwaysReturnsTrueFromSet() { + // The plain capacity constructor is uncapped: set stores unconditionally and never rejects, + // even well past the initial capacity. + LightMap map = LightMap.createUncapped(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() { + // 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)); + } + 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. + 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"); + } + 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. + LightMap.AdaptiveSizingHint hint = LightMap.createCappedAdaptiveSizingHint(8); + LightMap map = LightMap.create(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. + LightMap.AdaptiveSizingHint hint = + 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"); + } + 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. + LightMap.AdaptiveSizingHint hint = LightMap.createCappedAdaptiveSizingHint(8); + LightMap map = LightMap.create(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. + LightMap.AdaptiveSizingHint hint = LightMap.createCappedAdaptiveSizingHint(5); + LightMap map = LightMap.create(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, + () -> LightMap.AdaptiveSizingHint.builder().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. + LightMap.AdaptiveSizingHint hint = + 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); + } + assertTrue( + hint.currentSeedSlots() <= 16, + "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. + LightMap map = LightMap.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. + LightMap map = LightMap.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. + LightMap map = LightMap.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. + 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"); + } + 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. + LightMap map = LightMap.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, () -> LightMap.createCapped(16, 8)); + } + } +}