diff --git a/dd-trace-core/src/main/java/datadog/trace/common/metrics/CardinalityLimitReporter.java b/dd-trace-core/src/main/java/datadog/trace/common/metrics/CardinalityLimitReporter.java index 215c278bef3..fc64b9015d7 100644 --- a/dd-trace-core/src/main/java/datadog/trace/common/metrics/CardinalityLimitReporter.java +++ b/dd-trace-core/src/main/java/datadog/trace/common/metrics/CardinalityLimitReporter.java @@ -32,9 +32,10 @@ final class CardinalityLimitReporter { // Distinct blocked tag names in a window: 9 property fields + the configured peer tags + up to // AdditionalTagsSchema.MAX_ADDITIONAL_TAG_KEYS + base.service, with headroom for the brief - // overlap - // of old and new peer names across a schema rebuild. Fixed capacity; the table chains on overflow - // rather than dropping, so an underestimate only adds chain depth on this cold path. + // overlap of old and new peer names across a schema rebuild. Fixed, strict-cap capacity: if this + // is ever underestimated, excess distinct tags are silently dropped from the summary rather than + // recorded (see the null-check in record()) -- this is a cold, best-effort logging path, not a + // correctness-sensitive one. private static final int TAG_CAPACITY = 64; // Rough width of one "=, " entry, used to pre-size the summary builder. Cold path, so @@ -43,7 +44,8 @@ final class CardinalityLimitReporter { private final RatelimitedLogger rlLog; // Tag name -> blocked count accumulated since the last emitted summary. - private final Hashtable.D1 blockedByTag = new Hashtable.D1<>(TAG_CAPACITY); + private final Hashtable.D1 blockedByTag = + Hashtable.D1.createCapped(TagBlockEntry.class, TAG_CAPACITY); CardinalityLimitReporter() { this(new RatelimitedLogger(log, 5, MINUTES)); @@ -56,7 +58,10 @@ final class CardinalityLimitReporter { /** Records {@code count} values blocked for {@code tag} in the current reporting cycle. */ void record(String tag, long count) { if (count > 0) { - blockedByTag.getOrCreate(tag, TagBlockEntry::new).count += count; + TagBlockEntry entry = blockedByTag.tryGetOrCreate(tag, TagBlockEntry::new); + if (entry != null) { + entry.count += count; + } } } diff --git a/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java index 62a48976691..ec067cefce2 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java @@ -274,9 +274,10 @@ static CIEntry[] _create_flat(float loadFactor) { } } // Mirror the HashMap/TreeMap builds' second loop (UPPER_PREFIXES, suffix 0 & 2): 8 case- - // insensitive collisions. getOrCreate finds the already-present lower-case entry (a hit -> the - // create never fires, nothing allocates) and then the value is overwritten explicitly -- getOr- - // Create itself never updates an existing entry, so without this the FlatHashtable arm would do + // insensitive collisions. tryGetOrCreate finds the already-present lower-case entry (a hit, so + // the create never fires and nothing allocates) and then the value is overwritten explicitly -- + // tryGetOrCreate itself never updates an existing entry, so without this the FlatHashtable arm + // would do // less work (and end up with different final values) than the maps' overwriting put(), a false // performance advantage. With the overwrite, all three create arms perform the same 24 // operations and end up with the same final values. @@ -284,7 +285,8 @@ static CIEntry[] _create_flat(float loadFactor) { for (String prefix : UPPER_PREFIXES) { String key = prefix + "-" + suffix; CIEntry entry = - FlatHashtable.getOrCreate(table, key, CaseInsensitiveKeyStrategy.INSTANCE, CI_CREATE); + FlatHashtable.tryGetOrCreate( + table, key, CaseInsensitiveKeyStrategy.INSTANCE, CI_CREATE); entry.value = suffix + 1; } } diff --git a/internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java index 9581a8db520..f9bcb0de96e 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java @@ -96,6 +96,34 @@ * substitute for {@code HashMap} particularly for simple counter/tally use cases with a primitive * value, where avoiding the per-update boxing allocation pays off even on a JVM with much better * allocation handling than JDK 8 had. + * + *

Rerun on the capped/{@code State}-backed table (5 forks, 15 datapoints/method, Zulu 17.0.7 + * AArch64, 8 threads). Not comparable to the table above: JMH auto-detected the {@code full + * + dont-inline} Blackhole here rather than the cheap {@code compiler} one, on the same JVM build + * and JMH 1.37 -- the mode is auto-detected per run and is not stable across runs, so every + * absolute number in this file is conditional on a mode that JMH does not record beside it. Compare + * within a table, never across. M ops/us: + * + *

{@code
+ * add_hashMap        1204.8   add_hashtable       974.4
+ * update_hashMap      577.2   update_hashtable   1862.6
+ * iterate_hashMap      15.9   iterate_hashtable    21.5
+ * }
+ * + *

Within this run: {@code update_hashtable} wins by ~3.2x and {@code iterate_hashtable} by + * ~1.35x, while {@code add_hashtable} now loses by ~19% -- no longer the "roughly + * comparable" of the JDK 8 table, and a wider gap than the slight edge HashMap held in the previous + * Java 17 run. {@code add} is where the capped table's bookkeeping is least amortized: both sides + * allocate one entry per insert, so there is no boxing win to offset it, and the loop does nothing + * else. The counter/tally path -- the case {@code Hashtable} exists for -- is unaffected. + * + *

That is the right side of the trade for this family. {@code Hashtable} and {@link + * ConcurrentHashtable} are designed for workloads where updates dominate: the table is + * populated once and then hit repeatedly, so per-insert cost amortizes away and in-place mutation + * of a primitive field is the operation that runs hot. Paying on {@code add} to make {@code update} + * faster is the trade those workloads want. {@code FlatHashtable} and {@code TagMap} sit at the + * other end -- built up and read, not updated in a loop -- so this result does not transfer to + * them, and neither does the reasoning that justifies it. */ @Fork(2) @Warmup(iterations = 2) @@ -143,11 +171,14 @@ public static class D1State { int cursor; final BhD1Consumer consumer = new BhD1Consumer(); + // Level.Iteration, not Trial: this rebuilds the table and the HashMap, so each iteration must + // start from a fresh, identically-sized state rather than inheriting mutated counters. The + // pollution call rides along -- it is idempotent and untimed, so repeating it costs nothing. @Setup(Level.Iteration) public void setUp() { BenchmarkUtils.polluteHashDispatch(); - table = new Hashtable.D1<>(CAPACITY); + table = Hashtable.D1.createCapped(D1Counter.class, CAPACITY); hashMap = new HashMap<>(CAPACITY); keys = SOURCE_KEYS; for (int i = 0; i < N_KEYS; ++i) { diff --git a/internal-api/src/jmh/java/datadog/trace/util/HashtableD2Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/HashtableD2Benchmark.java index 49357ab9a17..4f233b8524b 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/HashtableD2Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/HashtableD2Benchmark.java @@ -179,11 +179,14 @@ public static class D2State { int cursor; final BhD2Consumer consumer = new BhD2Consumer(); + // Level.Iteration, not Trial: this rebuilds the table and the HashMap, so each iteration must + // start from a fresh, identically-sized state rather than inheriting mutated counters. The + // pollution call rides along -- it is idempotent and untimed, so repeating it costs nothing. @Setup(Level.Iteration) public void setUp() { BenchmarkUtils.polluteHashDispatch(); - table = new Hashtable.D2<>(CAPACITY); + table = Hashtable.D2.createCapped(D2Counter.class, CAPACITY); hashMap = new HashMap<>(CAPACITY); k1s = SOURCE_K1; k2s = SOURCE_K2; diff --git a/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java b/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java index 4dc6bf5a2ec..f8f6731d9a7 100644 --- a/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java @@ -23,9 +23,9 @@ *

Concurrent use is racy by design, not lock-free-safe in general. The single-reference * guarantee above covers only the slot reference and an entry's {@code final} fields; a non-final * payload field written after construction is not safely published by a racing {@link - * #getOrCreate}, and a freshly built entry that loses the slot race is discarded without ever being - * retained by the table. That is fine for build-then-publish usage (populate on one thread, e.g. a - * static-final table, then read from many) and for a payload where a stale/default read or a + * #tryGetOrCreate}, and a freshly built entry that loses the slot race is discarded without ever + * being retained by the table. That is fine for build-then-publish usage (populate on one thread, + * e.g. a static-final table, then read from many) and for a payload where a stale/default read or a * discarded race-loser is benign (miss → recreate; clobber → one wins). For concurrent * creation of entries with meaningful post-construction state, keep entry state fully {@code * final} — do not rely on this class for safe publication of mutable entry fields. @@ -35,21 +35,41 @@ * the question whose unasked version becomes an unbounded-growth leak in a long-lived agent living * in someone else's process. A regular {@code Map}'s auto-resize lets you forget that (fine when * you own the heap; the wrong default when you are a guest in one). This table never grows on its - * own: {@link #get} / {@link #getOrCreate} / {@link #insert} cap rather than churn — a full - * table degrades to recompute-on-miss with bounded memory and no reallocation — and growth is an - * explicit, deliberate {@link #resize} / {@link #resizingInsert}. So it defaults to the + * own: {@link #get} / {@link #tryGetOrCreate} / {@link #insert} cap rather than churn — a + * full table degrades to recompute-on-miss with bounded memory and no reallocation — and growth is + * an explicit, deliberate {@link #resize} / {@link #resizingInsert}. So it defaults to the * bounded-footprint posture the agent needs, with unbounded growth an opt-in you have to reach for * (and one that, over externally-controlled keys, is the leak this structure otherwise prevents — * see {@link #resizingInsert}). The trade only pays when a miss is benign (a cache / interner), not * for a must-hold-everything map. * + *

Choosing between the three tables

+ * + *
    + *
  1. Concurrent access? Use {@code ConcurrentHashtable} -- the only thread-safe one of + * the three. This class is racy by design (see above), and {@code Hashtable} is not + * thread-safe at all. + *
  2. Otherwise: does the population reset wholesale, or evolve? A table cleared as a unit + * -- once per cycle, per request, or built and then discarded -- wants this class, whose open + * addressing has no tombstones and so offers no removal beyond clearing. A table whose + * entries come and go independently wants the chained {@code Hashtable}, which removes and + * evicts in place. + *
+ * + *

Lifetime is the usual shorthand for that second question and mostly works, because a + * short-lived table never needs to remove -- it just dies. The case it mis-sorts is a long-lived + * table that resets on a cycle: that is a sequence of short lives, and belongs with the short-lived + * ones. Compare a table that evicts stale entries one at a time while the busy ones survive the + * cycle (evolving -- {@code Hashtable}) against one that clears every entry each time it reports + * (resets -- this class). + * *

Strategy roles, split by concern. The per-use policy is a small set of {@link Strategy * strategy} objects rather than one, so a caller supplies only what an operation needs: * *

    *
  • a {@link MatchingStrategy} — the key side: {@link MatchingStrategy#hashKey hash a * lookup key} (defaults to {@code hashCode}) and {@link MatchingStrategy#matches match} it - * against a stored entry. Used by {@link #get} / {@link #getOrCreate}. + * against a stored entry. Used by {@link #get} / {@link #tryGetOrCreate}. *
  • a {@link HashStrategy} — the entry side: {@link HashStrategy#hashOf hash a stored * entry}. Used by {@link #insert} / {@link #iterator} / {@link #resize} (which have an entry, * not a key). For {@link Entry}-based tables this is just the cached {@link Entry#hash}, so @@ -63,7 +83,7 @@ *
    {@code
      * private static final MyStrategy S = new MyStrategy();          // concrete type => exact type pinned
      * ...
    - * E e = FlatHashtable.getOrCreate(table, key, S, MyEntry::new);  // non-capturing create
    + * E e = FlatHashtable.tryGetOrCreate(table, key, S, MyEntry::new);  // non-capturing create
      * }
    * *

    Contract: {@code table.length} must be a power of two ({@link #capacityFor}). Both @@ -73,7 +93,7 @@ * where the entry was placed (trivially true when both default to {@code hashCode}). Cardinality * cap / overflow / a live-size counter are caller policy (this class is pure mechanism): a * capped caller does {@link #get} first, and only on a miss checks its budget before {@link - * #getOrCreate} (so hits stay a single probe and the create path is warmup-rare). + * #tryGetOrCreate} (so hits stay a single probe and the create path is warmup-rare). */ public final class FlatHashtable { private FlatHashtable() {} @@ -96,20 +116,21 @@ protected Entry(long hash) { /** * Single-key, {@code HashMap}-style convenience over the {@linkplain FlatHashtable static core}: - * {@link #get} / {@link #getOrCreate} / {@link #insert} / {@link #forEach} without writing a + * {@link #get} / {@link #tryGetOrCreate} / {@link #insert} / {@link #forEach} without writing a * {@link MatchingStrategy}. Reach for it when you want something quick that beats {@code * HashMap} — the entry carries its own value fields, so updating an existing value is * allocation-free (look up once, then write the returned entry). * *

    Fixed or growable, chosen at construction. {@link #createFixed} keeps the raw core's - * bounded posture — the table holds up to {@code maxCapacity} entries, then {@link #getOrCreate} - * caps and returns {@code null} (the caller supplies the overflow default). {@link - * #createGrowable} trades that for {@code HashMap}-like ergonomics — its {@code initialCapacity} - * is a sizing hint, not a cap, the table doubles when it fills past its load factor, and {@code - * getOrCreate} never returns {@code null}. The distinct factory names make the choice explicit at - * the call site (there's no ambiguous {@code (Class, int)} constructor); {@code Capacity} always - * counts entries — contrast the chained {@code Hashtable.D1}, whose factory counts - * buckets. + * bounded posture — the table holds up to {@code maxCapacity} entries, then {@link + * #tryGetOrCreate} caps and returns {@code null} (the caller supplies the overflow default). + * {@link #createGrowable} trades that for {@code HashMap}-like ergonomics — its {@code + * initialCapacity} is a sizing hint, not a cap, the table doubles when it fills past its load + * factor, and {@code tryGetOrCreate} never returns {@code null}. The distinct factory names make + * the choice explicit at the call site (there's no ambiguous {@code (Class, int)} constructor); + * {@code Capacity} always counts entries, matching the chained {@code + * Hashtable.D1.createCapped}. Across the family a table factory's number is always entries — only + * the low-level array allocators take a bucket count. * *

    Entry-centric, not strategy-based. Supply a {@link D1.Entry} subclass carrying the * key and value fields; key equality is {@link Object#equals} by default (override {@link @@ -176,7 +197,7 @@ private D1(TEntry[] table, float loadFactor, boolean growable, int limit) { /** * A bounded {@link D1} holding up to {@code maxCapacity} entries at the {@link - * #DEFAULT_LOAD_FACTOR}, then capping ({@link #getOrCreate} returns {@code null}). + * #DEFAULT_LOAD_FACTOR}, then capping ({@link #tryGetOrCreate} returns {@code null}). */ @Nonnull public static > D1 createFixed( @@ -239,9 +260,15 @@ public TEntry get(@Nullable K key) { * one. A growable table never returns {@code null}; a fixed one returns {@code null} when full * and {@code key} is absent (the caller supplies the overflow default). A hit is always * returned even at capacity — the cap blocks only creation, not lookup. + * + *

    The {@code try} prefix marks "this may refuse" — a growable table simply never exercises + * it. The name has to serve both postures, since the posture is chosen per instance at the + * factory while the method name is per class, and the two mistakes are not symmetric: + * under-promising refusal costs an NPE at the cap, over-promising it costs a redundant null + * check. So it errs toward {@code try}. */ @Nullable - public TEntry getOrCreate(@Nullable K key, @Nonnull CreateStrategy createStrat) { + public TEntry tryGetOrCreate(@Nullable K key, @Nonnull CreateStrategy createStrat) { final TEntry existing = get(key); if (existing != null) { return existing; @@ -300,7 +327,7 @@ public void forEach(C context, @Nonnull BiConsumer}. Same fixed-or-growable ({@link #createFixed} / {@link #createGrowable}), * entry-centric, no-{@code remove}, not-thread-safe contract as {@link D1}. * @@ -366,7 +393,7 @@ private D2(TEntry[] table, float loadFactor, boolean growable, int limit) { /** * A bounded {@link D2} holding up to {@code maxCapacity} entries at the {@link - * #DEFAULT_LOAD_FACTOR}, then capping ({@link #getOrCreate} returns {@code null}). + * #DEFAULT_LOAD_FACTOR}, then capping ({@link #tryGetOrCreate} returns {@code null}). */ @Nonnull public static > D2 createFixed( @@ -425,11 +452,11 @@ public TEntry get(@Nullable K1 key1, @Nullable K2 key2) { } /** - * Two-key analogue of {@link D1#getOrCreate}: growable never returns {@code null}; fixed + * Two-key analogue of {@link D1#tryGetOrCreate}: growable never returns {@code null}; fixed * returns {@code null} when full and {@code (key1, key2)} is absent. */ @Nullable - public TEntry getOrCreate( + public TEntry tryGetOrCreate( @Nullable K1 key1, @Nullable K2 key2, @Nonnull CreateStrategy2 createStrat) { @@ -486,7 +513,7 @@ public void forEach(C context, @Nonnull BiConsumernon-capturing lambda (e.g. {@code MyEntry::new}) so it stays a single monomorphic, * allocation-free instance. @@ -523,7 +550,7 @@ public interface HashStrategy { * #matches}), and how to hash that key ({@link #hashKey}). {@code hashKey} defaults to {@code * key.hashCode()} — override it only when the key's identity needs different hashing (e.g. * case-insensitive), and then keep it consistent with the table's {@link HashStrategy#hashOf}. - * Used by {@link #get} / {@link #getOrCreate}. + * Used by {@link #get} / {@link #tryGetOrCreate}. * *

    A {@link FunctionalInterface} ({@code matches} is the sole abstract method), so the common * case can be a non-capturing lambda; a strategy that also customizes hashing is a named class @@ -704,7 +731,7 @@ public static E get( */ @StrategyConsumer @Nullable - public static E getOrCreate( + public static E tryGetOrCreate( @Nonnull E[] table, K key, @Nonnull MatchingStrategy matchStrat, diff --git a/internal-api/src/main/java/datadog/trace/util/Hashtable.java b/internal-api/src/main/java/datadog/trace/util/Hashtable.java index a2fbfc62ad1..3692a365f76 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -1,5 +1,6 @@ package datadog.trace.util; +import java.lang.reflect.Array; import java.util.Arrays; import java.util.Iterator; import java.util.NoSuchElementException; @@ -8,6 +9,10 @@ import java.util.function.BiFunction; import java.util.function.Consumer; import java.util.function.Function; +import java.util.function.ObjLongConsumer; +import java.util.function.Predicate; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; /** * Light weight simple Hashtable system that can be useful when HashMap would be unnecessarily @@ -23,10 +28,33 @@ * Convenience classes are provided for lower key dimensions. * *

    For higher key dimensions, client code must implement its own class, but can still use the - * support class to ease the implementation complexity. + * static building blocks on this class to ease the implementation complexity. + * + *

    Choosing between the three tables

    + * + *
      + *
    1. Concurrent access? Use {@code ConcurrentHashtable} -- the only thread-safe one of + * the three. {@code FlatHashtable} is racy by design, and this class is not thread-safe at + * all. + *
    2. Otherwise: does the population reset wholesale, or evolve? A table cleared as a unit + * -- once per cycle, per request, or built and then discarded -- wants {@code FlatHashtable}, + * whose open addressing has no tombstones and so offers no removal beyond clearing. A table + * whose entries come and go independently wants this one, where chaining removes and evicts + * in place. + *
    + * + *

    Lifetime is the usual shorthand for that second question and mostly works, because a + * short-lived table never needs to remove -- it just dies. The case it mis-sorts is a long-lived + * table that resets on a cycle: that is a sequence of short lives, and belongs with the short-lived + * ones. Compare a table that evicts stale entries one at a time while the busy ones survive the + * cycle (evolving -- this class) against one that clears every entry each time it reports (resets + * -- {@code FlatHashtable}). * *

    This outer class is a pure namespace -- it can't be instantiated. The actual table types are - * {@link D1}, {@link D2}, and (for higher-arity callers) {@link Support}-driven custom tables. + * {@link D1}, {@link D2}, and (for higher-arity callers) custom tables driven by the static + * building blocks on this class (see {@link #create(Class, int)}, {@link + * #bucketFor(Hashtable.Entry[], long)}, {@link #insertHeadEntryAt(Hashtable.Entry[], int, + * Hashtable.Entry)}, and friends). */ public final class Hashtable { private Hashtable() {} @@ -37,7 +65,8 @@ private Hashtable() {} * *

    Subclasses add the actual key field(s) and a {@code matches(...)} method tailored to their * key arity. See {@link D1.Entry} and {@link D2.Entry}; for higher arities, client code can - * subclass this directly and use {@link Support} to drive the table mechanics. + * subclass this directly and drive the table with the static building blocks on {@link + * Hashtable}. */ public abstract static class Entry { public final long keyHash; @@ -47,11 +76,12 @@ protected Entry(long keyHash) { this.keyHash = keyHash; } - public final void setNext(TEntry next) { + public final void setNext(@Nullable TEntry next) { this.next = next; } @SuppressWarnings("unchecked") + @Nullable public final TEntry next() { return (TEntry) this.next; } @@ -68,8 +98,14 @@ public final TEntry next() { * Long>} and produces effectively zero GC pressure. * *

    Capacity is fixed at construction. The table does not resize, so the caller is responsible - * for choosing a capacity appropriate to the working set. Actual bucket-array length is rounded - * up to the next power of two. + * for choosing a capacity appropriate to the working set. Once {@link #size()} reaches that + * capacity, {@link #insert} returns {@code false} and {@link #tryGetOrCreate} returns {@code + * null} rather than adding more entries -- a lookup hit is still always returned even at + * capacity, the cap only blocks new entries. Want your own eviction policy instead of a hard cap? + * Drop down to the static building blocks and drive the bucket array yourself -- {@link + * Hashtable#createCapped(int)} hands you a spine and a {@link SizeManager} already matched to + * each other, and the manager evicts as well as counts. Actual bucket-array length is rounded up + * to the next power of two. * *

    Null keys are permitted; they collapse to a single bucket via the sentinel hash {@link * Long#MIN_VALUE} defined in {@link D1.Entry#hash}. @@ -94,17 +130,18 @@ public static final class D1> { public abstract static class Entry extends Hashtable.Entry { final K key; - protected Entry(K key) { + protected Entry(@Nullable K key) { super(hash(key)); this.key = key; } /** The key this entry was created with. */ + @Nullable public K key() { return this.key; } - public boolean matches(Object key) { + public boolean matches(@Nullable Object key) { return Objects.equals(this.key, key); } @@ -116,105 +153,267 @@ public boolean matches(Object key) { * [Integer.MIN_VALUE, Integer.MAX_VALUE]}; real-key collisions in chains are resolved by * {@link #matches(Object)}. */ - public static long hash(Object key) { + public static long hash(@Nullable Object key) { return (key == null) ? Long.MIN_VALUE : key.hashCode(); } } - // Package-private so iterator tests in the same package can drive Support.bucketIterator and - // friends directly against the table's bucket array. + // Package-private so iterator tests in the same package can drive the Hashtable static + // building blocks directly against the table's bucket array. final Hashtable.Entry[] buckets; - private int size; + private final SizeManager sizeManager; - public D1(int capacity) { - this.buckets = Support.create(capacity); - this.size = 0; + private D1(int maxCapacity) { + // Bucket array gets load-factor headroom over the strict entry cap below, so chains stay + // short even when the table is full; see Hashtable#capacityFor. + this.buckets = Hashtable.create(capacityFor(maxCapacity)); + this.sizeManager = new SizeManager(maxCapacity); + } + + /** + * A capped single-key table: it holds at most {@code maxCapacity} live entries, after + * which {@link #insert} returns {@code false} and {@link #tryGetOrCreate} returns {@code null}. + * A lookup hit is still always returned at capacity -- the cap only blocks new entries. + * + *

    "Capped" names the promise, not the mechanism: the bucket array is sized once from {@code + * maxCapacity} via {@link Hashtable#capacityFor(int)} and never resized, but that is an + * implementation detail. What the caller is choosing here is a bounded entry count and, with + * it, a bounded footprint -- the posture an agent living in someone else's heap wants by + * default. Callers that need overflow to be absorbed rather than refused should pair a {@link + * SizeManager}'s eviction half over the static building blocks (see {@link + * Hashtable#createCapped(int)}) rather than reaching for an uncapped table. + * + *

    Pick {@code maxCapacity} in the right ballpark of what you actually expect to hold + * -- the bucket array is sized from it, so it is read as both the limit and a rough estimate. + * Nothing assumes you will reach the cap, but a cap set as a paranoid safety valve far above + * typical usage over-allocates the spine for a fill that never arrives. When the limit and the + * expectation genuinely differ by a lot, size the two independently with the low-level API: + * {@code Hashtable.create(capacityFor(expected))} paired with {@code new SizeManager(limit)}. + * + *

    {@code entryClass} is a type token only -- it pins the concrete entry type so the compiler + * infers both {@code K} and {@code TEntry} at the call site (e.g. {@code + * D1.createCapped(MyEntry.class, 64)}), keeping the factory symmetric with the rest of the + * collections family. Unlike {@link Hashtable#create(Class, int)} it is not reflectively + * allocated: {@code buckets} stays a plain {@code Hashtable.Entry[]} internally, matching the + * static building blocks ({@link Hashtable#bucketFor}, {@link Hashtable#insertHeadEntryFor}, + * etc.) that {@link #get}, {@link #insert}, and friends delegate to. + */ + @Nonnull + public static > D1 createCapped( + @Nonnull Class entryClass, int maxCapacity) { + return new D1<>(maxCapacity); } + /** + * Live entry count. Exact here, unlike {@link SizeManager#estimateSize()}: this class reserves + * and links within a single call, so a caller can never observe the reservation window. + */ public int size() { - return this.size; + return this.sizeManager.estimateSize(); } - public TEntry get(K key) { + /** {@code true} once {@link #size()} has reached this table's fixed capacity. */ + public boolean isFull() { + return this.sizeManager.isFull(); + } + + @Nullable + public TEntry get(@Nullable K key) { long keyHash = D1.Entry.hash(key); - for (TEntry te = Support.bucket(this.buckets, keyHash); te != null; te = te.next()) { - if (te.keyHash == keyHash && te.matches(key)) { - return te; + for (TEntry curEntry = bucketFor(this.buckets, keyHash); + curEntry != null; + curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key)) { + return curEntry; } } return null; } - public TEntry remove(K key) { + @Nullable + public TEntry remove(@Nullable K key) { + // Walks the chain directly rather than delegating to Hashtable#removeMatching: a + // `e -> e.matches(key)` predicate captures `key`, so it allocates a fresh Predicate on every + // call. This class ships context-passing forEach/drain overloads precisely so callers can + // avoid capturing lambdas -- the write paths follow the same discipline. Same loop shape as + // tryInsertOrReplace below. long keyHash = D1.Entry.hash(key); - - for (MutatingBucketIterator iter = - Support.mutatingBucketIterator(this.buckets, keyHash); + for (MutatingBucketIterator iter = mutatingBucketIterator(this.buckets, keyHash); iter.hasNext(); ) { TEntry curEntry = iter.next(); - if (curEntry.matches(key)) { iter.remove(); - this.size -= 1; + this.sizeManager.decrement(); return curEntry; } } - return null; } - public void insert(TEntry newEntry) { - Support.insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); - this.size += 1; + /** + * Unconditionally adds {@code newEntry} ({@code true}), or {@code false} if the table is + * already at capacity. Caller-responsible: {@code newEntry}'s key must be absent, else it lands + * shadowed behind the existing entry. + */ + public boolean insert(@Nonnull TEntry newEntry) { + return insertHeadEntryFor(this.sizeManager, this.buckets, newEntry.keyHash, newEntry); } - public TEntry insertOrReplace(TEntry newEntry) { + /** + * Makes {@code newEntry} the entry for its key: replaces the existing entry for that key if one + * is present, otherwise inserts it fresh. Returns {@code false} only when the key is absent + * and the table is at capacity -- a replacement swaps one entry for another without + * growing {@link #size()}, so it always succeeds, even on a full table. + * + *

    Does not hand back the entry it displaced. Callers that need it can {@link #get} first; + * that is rare enough (the same way {@code Map.put}'s return value is rarely read) not to be + * worth the cost of the alternative, which was throwing {@link IllegalStateException} on + * refusal because {@code null} was already spoken for by "inserted fresh". Refusal at a cap is + * ordinary steady-state behaviour, not a programming error, and an exception would allocate a + * throwable plus stack trace exactly when the table is under the most pressure. + * + *

    Note this swaps the entry object. Where the goal is to change values on an entry + * that may or may not exist yet, prefer looking it up once and mutating in place -- that is the + * allocation-free path this class exists for. + */ + public boolean tryInsertOrReplace(@Nonnull TEntry newEntry) { for (MutatingBucketIterator iter = - Support.mutatingBucketIterator(this.buckets, newEntry.keyHash); + mutatingBucketIterator(this.buckets, newEntry.keyHash); iter.hasNext(); ) { TEntry curEntry = iter.next(); if (curEntry.matches(newEntry.key)) { iter.replace(newEntry); - return curEntry; + return true; } } - Support.insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); - this.size += 1; - return null; + return insertHeadEntryFor(this.sizeManager, this.buckets, newEntry.keyHash, newEntry); } /** - * Returns the entry for {@code key}, building one via {@code creator} if absent. Computes the - * hash once and reuses it for both the lookup and (on miss) the insert -- avoids the - * double-hash that "{@code get}; if null then {@code insert}" would incur. + * Returns the entry for {@code key}, building one via {@code creator} if absent -- or {@code + * null} if the key is absent and the table is at capacity. This method can refuse: + * despite the name it is not total, and a caller that dereferences the result without a null + * check will NPE the first time the cap is reached. A lookup hit is always returned even at + * capacity, so only the create half can fail. Check {@link #isFull()} beforehand if you want to + * distinguish "refused" from "created" without inspecting the result. + * + *

    Refusal is a designed steady state for a capped table, not an exceptional condition -- see + * {@link #createCapped}. Decide deliberately what a refused create should do (drop the sample, + * fall back, make room); silently ignoring the {@code null} turns the cap into data loss you + * cannot see. + * + *

    Computes the hash once and reuses it for both the lookup and (on miss) the insert -- + * avoids the double-hash that "{@code get}; if null then {@code insert}" would incur. * *

    The {@code creator} is expected to build an entry whose {@code keyHash} equals {@link * Entry#hash(Object) D1.Entry.hash(key)} -- typically by passing {@code key} to a constructor * that calls {@code super(key)}. A mismatched hash will leave the new entry inserted at a * bucket that future {@link #get} calls won't probe. */ - public TEntry getOrCreate(K key, Function creator) { + @Nullable + public TEntry tryGetOrCreate( + @Nullable K key, @Nonnull Function creator) { long keyHash = D1.Entry.hash(key); - for (TEntry te = Support.bucket(this.buckets, keyHash); te != null; te = te.next()) { - if (te.keyHash == keyHash && te.matches(key)) { - return te; + for (TEntry curEntry = bucketFor(this.buckets, keyHash); + curEntry != null; + curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key)) { + return curEntry; } } + // Deliberately isFull() -> create -> increment, rather than the one-call tracked + // insertHeadEntryFor(sizeManager, ...) that insert/tryInsertOrReplace use: `creator` runs + // between the check and the link and may throw, so a slot reserved up front could leak. See + // SizeManager#tryReserve. + if (this.sizeManager.isFull()) { + return null; + } TEntry newEntry = creator.apply(key); - Support.insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); - this.size += 1; + insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry); + this.sizeManager.increment(); return newEntry; } - public void clear() { - Support.clear(this.buckets); - this.size = 0; + /** + * {@link #tryGetOrCreate} followed by {@code updater}, returning whether the update happened. + * + *

    Prefer this over the two-call form for the common read-modify-write shape -- a counter + * bump, a max, a timestamp refresh: + * + *

    {@code
    +     * table.tryGetOrUpdate(key, Counter::new, Counter::inc);
    +     * }
    + * + *

    The two-call form leaves a {@code null} on the caller's happy path, and the {@code null} + * only ever appears once the table is at capacity -- so {@code + * tryGetOrCreate(...).inc()} reads fine, tests fine, and throws in production under cardinality + * pressure. Fusing the update keeps that reference inside the table: at capacity the update is + * skipped and {@code false} is returned, which a counter caller can safely ignore or check + * deliberately. + * + *

    No extra work versus doing it by hand -- the hash is still computed once, by the delegated + * {@link #tryGetOrCreate}. + */ + public boolean tryGetOrUpdate( + @Nullable K key, + @Nonnull Function creator, + @Nonnull Consumer updater) { + TEntry entry = tryGetOrCreate(key, creator); + if (entry == null) { + return false; + } + updater.accept(entry); + return true; + } + + /** + * Context-passing {@link #tryGetOrUpdate}, for updates that need a value the entry doesn't + * carry. {@code c -> c.add(n)} captures {@code n} and allocates a lambda per call; passing + * {@code n} as {@code context} against a non-capturing {@link BiConsumer} (typically a {@code + * static final}) does not. + */ + public boolean tryGetOrUpdate( + @Nullable K key, + @Nonnull Function creator, + C context, + @Nonnull BiConsumer updater) { + TEntry entry = tryGetOrCreate(key, creator); + if (entry == null) { + return false; + } + updater.accept(context, entry); + return true; + } + + /** + * Primitive-{@code long} {@link #tryGetOrUpdate}, for the accumulate-a-count shape: + * + *

    {@code
    +     * private static final ObjLongConsumer ADD = (c, n) -> c.count += n;
    +     * table.tryGetOrUpdate(key, Counter::new, n, ADD);
    +     * }
    + * + *

    The generic context overload would box {@code n} on every call; this one does not. Note + * the argument order is {@code (entry, value)} -- {@link ObjLongConsumer}'s, not the {@code + * (context, entry)} of the {@link BiConsumer} overload. + */ + public boolean tryGetOrUpdate( + @Nullable K key, + @Nonnull Function creator, + long context, + @Nonnull ObjLongConsumer updater) { + TEntry entry = tryGetOrCreate(key, creator); + if (entry == null) { + return false; + } + updater.accept(entry, context); + return true; } - public void forEach(Consumer consumer) { - Support.forEach(this.buckets, consumer); + public void forEach(@Nonnull Consumer consumer) { + Hashtable.forEach(this.buckets, consumer); } /** @@ -222,8 +421,30 @@ public void forEach(Consumer consumer) { * -- pass a non-capturing {@link BiConsumer} (typically a {@code static final}) plus whatever * side-band state it needs as {@code context}. */ - public void forEach(T context, BiConsumer consumer) { - Support.forEach(this.buckets, context, consumer); + public void forEach(C context, @Nonnull BiConsumer consumer) { + Hashtable.forEach(this.buckets, context, consumer); + } + + public void clear() { + Hashtable.clear(this.sizeManager, this.buckets); + } + + /** + * Removes every entry, passing each to {@code sink} as it is unlinked -- the read-and-reset + * primitive for flush/publish workflows (drain the table into a telemetry batch, an event + * emitter, etc.). Equivalent to {@link #forEach} then {@link #clear} in a single call. + */ + public void drain(@Nonnull Consumer sink) { + Hashtable.drain(this.sizeManager, this.buckets, sink); + } + + /** + * Context-passing {@link #drain(Consumer)}. Pass a non-capturing {@link BiConsumer} (typically + * a {@code static final}) plus the accumulator as {@code context} to avoid a capturing-lambda + * allocation. + */ + public void drain(C context, @Nonnull BiConsumer sink) { + Hashtable.drain(this.sizeManager, this.buckets, context, sink); } } @@ -233,12 +454,12 @@ public void forEach(T context, BiConsumer consume *

    The user supplies a {@link D2.Entry} subclass carrying both key parts and any value fields. * Compared to {@code HashMap} this avoids the per-lookup {@code Pair} (or record) * allocation: both key parts are passed directly through {@link #get}, {@link #remove}, {@link - * #insert}, and {@link #insertOrReplace}. Combined with in-place value mutation, this makes + * #insert}, and {@link #tryInsertOrReplace}. Combined with in-place value mutation, this makes * {@code D2} substantially less GC-intensive than the equivalent {@code HashMap} for * counter-style workloads. * - *

    Capacity is fixed at construction; the table does not resize. Actual bucket-array length is - * rounded up to the next power of two. + *

    Capacity is fixed at construction; the table does not resize. Same strict-cap semantics as + * {@link D1} once {@link #size()} reaches capacity. * *

    Key parts are combined into a 64-bit hash via {@link LongHashingUtils}; see {@link * D2.Entry#hash(Object, Object)}. @@ -264,23 +485,25 @@ public abstract static class Entry extends Hashtable.Entry { final K1 key1; final K2 key2; - protected Entry(K1 key1, K2 key2) { + protected Entry(@Nullable K1 key1, @Nullable K2 key2) { super(hash(key1, key2)); this.key1 = key1; this.key2 = key2; } /** The first key part this entry was created with. */ + @Nullable public K1 key1() { return this.key1; } /** The second key part this entry was created with. */ + @Nullable public K2 key2() { return this.key2; } - public boolean matches(K1 key1, K2 key2) { + public boolean matches(@Nullable K1 key1, @Nullable K2 key2) { return Objects.equals(this.key1, key1) && Objects.equals(this.key2, key2); } @@ -291,100 +514,184 @@ public boolean matches(K1 key1, K2 key2) { * combinations whose chained hash equals {@code hash(0, 0) = 0} or similar values. {@link * #matches(Object, Object)} resolves any such collision. */ - public static long hash(Object key1, Object key2) { + public static long hash(@Nullable Object key1, @Nullable Object key2) { return LongHashingUtils.hash(key1, key2); } } // Package-private to match D1.buckets -- available for iterator tests in the same package. final Hashtable.Entry[] buckets; - private int size; + private final SizeManager sizeManager; - public D2(int capacity) { - this.buckets = Support.create(capacity); - this.size = 0; + private D2(int maxCapacity) { + // Bucket array gets load-factor headroom over the strict entry cap below, so chains stay + // short even when the table is full; see Hashtable#capacityFor. + this.buckets = Hashtable.create(capacityFor(maxCapacity)); + this.sizeManager = new SizeManager(maxCapacity); + } + + /** + * Composite-key analogue of {@link D1#createCapped}: a capped table holding at most + * {@code maxCapacity} live entries, after which {@link #insert} returns {@code false} and + * {@link #tryGetOrCreate} returns {@code null}, with lookup hits still always returned. See + * {@link D1#createCapped} for what "capped" promises and why it is the default posture. + * + *

    {@code entryClass} is a type token only -- it pins the concrete entry type so the compiler + * infers {@code K1}, {@code K2}, and {@code TEntry} at the call site (e.g. {@code + * D2.createCapped(MyEntry.class, 64)}). Unlike {@link Hashtable#create(Class, int)} it is not + * reflectively allocated: {@code buckets} stays a plain {@code Hashtable.Entry[]} internally, + * matching the static building blocks that {@link #get}, {@link #insert}, and friends delegate + * to. + */ + @Nonnull + public static > D2 createCapped( + @Nonnull Class entryClass, int maxCapacity) { + return new D2<>(maxCapacity); } + /** + * Live entry count. Exact here, unlike {@link SizeManager#estimateSize()}: this class reserves + * and links within a single call, so a caller can never observe the reservation window. + */ public int size() { - return this.size; + return this.sizeManager.estimateSize(); } - public TEntry get(K1 key1, K2 key2) { + /** {@code true} once {@link #size()} has reached this table's fixed capacity. */ + public boolean isFull() { + return this.sizeManager.isFull(); + } + + @Nullable + public TEntry get(@Nullable K1 key1, @Nullable K2 key2) { long keyHash = D2.Entry.hash(key1, key2); - for (TEntry te = Support.bucket(this.buckets, keyHash); te != null; te = te.next()) { - if (te.keyHash == keyHash && te.matches(key1, key2)) { - return te; + for (TEntry curEntry = bucketFor(this.buckets, keyHash); + curEntry != null; + curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { + return curEntry; } } return null; } - public TEntry remove(K1 key1, K2 key2) { + @Nullable + public TEntry remove(@Nullable K1 key1, @Nullable K2 key2) { + // Chain walked directly rather than via Hashtable#removeMatching -- see D1#remove for why a + // capturing predicate is avoided on this path. long keyHash = D2.Entry.hash(key1, key2); - - for (MutatingBucketIterator iter = - Support.mutatingBucketIterator(this.buckets, keyHash); + for (MutatingBucketIterator iter = mutatingBucketIterator(this.buckets, keyHash); iter.hasNext(); ) { TEntry curEntry = iter.next(); - if (curEntry.matches(key1, key2)) { iter.remove(); - this.size -= 1; + this.sizeManager.decrement(); return curEntry; } } - return null; } - public void insert(TEntry newEntry) { - Support.insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); - this.size += 1; + /** Two-key analogue of {@link D1#insert}, with the same strict-cap refusal contract. */ + public boolean insert(@Nonnull TEntry newEntry) { + return insertHeadEntryFor(this.sizeManager, this.buckets, newEntry.keyHash, newEntry); } - public TEntry insertOrReplace(TEntry newEntry) { + /** Two-key analogue of {@link D1#tryInsertOrReplace}, with the same refusal contract. */ + public boolean tryInsertOrReplace(@Nonnull TEntry newEntry) { for (MutatingBucketIterator iter = - Support.mutatingBucketIterator(this.buckets, newEntry.keyHash); + mutatingBucketIterator(this.buckets, newEntry.keyHash); iter.hasNext(); ) { TEntry curEntry = iter.next(); if (curEntry.matches(newEntry.key1, newEntry.key2)) { iter.replace(newEntry); - return curEntry; + return true; } } - Support.insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); - this.size += 1; - return null; + return insertHeadEntryFor(this.sizeManager, this.buckets, newEntry.keyHash, newEntry); } /** - * Two-key analogue of {@link D1#getOrCreate}. Computes the combined hash once and reuses it for - * both lookup and (on miss) insert. The {@code creator} is expected to build an entry whose - * {@code keyHash} equals {@link Entry#hash(Object, Object) D2.Entry.hash(key1, key2)}. + * Two-key analogue of {@link D1#tryGetOrCreate}: returns the entry for {@code (key1, key2)}, + * building one via {@code creator} if absent -- or {@code null} if the pair is absent and the + * table is at capacity. Like the single-key form it is not total despite the name, and + * refusal is a designed steady state rather than an exceptional one; see {@link + * D1#tryGetOrCreate} for the full contract and what to do about a refused create. + * + *

    Computes the combined hash once and reuses it for both lookup and (on miss) insert. The + * {@code creator} is expected to build an entry whose {@code keyHash} equals {@link + * Entry#hash(Object, Object) D2.Entry.hash(key1, key2)}. */ - public TEntry getOrCreate( - K1 key1, K2 key2, BiFunction creator) { + @Nullable + public TEntry tryGetOrCreate( + @Nullable K1 key1, + @Nullable K2 key2, + @Nonnull BiFunction creator) { long keyHash = D2.Entry.hash(key1, key2); - for (TEntry te = Support.bucket(this.buckets, keyHash); te != null; te = te.next()) { - if (te.keyHash == keyHash && te.matches(key1, key2)) { - return te; + for (TEntry curEntry = bucketFor(this.buckets, keyHash); + curEntry != null; + curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { + return curEntry; } } + // Deliberately isFull() -> create -> increment, rather than the one-call tracked + // insertHeadEntryFor(sizeManager, ...) that insert/tryInsertOrReplace use: `creator` runs + // between the check and the link and may throw, so a slot reserved up front could leak. See + // SizeManager#tryReserve. + if (this.sizeManager.isFull()) { + return null; + } TEntry newEntry = creator.apply(key1, key2); - Support.insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); - this.size += 1; + insertHeadEntryFor(this.buckets, newEntry.keyHash, newEntry); + this.sizeManager.increment(); return newEntry; } - public void clear() { - Support.clear(this.buckets); - this.size = 0; + /** + * Two-key analogue of {@link D1#tryGetOrUpdate(Object, Function, Consumer)}: applies {@code + * updater} to the entry for {@code (key1, key2)}, creating one if absent, and returns whether + * the update happened. Returns {@code false} without updating when the pair is absent and the + * table is at capacity. See the single-key form for why fusing the update is preferred over + * {@code tryGetOrCreate(...)} followed by a dereference. + */ + public boolean tryGetOrUpdate( + @Nullable K1 key1, + @Nullable K2 key2, + @Nonnull BiFunction creator, + @Nonnull Consumer updater) { + TEntry entry = tryGetOrCreate(key1, key2, creator); + if (entry == null) { + return false; + } + updater.accept(entry); + return true; } - public void forEach(Consumer consumer) { - Support.forEach(this.buckets, consumer); + /** + * Context-passing {@link #tryGetOrUpdate(Object, Object, BiFunction, Consumer)}, for updates + * that need a value the entry doesn't carry. Pass a non-capturing {@link BiConsumer} (typically + * a {@code static final}) plus its side-band state as {@code context} to avoid allocating a + * capturing lambda per call. + */ + public boolean tryGetOrUpdate( + @Nullable K1 key1, + @Nullable K2 key2, + @Nonnull BiFunction creator, + C context, + @Nonnull BiConsumer updater) { + TEntry entry = tryGetOrCreate(key1, key2, creator); + if (entry == null) { + return false; + } + updater.accept(context, entry); + return true; + } + + public void forEach(@Nonnull Consumer consumer) { + Hashtable.forEach(this.buckets, consumer); } /** @@ -392,195 +699,944 @@ public void forEach(Consumer consumer) { * -- pass a non-capturing {@link BiConsumer} (typically a {@code static final}) plus whatever * side-band state it needs as {@code context}. */ - public void forEach(T context, BiConsumer consumer) { - Support.forEach(this.buckets, context, consumer); + public void forEach(C context, @Nonnull BiConsumer consumer) { + Hashtable.forEach(this.buckets, context, consumer); + } + + public void clear() { + Hashtable.clear(this.sizeManager, this.buckets); + } + + /** + * Removes every entry, passing each to {@code sink} as it is unlinked -- the read-and-reset + * primitive for flush/publish workflows (drain the table into a telemetry batch, an event + * emitter, etc.). Equivalent to {@link #forEach} then {@link #clear} in a single call. + */ + public void drain(@Nonnull Consumer sink) { + Hashtable.drain(this.sizeManager, this.buckets, sink); + } + + /** + * Context-passing {@link #drain(Consumer)}. Pass a non-capturing {@link BiConsumer} (typically + * a {@code static final}) plus the accumulator as {@code context} to avoid a capturing-lambda + * allocation. + */ + public void drain(C context, @Nonnull BiConsumer sink) { + Hashtable.drain(this.sizeManager, this.buckets, context, sink); } } + // ============================================================================================ + // Static building blocks over a caller-owned bucket array. + // + // Use these to assemble a custom table (higher arity, primitive keys, extra value fields) when + // D1/D2 don't fit; D1/D2 delegate to them internally. The calling class owns the array and + // exposes whatever operations it needs. + // + // Not thread-safe: there is no locking here. Concurrent access, including mixing reads with + // writes, requires external synchronization. + // ============================================================================================ + + /** Upper bound on the bucket-array length returned by {@link #sizeFor(int)}. */ + static final int MAX_BUCKETS = 1 << 30; + /** - * Building blocks for hash-table operations. + * Allocates a fixed-size bucket array sized to hold {@code capacity} entries: {@code capacity} + * rounded up to the next power of two. + * + *

    Erasure stops a caller writing {@code new TEntry[n]}, so {@code entryClass} is allocated + * reflectively via {@link Array#newInstance}. That buys a real {@code TEntry} component type + * rather than the base {@code Entry[]}: typed reads, real array-store checks, and a monomorphic + * element type for the JIT. The one reflective call happens at construction, off any hot path. + * Capacity is fixed; the table does not resize. Use {@link #create(int)} when the spine is driven + * purely through the static building blocks and the base component type is enough. * - *

    Used by {@link D1} and {@link D2}, and available to callers that want to assemble their own - * higher-arity table (3+ key parts) without re-implementing the bucket-array mechanics. The - * typical recipe: + *

    {@code capacity} sizes the bucket array 1:1 (no headroom) -- chains stay a plain hash table + * at exactly this many entries. For load-factor headroom over a target cap on live entries (so + * chains stay short even as the table fills, the way {@link D1}/{@link D2}/{@link #createCapped} + * size themselves), pass {@link #capacityFor(int)} instead: {@code create(MyEntry.class, + * capacityFor(cardinalityLimit))}. + */ + @SuppressWarnings("unchecked") + @Nonnull + public static TEntry[] create( + @Nonnull Class entryClass, int capacity) { + return (TEntry[]) Array.newInstance(entryClass, sizeFor(capacity)); + } + + /** + * Untyped sibling of {@link #create(Class, int)}: allocates a bucket array of {@code buckets} + * rounded up to the next power of two, with the base {@code Hashtable.Entry[]} component type. * - *

      - *
    • Subclass {@link Hashtable.Entry} directly, adding the key fields and a {@code - * matches(...)} method of your chosen arity. - *
    • Allocate a backing array with {@link #create(int)} or {@link #create(int, float)} (the - * latter scales for a target load factor; see {@link #MAX_RATIO}). - *
    • Use {@link #bucketIndex(Object[], long)} for the bucket lookup, {@link - * #bucketIterator(Hashtable.Entry[], long)} for read-only chain walks, and {@link - * #mutatingBucketIterator(Hashtable.Entry[], long)} when you also need {@code remove} / - * {@code replace}. - *
    • Use {@link #insertHeadEntry(Hashtable.Entry[], int, Hashtable.Entry)} to splice a new - * entry as the head of a bucket chain. - *
    • Iterate every entry with {@link #forEach(Hashtable.Entry[], Consumer)} or its - * context-passing sibling. For full-table sweeps with {@code remove}, use {@link - * #mutatingTableIterator(Hashtable.Entry[])}. - *
    • Clear with {@link #clear(Hashtable.Entry[])}. - *
    + *

    Use this when the spine is driven purely through the static building blocks, which all take + * {@code Hashtable.Entry[]} -- that is what {@link D1}, {@link D2}, and {@link #createCapped} + * allocate internally. Prefer {@link #create(Class, int)} when you own the array and want a real + * {@code TEntry} component type (typed reads, array-store checks, a monomorphic element type for + * the JIT); prefer this one when a typed spine would only buy you covariant array-store checks on + * every insert. Capacity is fixed; the table does not resize. * - *

    All bucket arrays produced by {@code create} have a power-of-two length, so {@link - * #bucketIndex(Object[], long)} can use a bit mask. + *

    {@code buckets} is a bucket count, not an entry cap -- see {@link #capacityFor(int)} to + * derive one from a target cap on live entries. */ - public static final class Support { + @Nonnull + public static Hashtable.Entry[] create(int buckets) { + return new Hashtable.Entry[sizeFor(buckets)]; + } + + /** + * Balanced default load factor for a chained bucket array: at this target fill, chains from a + * well-spread hash stay short (average chain length {@code ~1/DEFAULT_LOAD_FACTOR}) without + * over-provisioning the array. Chaining tolerates a high target fill: past 1.0 it degrades + * gradually into longer chains rather than failing, so there is no cliff to stay clear of and no + * reason to over-allocate the spine. + */ + public static final float DEFAULT_LOAD_FACTOR = 0.75f; + + /** + * Bucket-array length for a strict cap of {@code cardinalityLimit} live entries at {@link + * #DEFAULT_LOAD_FACTOR}: infers a reasonable bucket count from the entry cap you actually care + * about, rather than making every caller redo the headroom math ({@link D1}, {@link D2}, and + * {@link #createCapped} all size themselves this way). Pair with a {@link SizeManager} of {@code + * cardinalityLimit} for the matching strict cap; this method only sizes the array. + */ + public static int capacityFor(int cardinalityLimit) { + return capacityFor(cardinalityLimit, DEFAULT_LOAD_FACTOR); + } + + /** + * {@link #capacityFor(int)} at an explicit {@code loadFactor} in {@code (0, 1)}: the bucket-array + * length for a strict cap of {@code cardinalityLimit} live entries, rounded up to a power of two + * via {@link #sizeFor(int)}. + */ + public static int capacityFor(int cardinalityLimit, float loadFactor) { + if (!(loadFactor > 0f && loadFactor < 1f)) { + throw new IllegalArgumentException("loadFactor must be in (0, 1): " + loadFactor); + } + return sizeFor((int) (cardinalityLimit / loadFactor)); + } + + /** + * Rounds {@code requestedSize} up to the next power of two, capped at {@link #MAX_BUCKETS}, and + * returns the bucket-array length to allocate. Throws {@link IllegalArgumentException} for + * negative inputs or inputs above the cap. + */ + public static int sizeFor(int requestedSize) { + if (requestedSize < 0) { + throw new IllegalArgumentException("requestedSize must be non-negative: " + requestedSize); + } + if (requestedSize > MAX_BUCKETS) { + throw new IllegalArgumentException( + "requestedSize exceeds maximum bucket count (" + MAX_BUCKETS + "): " + requestedSize); + } + if (requestedSize <= 1) { + return 1; + } + return Integer.highestOneBit(requestedSize - 1) << 1; + } + + public static int bucketIndex(@Nonnull Object[] buckets, long keyHash) { + return (int) (keyHash & buckets.length - 1); + } + + /** + * Returns the head entry of the bucket that {@code keyHash} maps to, cast to the caller's + * concrete entry type. The unchecked cast lives here so the chain-walk loop at the call site + * doesn't need to thread a raw {@link Entry} variable through. + * + *

    Named {@code bucketFor} rather than {@code bucket}: there is no competing {@code int}-index + * overload today, but the {@code For} suffix marks "derives the index from a key hash" up front, + * so adding an index-taking sibling later cannot reintroduce the int-vs-long overload ambiguity + * described on {@link #insertHeadEntryFor(Hashtable.Entry[], long, Hashtable.Entry)}. + */ + @SuppressWarnings("unchecked") + @Nullable + public static TEntry bucketFor( + @Nonnull Hashtable.Entry[] buckets, long keyHash) { + return (TEntry) buckets[bucketIndex(buckets, keyHash)]; + } + + /** + * Splices {@code entry} in as the new head of the chain at {@code bucketIndex}. Caller is + * responsible for size accounting -- this method only touches the chain pointers. + */ + public static void insertHeadEntryAt( + @Nonnull Hashtable.Entry[] buckets, int bucketIndex, @Nonnull Hashtable.Entry entry) { + assert entry.next() == null + : "Entry already linked -- inserting the same Entry instance twice corrupts the chain"; + entry.setNext(buckets[bucketIndex]); + buckets[bucketIndex] = entry; + } + + /** + * Convenience form of {@link #insertHeadEntryAt} that derives the bucket index from {@code + * keyHash}. Use this when the caller has the hash but not the index; if the index has already + * been computed for another reason, prefer {@link #insertHeadEntryAt} to avoid the redundant + * mask. + * + *

    Named distinctly from {@link #insertHeadEntryAt} rather than overloaded on {@code long} vs. + * {@code int}, because the overloaded form is a trap: a caller with a primitive {@code int}-typed + * key hash calling an overloaded {@code insertHeadEntry(buckets, intHash, entry)} would silently + * bind to the {@code int}-index overload instead of widening to this one, treating the raw hash + * as an array index. + */ + public static void insertHeadEntryFor( + @Nonnull Hashtable.Entry[] buckets, long keyHash, @Nonnull Hashtable.Entry entry) { + insertHeadEntryAt(buckets, bucketIndex(buckets, keyHash), entry); + } + + /** + * {@link #insertHeadEntryFor(Hashtable.Entry[], long, Hashtable.Entry)}, but folding in the + * strict-cap check that every unconditional insert needs: reserves a slot from {@code + * sizeManager} first, splicing {@code entry} in only if the reservation succeeds. Returns {@code + * false} (without touching {@code buckets}) once {@code sizeManager} is at capacity. Lets a + * composer working directly against the static building blocks (e.g. {@link D1#insert}, or a + * caller-owned table of higher key arity) get the same one-call insert-with-cap-check contract + * that {@link D1}/{@link D2} give their own callers. + * + *

    {@code sizeManager} leads, per this class's parameter order for the size-tracked statics: + * mutated bookkeeping, then the spine, then the key, then callbacks. Putting it first (rather + * than appending it) makes the tracked and untracked forms visibly different at the head of the + * call instead of differing only in a trailing argument -- forgetting the tracker leaks the cap + * silently, so the distinction should be hard to overlook at the call site and in review. + */ + public static boolean insertHeadEntryFor( + @Nonnull SizeManager sizeManager, + @Nonnull Hashtable.Entry[] buckets, + long keyHash, + @Nonnull Hashtable.Entry entry) { + if (!sizeManager.tryReserve()) { + return false; + } + insertHeadEntryFor(buckets, keyHash, entry); + return true; + } + + /** + * {@link #insertHeadEntryFor(SizeManager, Hashtable.Entry[], long, Hashtable.Entry)} over a + * {@link State}, which carries the spine and its manager together -- so there is no way to pass a + * manager that belongs to a different table, and {@code TEntry} is inferred rather than needing a + * witness at the call site. + */ + public static boolean insertHeadEntryFor( + @Nonnull State state, long keyHash, @Nonnull TEntry entry) { + return insertHeadEntryFor(state.sizeManager, state.buckets, keyHash, entry); + } + + /** + * Scans the bucket chain at {@code keyHash} for the first entry matching {@code matches}, unlinks + * it, decrements {@code sizeManager}, and returns it -- or returns {@code null} (leaving {@code + * buckets} and {@code sizeManager} untouched) if nothing in the chain matches. Mirrors {@link + * #insertHeadEntryFor(SizeManager, Hashtable.Entry[], long, Hashtable.Entry)} on the removal + * side: the one-call, size-tracked shape a composer driving the static building blocks directly + * can use instead of hand-rolling the mutating-iterator loop and remembering to decrement. + * + *

    {@code sizeManager} leads for the same reason it does on the insert side. + */ + @Nullable + public static TEntry removeMatching( + @Nonnull SizeManager sizeManager, + @Nonnull Hashtable.Entry[] buckets, + long keyHash, + @Nonnull Predicate matches) { + for (MutatingBucketIterator iter = mutatingBucketIterator(buckets, keyHash); + iter.hasNext(); ) { + TEntry curEntry = iter.next(); + if (matches.test(curEntry)) { + iter.remove(); + sizeManager.decrement(); + return curEntry; + } + } + return null; + } + + /** + * {@link #drain(Hashtable.Entry[], Consumer)} plus the matching bookkeeping: empties the table + * into {@code sink} and resets {@code sizeManager} to zero. Draining without resetting leaves the + * cap permanently consumed, so the two belong in one call rather than as a pair the caller has to + * remember -- same reasoning as {@link #clear(SizeManager, Hashtable.Entry[])}. + */ + public static void drain( + @Nonnull SizeManager sizeManager, + @Nonnull Hashtable.Entry[] buckets, + @Nonnull Consumer sink) { + Hashtable.drain(buckets, sink); + sizeManager.reset(); + } + + /** Context-passing form of {@link #drain(SizeManager, Hashtable.Entry[], Consumer)}. */ + public static void drain( + @Nonnull SizeManager sizeManager, + @Nonnull Hashtable.Entry[] buckets, + C context, + @Nonnull BiConsumer sink) { + Hashtable.drain(buckets, context, sink); + sizeManager.reset(); + } + + /** {@link #drain(SizeManager, Hashtable.Entry[], Consumer)} over a {@link State}. */ + public static void drain( + @Nonnull State state, @Nonnull Consumer sink) { + drain(state.sizeManager, state.buckets, sink); + } + + /** Context-passing form of {@link #drain(State, Consumer)}. */ + public static void drain( + @Nonnull State state, + C context, + @Nonnull BiConsumer sink) { + drain(state.sizeManager, state.buckets, context, sink); + } + + /** Live entries in {@code state}; see {@link SizeManager#estimateSize()} for why an estimate. */ + public static int estimateSize(@Nonnull State state) { + return state.sizeManager.estimateSize(); + } + + /** + * {@code true} when {@code state} appears to hold no entries. Derived from {@link + * SizeManager#estimateSize()} and inherits its imprecision -- an outstanding reservation reads as + * non-empty, and a link made without one can read as empty while the spine is not. Named for what + * it can honestly promise: use it to skip work that is merely wasted on an empty table, not to + * establish that there is nothing there. + */ + public static boolean isLikelyEmpty(@Nonnull State state) { + return state.sizeManager.estimateSize() == 0; + } + + /** + * Head entry of the bucket {@code keyHash} maps to in {@code state}, typed to the state's entry + * type so the chain walk at the call site needs no cast or witness. + */ + @Nullable + public static TEntry bucketFor( + @Nonnull State state, long keyHash) { + return bucketFor(state.buckets, keyHash); + } + + /** + * Splices {@code entry} in as the new head of its bucket without touching the count, + * because the caller already holds a reservation for it -- from {@link #tryReserveOrEvict} or a + * bare {@link SizeManager#tryReserve()}. Pairing those is the shape of a miss path that wants to + * refuse before it allocates: + * + *

    {@code
    +   * if (!tryReserveOrEvict(state, STALE)) {
    +   *   return null;                       // refused -- no entry was built
    +   * }
    +   * insertReserved(state, keyHash, buildEntry());
    +   * }
    + * + *

    Distinct from {@link #insertHeadEntryFor(State, long, Entry)}, which reserves as it inserts; + * calling that one here would count the entry twice. + */ + public static void insertReserved( + @Nonnull State state, long keyHash, @Nonnull TEntry entry) { + insertHeadEntryFor(state.buckets, keyHash, entry); + } + + /** {@link #forEach(Hashtable.Entry[], Consumer)} over a {@link State}. */ + public static void forEach( + @Nonnull State state, @Nonnull Consumer consumer) { + Hashtable.forEach(state.buckets, consumer); + } + + /** {@link #forEach(Hashtable.Entry[], Object, BiConsumer)} over a {@link State}. */ + public static void forEach( + @Nonnull State state, + C context, + @Nonnull BiConsumer consumer) { + Hashtable.forEach(state.buckets, context, consumer); + } + + /** + * Reserves a slot in {@code state} for a fresh insert, evicting one entry matching {@code + * evictable} if the table is full. {@code false} means full with nothing evictable -- the caller + * should drop the datum. The whole capacity decision of a self-evicting table's miss path in one + * call; pass a non-capturing {@code evictable} (typically a {@code static final}) to keep it + * allocation-free. + */ + public static boolean tryReserveOrEvict( + @Nonnull State state, @Nonnull Predicate evictable) { + return state.sizeManager.tryReserveOrEvict(state.buckets, evictable); + } + + /** + * Unlinks the first entry in {@code state} matching {@code evictable}, resuming from where the + * last eviction looked, and decrements the count. {@code null} if nothing matched anywhere. + */ + @Nullable + public static TEntry evictOne( + @Nonnull State state, @Nonnull Predicate evictable) { + return state.sizeManager.evictOne(state.buckets, evictable); + } + + /** + * Unlinks every entry in {@code state} matching {@code evictable}, decrementing per removal, and + * returns how many went. + */ + public static int evictAll( + @Nonnull State state, @Nonnull Predicate evictable) { + return state.sizeManager.evictAll(state.buckets, evictable); + } + + /** {@link #clear(SizeManager, Hashtable.Entry[])} over a {@link State}. */ + public static void clear(@Nonnull State state) { + clear(state.sizeManager, state.buckets); + } + + /** + * Walks every entry in {@code buckets} and invokes {@code consumer} on it. The unchecked cast to + * {@code TEntry} lives here (mirroring {@link Entry#next()}) so callers don't have to sprinkle it + * across their own forEach loops. + */ + @SuppressWarnings("unchecked") + public static void forEach( + @Nonnull Hashtable.Entry[] buckets, @Nonnull Consumer consumer) { + for (int i = 0; i < buckets.length; i++) { + for (Hashtable.Entry e = buckets[i]; e != null; e = e.next()) { + consumer.accept((TEntry) e); + } + } + } + + /** + * Context-passing variant of {@link #forEach(Hashtable.Entry[], Consumer)}. Pair a non-capturing + * {@link BiConsumer} (typically a {@code static final}) with side-band state passed as {@code + * context} to avoid a fresh-Consumer allocation each call. + */ + @SuppressWarnings("unchecked") + public static void forEach( + @Nonnull Hashtable.Entry[] buckets, + C context, + @Nonnull BiConsumer consumer) { + for (int i = 0; i < buckets.length; i++) { + for (Hashtable.Entry e = buckets[i]; e != null; e = e.next()) { + consumer.accept(context, (TEntry) e); + } + } + } + + @Nonnull + public static BucketIterator bucketIterator( + @Nonnull Hashtable.Entry[] buckets, long keyHash) { + return new BucketIterator(buckets, keyHash); + } + + @Nonnull + public static + MutatingBucketIterator mutatingBucketIterator( + @Nonnull Hashtable.Entry[] buckets, long keyHash) { + return new MutatingBucketIterator(buckets, keyHash); + } + + /** + * Returns a {@link MutatingTableIterator} over every entry in {@code buckets}. Useful for sweeps + * -- eviction, expunge -- that aren't keyed to a specific hash. + */ + @Nonnull + public static + MutatingTableIterator mutatingTableIterator(@Nonnull Hashtable.Entry[] buckets) { + return new MutatingTableIterator(buckets, 0, buckets.length); + } + + /** + * Variant of {@link #mutatingTableIterator(Hashtable.Entry[])} that walks only the half-open + * bucket range {@code [startBucket, endBucket)}. Useful for resumable sweeps -- e.g. the + * cursor-based eviction in {@link SizeManager#evictOne} -- where one call drives {@code [cursor, + * length)} and a wrap-around call drives {@code [0, cursor)}. The iterator does not wrap + * around within a single instance; callers compose two iterators when wrap-around is desired. An + * empty range ({@code startBucket == endBucket}) produces an immediately exhausted iterator. + * + * @param startBucket inclusive lower bound; must be in {@code [0, buckets.length]}. + * @param endBucket exclusive upper bound; must be in {@code [startBucket, buckets.length]}. + */ + @Nonnull + public static + MutatingTableIterator mutatingTableIterator( + @Nonnull Hashtable.Entry[] buckets, int startBucket, int endBucket) { + return new MutatingTableIterator(buckets, startBucket, endBucket); + } + + /** + * {@link #removeMatching(SizeManager, Hashtable.Entry[], long, Predicate)} over a {@link State}. + * The predicate is typed to {@code TEntry}, so a caller matching on entry fields needs no cast. + */ + @Nullable + public static TEntry removeMatching( + @Nonnull State state, long keyHash, @Nonnull Predicate matches) { + return removeMatching(state.sizeManager, state.buckets, keyHash, matches); + } + + public static void clear(@Nonnull Hashtable.Entry[] buckets) { + Arrays.fill(buckets, null); + } + + /** + * {@link #clear(Hashtable.Entry[])} plus the matching bookkeeping: empties {@code buckets} and + * resets {@code sizeManager} to zero. Emptying a table without resetting its tracker leaves the + * cap permanently consumed, so the two belong in one call rather than as a pair a caller has to + * remember. + * + *

    {@code sizeManager} leads, per this class's parameter order for the size-tracked statics. + */ + public static void clear(@Nonnull SizeManager sizeManager, @Nonnull Hashtable.Entry[] buckets) { + clear(buckets); + sizeManager.reset(); + } + + /** + * Removes every entry, passing each removed entry to {@code sink} as it is unlinked -- the + * read-and-reset primitive for flush/publish workflows (drain the table into a telemetry batch, + * an event emitter, etc.). Equivalent to {@link #forEach} then {@link #clear}, offered as one + * call so composers don't have to spell out both steps. + */ + @SuppressWarnings("unchecked") + public static void drain( + @Nonnull Hashtable.Entry[] buckets, @Nonnull Consumer sink) { + for (int i = 0; i < buckets.length; i++) { + Entry entry = buckets[i]; + buckets[i] = null; + while (entry != null) { + // Unhook before handing over: a sink that retains one entry of a chain would otherwise pin + // the whole chain through `next`, including entries it chose not to keep. Read `next` + // first, since the sink may do anything with the entry once it has it. + Entry next = entry.next(); + entry.setNext(null); + sink.accept((TEntry) entry); + entry = next; + } + } + } + + /** + * Context-passing variant of {@link #drain(Hashtable.Entry[], Consumer)}. Pass a non-capturing + * {@link BiConsumer} (typically a {@code static final}) plus the accumulator as {@code context} + * (e.g. the target list or event builder) to avoid a capturing-lambda allocation. + */ + @SuppressWarnings("unchecked") + public static void drain( + @Nonnull Hashtable.Entry[] buckets, + C context, + @Nonnull BiConsumer sink) { + for (int i = 0; i < buckets.length; i++) { + Entry entry = buckets[i]; + buckets[i] = null; + while (entry != null) { + Entry next = entry.next(); + entry.setNext(null); + sink.accept(context, (TEntry) entry); + entry = next; + } + } + } + + /** + * Manages a table's occupancy against a fixed cap -- both directions. Reserving a slot for an + * insert and evicting to make room are two halves of the same policy, so they live on one object: + * a caller never has to remember to decrement after unlinking, and there is no second object to + * wire up (or mis-wire) alongside the count. + * + *

    {@link D1} and {@link D2} use one internally for their strict entry-count cap; composers + * driving a {@code Hashtable.Entry[]} through the static building blocks can reuse it instead of + * hand-rolling the same increment/decrement/cap-check bookkeeping. A table that never evicts + * simply never calls the eviction half. + * + *

    {@code
    +   * // miss path of a capped, self-evicting table
    +   * if (!sizeManager.tryReserveOrEvict(buckets, STALE)) {
    +   *   return null;                       // full, and nothing was evictable -- drop the datum
    +   * }
    +   * insertHeadEntryFor(buckets, keyHash, newEntry);   // slot already reserved
    +   * }
    + * + *

    Not thread-safe, matching the rest of this class. + */ + public static final class SizeManager { + private final int capacity; + private int size; + /** - * Allocates a bucket array sized to hold {@code requestedSize} entries. Returned length is - * {@code requestedSize} rounded up to the next power of two (capped at {@link #MAX_BUCKETS}). + * Bucket index the last eviction removed from. The next scan resumes here, so a sustained + * eviction stream doesn't repeatedly re-walk the same hot entries clustered near bucket 0. */ - public static final Hashtable.Entry[] create(int requestedSize) { - return new Entry[sizeFor(requestedSize)]; + private int cursor; + + public SizeManager(int capacity) { + this.capacity = capacity; } /** - * Variant of {@link #create(int)} that scales the requested working-set size before sizing the - * bucket array. Pair with {@link #MAX_RATIO} to leave headroom over the working set for a - * desired load factor; the canonical call is {@code create(n, MAX_RATIO)}. + * Live entries, as far as this manager knows -- an estimate, not a census. A reservation taken + * by {@link #tryReserve()} or {@link #tryReserveOrEvict} counts immediately, so between + * reserving and linking the figure runs one high; and {@link Hashtable#insertReserved} trusts + * the caller to have reserved, so a link without one leaves it low. The manager counts what it + * is told, and cannot audit the spine to check. * - *

    The scaled size is truncated to {@code int} before going through {@link #sizeFor(int)}. - * Truncation rather than {@code ceil} is intentional: {@code sizeFor} rounds up to the next - * power of two anyway, so the fractional part would only matter when float fuzz pushes the - * result across a power-of-two boundary -- {@code ceil} would then double the array size for no - * reason (e.g. {@code 12 * 4/3 = 16.0...0005f -> ceil 17 -> sizeFor 32}). + *

    Wrappers that never expose the reservation window -- {@link D1} and {@link D2}, which + * reserve and link inside a single call -- can and do present this as an exact {@code size()}. */ - public static final Hashtable.Entry[] create(int requestedSize, float scale) { - return new Entry[sizeFor((int) (requestedSize * scale))]; + public int estimateSize() { + return this.size; } - /** Upper bound on the bucket array length returned by {@link #sizeFor(int)}. */ - static final int MAX_BUCKETS = 1 << 30; + public int capacity() { + return this.capacity; + } + + /** {@code true} once {@link #size()} has reached {@link #capacity()}. */ + public boolean isFull() { + return this.size >= this.capacity; + } /** - * Inverse of a 75% load factor. Callers that size their bucket array from a target working-set - * size {@code n} should pass {@code create(n, MAX_RATIO)} to leave ~25% headroom in the array. + * Reserves a slot for a fresh insert: increments and returns {@code true}, or leaves the count + * unchanged and returns {@code false} if already at capacity. Use this when the entry to link + * is already fully built (nothing between the check and the increment can fail). When building + * the entry is itself fallible, check {@link #isFull()} first, do the fallible work, then call + * {@link #increment()} only once linking actually succeeds. + * + *

    Returning {@code false} is not a final refusal -- it is the caller's cue to either refuse + * the insert or make room. {@link #tryReserveOrEvict} folds those two steps into one call. */ - public static final float MAX_RATIO = 4.0f / 3.0f; + public boolean tryReserve() { + if (isFull()) { + return false; + } + this.size += 1; + return true; + } /** - * Rounds {@code requestedSize} up to the next power of two, capped at {@link #MAX_BUCKETS}. - * Throws {@link IllegalArgumentException} for negative inputs or inputs above the cap. Returns - * the bucket-array length to allocate. + * {@link #tryReserve()}, falling back to evicting one entry matching {@code evictable} when the + * table is full. Returns {@code true} with a slot reserved, or {@code false} if the table was + * full and nothing was evictable -- in which case {@code buckets} is untouched and the caller + * should drop the datum. + * + *

    The whole capacity decision of a self-evicting table's miss path, in one call. Pass a + * non-capturing {@code evictable} (typically a {@code static final}) to keep it + * allocation-free. */ - static final int sizeFor(int requestedSize) { - if (requestedSize < 0) { - throw new IllegalArgumentException("requestedSize must be non-negative: " + requestedSize); + public boolean tryReserveOrEvict( + @Nonnull Hashtable.Entry[] buckets, @Nonnull Predicate evictable) { + if (tryReserve()) { + return true; + } + if (evictOne(buckets, evictable) == null) { + return false; } - if (requestedSize > MAX_BUCKETS) { - throw new IllegalArgumentException( - "requestedSize exceeds maximum bucket count (" + MAX_BUCKETS + "): " + requestedSize); + // evictOne decremented; the slot it freed is ours. + this.size += 1; + return true; + } + + /** Call after successfully linking a new entry. */ + public void increment() { + this.size += 1; + } + + /** Call after successfully unlinking an entry. */ + public void decrement() { + this.size -= 1; + } + + /** Zeroes both the live count and the eviction scan position. */ + public void reset() { + this.size = 0; + this.cursor = 0; + } + + /** + * Scans {@code buckets} for the first entry matching {@code evictable}, starting where the last + * eviction left off and wrapping around if needed. Unlinks and returns the evicted entry, + * decrementing the count; returns {@code null} (count untouched) if nothing matched anywhere. + * + *

    Resuming from the previous position is what keeps a sustained eviction stream amortized: + * the worst case for a single call is still O(N) when nearly every entry is hot, but N + * successful evictions never re-scan the hot prefix more than twice. + * + *

    That amortization covers successes only. A call that matches nothing has, by + * definition, tested every live entry -- so a table that is full and entirely hot pays a full + * pass per attempt. The cursor still steps on, so repeated refusals at least start from a + * different bucket rather than re-testing in identical order, but the per-attempt cost does not + * shrink. Size the cap to the steady-state working set so this stays the rare path, and keep + * {@code evictable} cheap -- it is called once per live entry on every refusal. + */ + @SuppressWarnings("unchecked") + @Nullable + public TEntry evictOne( + @Nonnull Hashtable.Entry[] buckets, @Nonnull Predicate evictable) { + Entry evicted = evictOneInRange(buckets, evictable, this.cursor, buckets.length); + if (evicted == null && this.cursor != 0) { + evicted = evictOneInRange(buckets, evictable, 0, this.cursor); } - if (requestedSize <= 1) { - return 1; + if (evicted != null) { + this.size -= 1; + return (TEntry) evicted; } - return Integer.highestOneBit(requestedSize - 1) << 1; + // Nothing matched anywhere. Step the cursor on regardless, so a table that is full of hot + // entries doesn't retry from the same origin every time -- successive refusals sweep a + // different starting bucket instead of re-testing the same entries in the same order. + // (buckets.length is a power of two, so the mask wraps.) + this.cursor = (this.cursor + 1) & (buckets.length - 1); + return null; } - public static final void clear(Hashtable.Entry[] buckets) { - Arrays.fill(buckets, null); + @SuppressWarnings("unchecked") + @Nullable + private Entry evictOneInRange( + @Nonnull Hashtable.Entry[] buckets, + @Nonnull Predicate evictable, + int startBucket, + int endBucket) { + MutatingTableIterator iter = mutatingTableIterator(buckets, startBucket, endBucket); + while (iter.hasNext()) { + Entry candidate = iter.next(); + if (evictable.test((TEntry) candidate)) { + int bucket = iter.currentBucket(); + iter.remove(); + this.cursor = bucket; + return candidate; + } + } + return null; } - public static final BucketIterator bucketIterator( - Hashtable.Entry[] buckets, long keyHash) { - return new BucketIterator(buckets, keyHash); + /** + * Unlinks every entry matching {@code evictable} in one full pass, decrementing the count for + * each, and returns how many were removed. Resets the scan position, since a full pass leaves + * nothing later to resume from. + * + *

    Named {@code evictAll} rather than {@code drain} to keep it distinct from {@link + * Hashtable#drain(Hashtable.Entry[], Consumer)}, which empties the whole table into a sink. + * This one removes only what matches, and hands back a count rather than the entries. + */ + @SuppressWarnings("unchecked") + public int evictAll( + @Nonnull Hashtable.Entry[] buckets, @Nonnull Predicate evictable) { + int count = 0; + MutatingTableIterator iter = mutatingTableIterator(buckets); + while (iter.hasNext()) { + Entry candidate = iter.next(); + if (evictable.test((TEntry) candidate)) { + iter.remove(); + // Decrement per removal rather than subtracting `count` after the loop: if `evictable` + // throws part way through, the entries unlinked so far are already gone from the chains, + // and a deferred subtraction would never run -- leaving the count permanently high. + this.size -= 1; + count++; + } + } + this.cursor = 0; + return count; } + } - public static final - MutatingBucketIterator mutatingBucketIterator( - Hashtable.Entry[] buckets, long keyHash) { - return new MutatingBucketIterator(buckets, keyHash); + /** + * The mutable state of a caller-driven table: a bucket array and the {@link SizeManager} sized + * and matched to it. Both halves are stateful and neither is much use without the other, which is + * what the name is getting at -- the spine holds the entries, the manager holds how many there + * are and where the last eviction looked. + * + *

    Hold this, rather than unpacking it. Keeping one field instead of two is not just + * tidier: an array and a manager stored separately can drift apart, which is the mistake this + * type exists to prevent. Composers reach through it -- {@code state.buckets}, {@code + * state.sizeManager} -- when calling the static building blocks. + * + *

    Same headroom idiom as {@link D1}/{@link D2}: {@code maxCapacity} is the strict cap on live + * entries, and the backing array is sized with load-factor headroom over it. + */ + public static final class State { + public final Hashtable.Entry[] buckets; + public final SizeManager sizeManager; + + private State(Hashtable.Entry[] buckets, int maxCapacity) { + this.buckets = buckets; + this.sizeManager = new SizeManager(maxCapacity); } + } + + /** + * Creates a {@link State}: a bucket array sized with load-factor headroom over {@code + * maxCapacity}, paired with a {@link SizeManager} capped at the strict {@code maxCapacity}. + */ + @Nonnull + public static State createCapped(int maxCapacity) { + Hashtable.Entry[] buckets = create(capacityFor(maxCapacity)); + return new State<>(buckets, maxCapacity); + } + + /** + * Deprecated facade over the static building blocks that are now methods on {@link Hashtable} + * itself. Every member here delegates to its {@code Hashtable.*} counterpart -- no real logic + * lives in this class, so it can be deleted outright once the last caller migrates. + * + *

    Retained only for source compatibility with existing callers. New code should call the + * {@code Hashtable.*} statics directly. + * + * @deprecated use the static building blocks on {@link Hashtable} directly. + */ + @Deprecated + public static final class Support { + private Support() {} /** - * Returns a {@link MutatingTableIterator} over every entry in {@code buckets}. Useful for - * sweeps -- eviction, expunge -- that aren't keyed to a specific hash. + * @deprecated use {@link Hashtable#create(int)} (or {@link Hashtable#create(Class, int)} for a + * typed spine). + */ + @Deprecated + @Nonnull + public static Hashtable.Entry[] create(int requestedSize) { + return Hashtable.create(requestedSize); + } + + /** + * Scales the requested working-set size before sizing the bucket array. Pair with {@link + * #MAX_RATIO} to leave headroom over the working set for a desired load factor; the canonical + * call is {@code create(n, MAX_RATIO)}. + * + *

    The scaled size is truncated to {@code int} before going through {@link + * Hashtable#sizeFor(int)}. Truncation rather than {@code ceil} is intentional: {@code sizeFor} + * rounds up to the next power of two anyway, so the fractional part would only matter when + * float fuzz pushes the result across a power-of-two boundary -- {@code ceil} would then double + * the array size for no reason (e.g. {@code 12 * 4/3 = 16.0...0005f -> ceil 17 -> sizeFor 32}). + * + * @deprecated use {@link Hashtable#capacityFor(int)} (or {@link Hashtable#capacityFor(int, + * float)} for a load factor other than {@link Hashtable#DEFAULT_LOAD_FACTOR}), then {@link + * Hashtable#create(Class, int)} with the result. */ - public static final - MutatingTableIterator mutatingTableIterator(Hashtable.Entry[] buckets) { - return new MutatingTableIterator(buckets, 0, buckets.length); + @Deprecated + @Nonnull + public static Hashtable.Entry[] create(int requestedSize, float scale) { + // Deliberately multiplies by `scale` rather than routing through + // Hashtable#capacityFor(int, float), which divides by a load factor: `n * MAX_RATIO` and + // `n / DEFAULT_LOAD_FACTOR` are not bit-identical in float, and this deprecated path keeps + // its exact legacy sizing. Only the allocation itself is inverted onto the blessed API. + return Hashtable.create((int) (requestedSize * scale)); } /** - * Variant of {@link #mutatingTableIterator(Hashtable.Entry[])} that walks only the half-open - * bucket range {@code [startBucket, endBucket)}. Useful for resumable sweeps -- e.g. cursor- - * based eviction in {@code AggregateTable} -- where one call drives {@code [cursor, length)} - * and a wrap-around call drives {@code [0, cursor)}. The iterator does not wrap around - * within a single instance; callers compose two iterators when wrap-around is desired. An empty - * range ({@code startBucket == endBucket}) produces an immediately exhausted iterator. + * Inverse of a 75% load factor. Callers that size their bucket array from a target working-set + * size {@code n} should pass {@code create(n, MAX_RATIO)} to leave ~25% headroom in the array. * - * @param startBucket inclusive lower bound; must be in {@code [0, buckets.length]}. - * @param endBucket exclusive upper bound; must be in {@code [startBucket, buckets.length]}. + * @deprecated equivalent to {@code 1f / Hashtable#DEFAULT_LOAD_FACTOR}; prefer {@link + * Hashtable#capacityFor(int)}, which applies that load factor directly. + */ + @Deprecated public static final float MAX_RATIO = 1.0f / Hashtable.DEFAULT_LOAD_FACTOR; + + /** + * @deprecated use {@link Hashtable#sizeFor(int)}. + */ + @Deprecated + static int sizeFor(int requestedSize) { + return Hashtable.sizeFor(requestedSize); + } + + /** + * @deprecated use {@link Hashtable#clear(Hashtable.Entry[])}. */ - public static final + @Deprecated + public static void clear(@Nonnull Hashtable.Entry[] buckets) { + Hashtable.clear(buckets); + } + + /** + * @deprecated use {@link Hashtable#bucketIterator(Hashtable.Entry[], long)}. + */ + @Deprecated + @Nonnull + public static BucketIterator bucketIterator( + @Nonnull Hashtable.Entry[] buckets, long keyHash) { + return Hashtable.bucketIterator(buckets, keyHash); + } + + /** + * @deprecated use {@link Hashtable#mutatingBucketIterator(Hashtable.Entry[], long)}. + */ + @Deprecated + @Nonnull + public static + MutatingBucketIterator mutatingBucketIterator( + @Nonnull Hashtable.Entry[] buckets, long keyHash) { + return Hashtable.mutatingBucketIterator(buckets, keyHash); + } + + /** + * @deprecated use {@link Hashtable#mutatingTableIterator(Hashtable.Entry[])}. + */ + @Deprecated + @Nonnull + public static + MutatingTableIterator mutatingTableIterator(@Nonnull Hashtable.Entry[] buckets) { + return Hashtable.mutatingTableIterator(buckets); + } + + /** + * @deprecated use {@link Hashtable#mutatingTableIterator(Hashtable.Entry[], int, int)}. + */ + @Deprecated + @Nonnull + public static MutatingTableIterator mutatingTableIterator( - Hashtable.Entry[] buckets, int startBucket, int endBucket) { - return new MutatingTableIterator(buckets, startBucket, endBucket); + @Nonnull Hashtable.Entry[] buckets, int startBucket, int endBucket) { + return Hashtable.mutatingTableIterator(buckets, startBucket, endBucket); } - public static final int bucketIndex(Object[] buckets, long keyHash) { - return (int) (keyHash & buckets.length - 1); + /** + * @deprecated use {@link Hashtable#bucketIndex(Object[], long)}. + */ + @Deprecated + public static int bucketIndex(@Nonnull Object[] buckets, long keyHash) { + return Hashtable.bucketIndex(buckets, keyHash); } /** - * Splices {@code entry} in as the new head of the chain at {@code bucketIndex}. Caller is - * responsible for size accounting -- this method only touches the chain pointers. + * @deprecated use {@link Hashtable#insertHeadEntryAt(Hashtable.Entry[], int, Hashtable.Entry)}. */ - public static final void insertHeadEntry( - Hashtable.Entry[] buckets, int bucketIndex, Hashtable.Entry entry) { - entry.setNext(buckets[bucketIndex]); - buckets[bucketIndex] = entry; + @Deprecated + public static void insertHeadEntry( + @Nonnull Hashtable.Entry[] buckets, int bucketIndex, @Nonnull Hashtable.Entry entry) { + Hashtable.insertHeadEntryAt(buckets, bucketIndex, entry); } /** - * Convenience overload of {@link #insertHeadEntry(Hashtable.Entry[], int, Hashtable.Entry)} - * that derives the bucket index from {@code keyHash}. Use this when the caller has the hash but - * not the index; if the index has already been computed for another reason, prefer the - * int-taking overload to avoid the redundant mask. + * @deprecated use {@link Hashtable#insertHeadEntryFor(Hashtable.Entry[], long, + * Hashtable.Entry)}. */ - public static final void insertHeadEntry( - Hashtable.Entry[] buckets, long keyHash, Hashtable.Entry entry) { - insertHeadEntry(buckets, bucketIndex(buckets, keyHash), entry); + @Deprecated + public static void insertHeadEntry( + @Nonnull Hashtable.Entry[] buckets, long keyHash, @Nonnull Hashtable.Entry entry) { + Hashtable.insertHeadEntryFor(buckets, keyHash, entry); } /** - * Returns the head entry of the bucket that {@code keyHash} maps to, cast to the caller's - * concrete entry type. The unchecked cast lives here so the chain-walk loop at the call site - * doesn't need to thread a raw {@link Entry} variable through. + * @deprecated use {@link Hashtable#bucketFor(Hashtable.Entry[], long)}. */ - @SuppressWarnings("unchecked") - public static final TEntry bucket( - Hashtable.Entry[] buckets, long keyHash) { - return (TEntry) buckets[bucketIndex(buckets, keyHash)]; + @Deprecated + @Nullable + public static TEntry bucket( + @Nonnull Hashtable.Entry[] buckets, long keyHash) { + return Hashtable.bucketFor(buckets, keyHash); } /** - * Walks every entry in {@code buckets} and invokes {@code consumer} on it. The unchecked cast - * to {@code TEntry} lives here (mirroring {@link Entry#next()}) so callers don't have to - * sprinkle it across their own forEach loops. + * @deprecated use {@link Hashtable#forEach(Hashtable.Entry[], Consumer)}. */ - @SuppressWarnings("unchecked") - public static final void forEach( - Hashtable.Entry[] buckets, Consumer consumer) { - for (int i = 0; i < buckets.length; i++) { - for (Hashtable.Entry e = buckets[i]; e != null; e = e.next()) { - consumer.accept((TEntry) e); - } - } + @Deprecated + public static void forEach( + @Nonnull Hashtable.Entry[] buckets, @Nonnull Consumer consumer) { + Hashtable.forEach(buckets, consumer); } /** - * Context-passing variant of {@link #forEach(Hashtable.Entry[], Consumer)}. Pair a - * non-capturing {@link BiConsumer} (typically a {@code static final}) with side-band state - * passed as {@code context} to avoid a fresh-Consumer allocation each call. + * @deprecated use {@link Hashtable#forEach(Hashtable.Entry[], Object, BiConsumer)}. */ - @SuppressWarnings("unchecked") - public static final void forEach( - Hashtable.Entry[] buckets, T context, BiConsumer consumer) { - for (int i = 0; i < buckets.length; i++) { - for (Hashtable.Entry e = buckets[i]; e != null; e = e.next()) { - consumer.accept(context, (TEntry) e); - } - } + @Deprecated + public static void forEach( + @Nonnull Hashtable.Entry[] buckets, + C context, + @Nonnull BiConsumer consumer) { + Hashtable.forEach(buckets, context, consumer); } } @@ -599,9 +1655,9 @@ public static final class BucketIterator implements Iterat private final long keyHash; private Hashtable.Entry nextEntry; - BucketIterator(Hashtable.Entry[] buckets, long keyHash) { + BucketIterator(@Nonnull Hashtable.Entry[] buckets, long keyHash) { this.keyHash = keyHash; - Hashtable.Entry cur = buckets[Support.bucketIndex(buckets, keyHash)]; + Hashtable.Entry cur = buckets[Hashtable.bucketIndex(buckets, keyHash)]; while (cur != null && cur.keyHash != keyHash) { cur = cur.next(); } @@ -615,6 +1671,7 @@ public boolean hasNext() { @Override @SuppressWarnings("unchecked") + @Nonnull public TEntry next() { Hashtable.Entry cur = this.nextEntry; if (cur == null) { @@ -661,11 +1718,11 @@ public static final class MutatingBucketIterator /** The next entry to be returned by next */ private Hashtable.Entry nextEntry; - MutatingBucketIterator(Hashtable.Entry[] buckets, long keyHash) { + MutatingBucketIterator(@Nonnull Hashtable.Entry[] buckets, long keyHash) { this.buckets = buckets; this.keyHash = keyHash; - int bucketIndex = Support.bucketIndex(buckets, keyHash); + int bucketIndex = Hashtable.bucketIndex(buckets, keyHash); Hashtable.Entry headEntry = this.buckets[bucketIndex]; if (headEntry == null) { this.nextEntry = null; @@ -695,6 +1752,7 @@ public boolean hasNext() { @Override @SuppressWarnings("unchecked") + @Nonnull public TEntry next() { Hashtable.Entry curEntry = this.nextEntry; if (curEntry == null) { @@ -739,7 +1797,7 @@ public void remove() { this.curEntry = null; } - public void replace(TEntry replacementEntry) { + public void replace(@Nonnull TEntry replacementEntry) { Hashtable.Entry oldCurEntry = this.curEntry; if (oldCurEntry == null) { throw new IllegalStateException(); @@ -759,10 +1817,10 @@ public void replace(TEntry replacementEntry) { this.curEntry = replacementEntry; } - void setPrevNext(Hashtable.Entry nextEntry) { + void setPrevNext(@Nullable Hashtable.Entry nextEntry) { if (this.curPrevEntry == null) { Hashtable.Entry[] buckets = this.buckets; - buckets[Support.bucketIndex(buckets, this.keyHash)] = nextEntry; + buckets[Hashtable.bucketIndex(buckets, this.keyHash)] = nextEntry; } else { this.curPrevEntry.setNext(nextEntry); } @@ -816,7 +1874,7 @@ public static final class MutatingTableIterator */ private Hashtable.Entry curEntry; - MutatingTableIterator(Hashtable.Entry[] buckets, int startBucket, int endBucket) { + MutatingTableIterator(@Nonnull Hashtable.Entry[] buckets, int startBucket, int endBucket) { this.buckets = buckets; if (startBucket < 0 || startBucket > buckets.length) { throw new IndexOutOfBoundsException( @@ -853,6 +1911,7 @@ public boolean hasNext() { @Override @SuppressWarnings("unchecked") + @Nonnull public TEntry next() { Hashtable.Entry e = this.nextEntry; if (e == null) { diff --git a/internal-api/src/test/java/datadog/trace/util/FlatHashtableD1Test.java b/internal-api/src/test/java/datadog/trace/util/FlatHashtableD1Test.java index 4fc6838974b..e51462451aa 100644 --- a/internal-api/src/test/java/datadog/trace/util/FlatHashtableD1Test.java +++ b/internal-api/src/test/java/datadog/trace/util/FlatHashtableD1Test.java @@ -188,7 +188,7 @@ void getOrCreateOnMissBuildsEntryViaCreator() { FlatHashtable.D1 table = growable(8); int[] createCount = {0}; StringIntEntry created = - table.getOrCreate( + table.tryGetOrCreate( "foo", k -> { createCount[0]++; @@ -209,7 +209,7 @@ void getOrCreateOnHitSkipsCreator() { table.insert(seeded); int[] createCount = {0}; StringIntEntry got = - table.getOrCreate( + table.tryGetOrCreate( "foo", k -> { createCount[0]++; @@ -237,7 +237,7 @@ void growableGrowsPastInitialCapacity() { void growableGetOrCreateNeverReturnsNull() { FlatHashtable.D1 table = growable(1); for (int i = 0; i < 50; i++) { - StringIntEntry e = table.getOrCreate("k" + i, k -> new StringIntEntry(k, 0)); + StringIntEntry e = table.tryGetOrCreate("k" + i, k -> new StringIntEntry(k, 0)); assertNotNull(e); } assertEquals(50, table.size()); @@ -246,15 +246,15 @@ void growableGetOrCreateNeverReturnsNull() { @Test void fixedGetOrCreateCapsWhenFull() { FlatHashtable.D1 table = fixed(2); - assertNotNull(table.getOrCreate("a", k -> new StringIntEntry(k, 1))); - assertNotNull(table.getOrCreate("b", k -> new StringIntEntry(k, 2))); + assertNotNull(table.tryGetOrCreate("a", k -> new StringIntEntry(k, 1))); + assertNotNull(table.tryGetOrCreate("b", k -> new StringIntEntry(k, 2))); assertEquals(2, table.size()); // At capacity, a new key can't be created -> null (caller's overflow default). - assertNull(table.getOrCreate("c", k -> new StringIntEntry(k, 3))); + assertNull(table.tryGetOrCreate("c", k -> new StringIntEntry(k, 3))); assertEquals(2, table.size()); // ...but an existing key still resolves even at capacity (cap blocks creation, not lookup). StringIntEntry a = table.get("a"); - assertSame(a, table.getOrCreate("a", k -> new StringIntEntry(k, 99))); + assertSame(a, table.tryGetOrCreate("a", k -> new StringIntEntry(k, 99))); } @Test diff --git a/internal-api/src/test/java/datadog/trace/util/FlatHashtableD2Test.java b/internal-api/src/test/java/datadog/trace/util/FlatHashtableD2Test.java index 5d19af4fecf..0900617035e 100644 --- a/internal-api/src/test/java/datadog/trace/util/FlatHashtableD2Test.java +++ b/internal-api/src/test/java/datadog/trace/util/FlatHashtableD2Test.java @@ -167,7 +167,7 @@ void getOrCreateOnMissBuildsEntryViaCreator() { FlatHashtable.D2 table = growable(8); int[] createCount = {0}; PairEntry created = - table.getOrCreate( + table.tryGetOrCreate( "foo", 1, (k1, k2) -> { @@ -190,7 +190,7 @@ void getOrCreateOnHitSkipsCreator() { table.insert(seeded); int[] createCount = {0}; PairEntry got = - table.getOrCreate( + table.tryGetOrCreate( "foo", 1, (k1, k2) -> { @@ -217,13 +217,13 @@ void growableGrowsPastInitialCapacity() { @Test void fixedGetOrCreateCapsWhenFull() { FlatHashtable.D2 table = fixed(2); - assertNotNull(table.getOrCreate("a", 1, (k1, k2) -> new PairEntry(k1, k2, 1))); - assertNotNull(table.getOrCreate("b", 2, (k1, k2) -> new PairEntry(k1, k2, 2))); + assertNotNull(table.tryGetOrCreate("a", 1, (k1, k2) -> new PairEntry(k1, k2, 1))); + assertNotNull(table.tryGetOrCreate("b", 2, (k1, k2) -> new PairEntry(k1, k2, 2))); assertEquals(2, table.size()); - assertNull(table.getOrCreate("c", 3, (k1, k2) -> new PairEntry(k1, k2, 3))); + assertNull(table.tryGetOrCreate("c", 3, (k1, k2) -> new PairEntry(k1, k2, 3))); assertEquals(2, table.size()); PairEntry a = table.get("a", 1); - assertSame(a, table.getOrCreate("a", 1, (k1, k2) -> new PairEntry(k1, k2, 99))); + assertSame(a, table.tryGetOrCreate("a", 1, (k1, k2) -> new PairEntry(k1, k2, 99))); } @Test @@ -258,7 +258,7 @@ void hashCollisionsResolveByKeyEquality() { void growableGetOrCreateGrowsPastInitialCapacity() { FlatHashtable.D2 table = growable(1); for (int i = 0; i < 50; i++) { - PairEntry e = table.getOrCreate("k", i, (k1, k2) -> new PairEntry(k1, k2, k2)); + PairEntry e = table.tryGetOrCreate("k", i, (k1, k2) -> new PairEntry(k1, k2, k2)); assertNotNull(e); } assertEquals(50, table.size()); diff --git a/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java b/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java index b1099210611..0ecf3712d3f 100644 --- a/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java +++ b/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java @@ -185,10 +185,10 @@ void create_allocatesTypedTableOfCapacity() { @Test void getOrCreate_insertsOnceAndReturnsTheExistingEntry() { TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); - TestEntry first = FlatHashtable.getOrCreate(table, "a", TestEntryStrategy.INSTANCE, CREATE); + TestEntry first = FlatHashtable.tryGetOrCreate(table, "a", TestEntryStrategy.INSTANCE, CREATE); assertEquals("a", first.key); // A second call must return the SAME instance, not mint a new one. - assertSame(first, FlatHashtable.getOrCreate(table, "a", TestEntryStrategy.INSTANCE, CREATE)); + assertSame(first, FlatHashtable.tryGetOrCreate(table, "a", TestEntryStrategy.INSTANCE, CREATE)); assertSame(first, FlatHashtable.get(table, "a", TestEntryStrategy.INSTANCE)); } @@ -196,7 +196,7 @@ void getOrCreate_insertsOnceAndReturnsTheExistingEntry() { void get_returnsNullForAbsentKey() { TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); assertNull(FlatHashtable.get(table, "missing", TestEntryStrategy.INSTANCE)); - FlatHashtable.getOrCreate(table, "present", TestEntryStrategy.INSTANCE, CREATE); + FlatHashtable.tryGetOrCreate(table, "present", TestEntryStrategy.INSTANCE, CREATE); assertNull(FlatHashtable.get(table, "still-missing", TestEntryStrategy.INSTANCE)); } @@ -204,14 +204,16 @@ void get_returnsNullForAbsentKey() { void getOrCreate_returnsNullWhenTableIsFull() { // capacityFor(1) == 2 slots. TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); - assertTrue(FlatHashtable.getOrCreate(table, "k0", TestEntryStrategy.INSTANCE, CREATE) != null); - assertTrue(FlatHashtable.getOrCreate(table, "k1", TestEntryStrategy.INSTANCE, CREATE) != null); + assertTrue( + FlatHashtable.tryGetOrCreate(table, "k0", TestEntryStrategy.INSTANCE, CREATE) != null); + assertTrue( + FlatHashtable.tryGetOrCreate(table, "k1", TestEntryStrategy.INSTANCE, CREATE) != null); // Both slots occupied by distinct keys -> a third distinct key finds no room. - assertNull(FlatHashtable.getOrCreate(table, "k2", TestEntryStrategy.INSTANCE, CREATE)); + assertNull(FlatHashtable.tryGetOrCreate(table, "k2", TestEntryStrategy.INSTANCE, CREATE)); // ...but an existing key still resolves even when full. assertSame( FlatHashtable.get(table, "k0", TestEntryStrategy.INSTANCE), - FlatHashtable.getOrCreate(table, "k0", TestEntryStrategy.INSTANCE, CREATE)); + FlatHashtable.tryGetOrCreate(table, "k0", TestEntryStrategy.INSTANCE, CREATE)); } @Test @@ -225,11 +227,11 @@ void hashKey_isStableForEqualKeys() { void collision_probesPastOccupiedSlots_andResolvesEach() { // 8 slots; COLLIDING sends all to slot 0 TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); - TestEntry a = FlatHashtable.getOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); + TestEntry a = FlatHashtable.tryGetOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); // slot 0 taken -> 1 - TestEntry b = FlatHashtable.getOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); + TestEntry b = FlatHashtable.tryGetOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); // -> slot 2 - TestEntry c = FlatHashtable.getOrCreate(table, "c", TestCollidingStrategy.INSTANCE, CREATE); + TestEntry c = FlatHashtable.tryGetOrCreate(table, "c", TestCollidingStrategy.INSTANCE, CREATE); assertNotSame(a, b); assertNotSame(b, c); @@ -240,7 +242,7 @@ void collision_probesPastOccupiedSlots_andResolvesEach() { assertSame(c, FlatHashtable.get(table, "c", TestCollidingStrategy.INSTANCE)); // existing colliding key: found after probing, no new entry minted - assertSame(b, FlatHashtable.getOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE)); + assertSame(b, FlatHashtable.tryGetOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE)); // absent key: probe past the 3 occupied slots, hit an empty slot -> null assertNull(FlatHashtable.get(table, "absent", TestCollidingStrategy.INSTANCE)); @@ -251,9 +253,9 @@ void collision_probeWrapsAroundToFront() { // 2 slots (0,1), mask=1; LAST_SLOT starts at 1 TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); // -> slot 1 - TestEntry k0 = FlatHashtable.getOrCreate(table, "k0", TestLastSlotStrategy.INSTANCE, CREATE); + TestEntry k0 = FlatHashtable.tryGetOrCreate(table, "k0", TestLastSlotStrategy.INSTANCE, CREATE); // taken -> wraps to 0 - TestEntry k1 = FlatHashtable.getOrCreate(table, "k1", TestLastSlotStrategy.INSTANCE, CREATE); + TestEntry k1 = FlatHashtable.tryGetOrCreate(table, "k1", TestLastSlotStrategy.INSTANCE, CREATE); assertNotSame(k0, k1); assertSame(k0, FlatHashtable.get(table, "k0", TestLastSlotStrategy.INSTANCE)); @@ -264,9 +266,9 @@ void collision_probeWrapsAroundToFront() { @Test void get_returnsNullWhenTableFullAndKeyAbsent() { TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); // 2 slots - FlatHashtable.getOrCreate(table, "k0", TestCollidingStrategy.INSTANCE, CREATE); + FlatHashtable.tryGetOrCreate(table, "k0", TestCollidingStrategy.INSTANCE, CREATE); // fills slots 0 and 1 - FlatHashtable.getOrCreate(table, "k1", TestCollidingStrategy.INSTANCE, CREATE); + FlatHashtable.tryGetOrCreate(table, "k1", TestCollidingStrategy.INSTANCE, CREATE); // get() probes both occupied slots, wraps back to start -> null (get's full-wrap branch) assertNull(FlatHashtable.get(table, "absent", TestCollidingStrategy.INSTANCE)); @@ -302,9 +304,9 @@ void insert_returnsFalseWhenFull() { @Test void forEach_visitsEveryEntry() { TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); - FlatHashtable.getOrCreate(table, "a", TestEntryStrategy.INSTANCE, CREATE); - FlatHashtable.getOrCreate(table, "b", TestEntryStrategy.INSTANCE, CREATE); - FlatHashtable.getOrCreate(table, "c", TestEntryStrategy.INSTANCE, CREATE); + FlatHashtable.tryGetOrCreate(table, "a", TestEntryStrategy.INSTANCE, CREATE); + FlatHashtable.tryGetOrCreate(table, "b", TestEntryStrategy.INSTANCE, CREATE); + FlatHashtable.tryGetOrCreate(table, "c", TestEntryStrategy.INSTANCE, CREATE); Set seen = new HashSet<>(); FlatHashtable.forEach(table, e -> seen.add(e.key)); @@ -314,8 +316,8 @@ void forEach_visitsEveryEntry() { @Test void forEach_contextVariant_passesContextWithoutCapture() { TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); - FlatHashtable.getOrCreate(table, "a", TestEntryStrategy.INSTANCE, CREATE); - FlatHashtable.getOrCreate(table, "b", TestEntryStrategy.INSTANCE, CREATE); + FlatHashtable.tryGetOrCreate(table, "a", TestEntryStrategy.INSTANCE, CREATE); + FlatHashtable.tryGetOrCreate(table, "b", TestEntryStrategy.INSTANCE, CREATE); Set seen = new HashSet<>(); FlatHashtable.forEach(table, seen, (ctx, e) -> ctx.add(e.key)); @@ -325,9 +327,9 @@ void forEach_contextVariant_passesContextWithoutCapture() { @Test void iterator_yieldsEveryEntrySharingTheHash() { TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); // COLLIDING sends all to slot 0 - TestEntry a = FlatHashtable.getOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); - TestEntry b = FlatHashtable.getOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); - TestEntry c = FlatHashtable.getOrCreate(table, "c", TestCollidingStrategy.INSTANCE, CREATE); + TestEntry a = FlatHashtable.tryGetOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); + TestEntry b = FlatHashtable.tryGetOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); + TestEntry c = FlatHashtable.tryGetOrCreate(table, "c", TestCollidingStrategy.INSTANCE, CREATE); Set seen = new HashSet<>(); Iterator it = FlatHashtable.iterator(table, 0, TestCollidingStrategy.INSTANCE); @@ -340,8 +342,8 @@ void iterator_yieldsEveryEntrySharingTheHash() { @Test void iterator_filtersOutEntriesWithADifferentHash() { TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); // entries at slot 0, hashOf == 0 - FlatHashtable.getOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); - FlatHashtable.getOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); + FlatHashtable.tryGetOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); + FlatHashtable.tryGetOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); // a hash that shares the entries' home slot (0) but that no stored entry has as its hashOf long sameHomeOtherHash = hashLandingOn(0, table.length - 1); @@ -363,8 +365,8 @@ void iterator_fullTable_yieldsMatchesIncludingTheWrappingSlot() { // 2 slots, both filled by colliding (hash 0) entries -> the probe has no empty slot to stop at, // so the traversal must yield the entry on the wrapping slot and then terminate on wrap. TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); - TestEntry a = FlatHashtable.getOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); - TestEntry b = FlatHashtable.getOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); + TestEntry a = FlatHashtable.tryGetOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); + TestEntry b = FlatHashtable.tryGetOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); Set seen = new HashSet<>(); Iterator it = FlatHashtable.iterator(table, 0, TestCollidingStrategy.INSTANCE); @@ -379,8 +381,8 @@ void iterator_fullTable_absentHash_terminatesOnWrap() { // Full table, iterating a hash no stored entry has (all hashOf == 0) -> the traversal walks // every slot and wraps without ever hitting an empty one, then reports no elements. TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); - FlatHashtable.getOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); - FlatHashtable.getOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); + FlatHashtable.tryGetOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); + FlatHashtable.tryGetOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); Iterator it = FlatHashtable.iterator(table, 5, TestCollidingStrategy.INSTANCE); assertFalse(it.hasNext()); @@ -543,7 +545,7 @@ void entryIterator_emptyRunHasNoNext() { void caseInsensitiveStrategy_matchesRegardlessOfCase() { TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); TestEntry stored = - FlatHashtable.getOrCreate( + FlatHashtable.tryGetOrCreate( table, "Content-Type", TestCaseInsensitiveStrategy.INSTANCE, CREATE); // Look-ups in any case resolve to the same stored entry, allocation-free. @@ -553,10 +555,10 @@ void caseInsensitiveStrategy_matchesRegardlessOfCase() { stored, FlatHashtable.get(table, "CONTENT-TYPE", TestCaseInsensitiveStrategy.INSTANCE)); assertSame( stored, FlatHashtable.get(table, "cOnTeNt-TyPe", TestCaseInsensitiveStrategy.INSTANCE)); - // getOrCreate with a differently-cased key does not mint a second entry. + // tryGetOrCreate with a differently-cased key does not mint a second entry. assertSame( stored, - FlatHashtable.getOrCreate( + FlatHashtable.tryGetOrCreate( table, "CONTENT-TYPE", TestCaseInsensitiveStrategy.INSTANCE, CREATE)); assertNull(FlatHashtable.get(table, "content-length", TestCaseInsensitiveStrategy.INSTANCE)); } @@ -575,7 +577,7 @@ void caseInsensitiveStrategy_doesNotFalseMissOnSupplementaryCasePair() { String s2 = new String(Character.toChars(0x10428)); // DESERET SMALL LETTER LONG I TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); TestEntry stored = - FlatHashtable.getOrCreate(table, s1, TestCaseInsensitiveStrategy.INSTANCE, CREATE); + FlatHashtable.tryGetOrCreate(table, s1, TestCaseInsensitiveStrategy.INSTANCE, CREATE); if (s1.equalsIgnoreCase(s2)) { assertSame(stored, FlatHashtable.get(table, s2, TestCaseInsensitiveStrategy.INSTANCE)); diff --git a/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java b/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java index a3cd4c25247..2c1a28b4bf4 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java @@ -4,26 +4,29 @@ import static datadog.trace.util.HashtableTestEntries.CollidingKeyEntry; import static datadog.trace.util.HashtableTestEntries.StringIntEntry; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.HashMap; import java.util.Map; +import java.util.function.ObjLongConsumer; import org.junit.jupiter.api.Test; class HashtableD1Test { @Test void emptyTableLookupReturnsNull() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); assertNull(table.get("missing")); assertEquals(0, table.size()); } @Test void insertedEntryIsRetrievable() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); StringIntEntry e = new StringIntEntry("foo", 1); table.insert(e); assertEquals(1, table.size()); @@ -38,7 +41,8 @@ void keyExposesTheConstructionKey() { @Test void multipleInsertsRetrievableSeparately() { - Hashtable.D1 table = new Hashtable.D1<>(16); + Hashtable.D1 table = + Hashtable.D1.createCapped(StringIntEntry.class, 16); StringIntEntry a = new StringIntEntry("alpha", 1); StringIntEntry b = new StringIntEntry("beta", 2); StringIntEntry c = new StringIntEntry("gamma", 3); @@ -53,7 +57,7 @@ void multipleInsertsRetrievableSeparately() { @Test void inPlaceMutationVisibleViaSubsequentGet() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); table.insert(new StringIntEntry("counter", 0)); for (int i = 0; i < 10; i++) { StringIntEntry e = table.get("counter"); @@ -64,7 +68,7 @@ void inPlaceMutationVisibleViaSubsequentGet() { @Test void removeUnlinksEntryAndDecrementsSize() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); table.insert(new StringIntEntry("a", 1)); table.insert(new StringIntEntry("b", 2)); assertEquals(2, table.size()); @@ -79,28 +83,28 @@ void removeUnlinksEntryAndDecrementsSize() { @Test void removeNonexistentReturnsNullAndDoesNotChangeSize() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); table.insert(new StringIntEntry("a", 1)); assertNull(table.remove("nope")); assertEquals(1, table.size()); } @Test - void insertOrReplaceReturnsPriorEntryOrNullOnInsert() { - Hashtable.D1 table = new Hashtable.D1<>(8); + void tryInsertOrReplaceInsertsThenReplacesWithoutGrowing() { + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); StringIntEntry first = new StringIntEntry("k", 1); - assertNull(table.insertOrReplace(first), "fresh insert returns null"); + assertTrue(table.tryInsertOrReplace(first), "fresh insert accepted"); assertEquals(1, table.size()); StringIntEntry second = new StringIntEntry("k", 2); - assertSame(first, table.insertOrReplace(second), "replace returns the prior entry"); - assertEquals(1, table.size()); + assertTrue(table.tryInsertOrReplace(second), "replace accepted"); + assertEquals(1, table.size(), "replacing an existing key does not grow the table"); assertSame(second, table.get("k"), "new entry visible after replace"); } @Test void clearEmptiesTheTable() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); table.insert(new StringIntEntry("a", 1)); table.insert(new StringIntEntry("b", 2)); table.clear(); @@ -113,7 +117,7 @@ void clearEmptiesTheTable() { @Test void forEachVisitsEveryInsertedEntry() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); table.insert(new StringIntEntry("a", 1)); table.insert(new StringIntEntry("b", 2)); table.insert(new StringIntEntry("c", 3)); @@ -127,7 +131,7 @@ void forEachVisitsEveryInsertedEntry() { @Test void forEachWithContextPassesContextToConsumer() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); table.insert(new StringIntEntry("a", 10)); table.insert(new StringIntEntry("b", 20)); table.insert(new StringIntEntry("c", 30)); @@ -141,7 +145,7 @@ void forEachWithContextPassesContextToConsumer() { @Test void forEachWithContextOnEmptyTableDoesNothing() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); Map seen = new HashMap<>(); table.forEach(seen, (ctx, e) -> ctx.put(e.key, e.value)); assertEquals(0, seen.size()); @@ -149,7 +153,7 @@ void forEachWithContextOnEmptyTableDoesNothing() { @Test void nullKeyIsPermittedAndDistinctFromAbsent() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); assertNull(table.get(null)); StringIntEntry nullKeyed = new StringIntEntry(null, 7); table.insert(nullKeyed); @@ -163,7 +167,8 @@ void nullKeyIsPermittedAndDistinctFromAbsent() { void hashCollisionsResolveByEquality() { // Force two distinct keys with the same hashCode -- the chain must still distinguish them // via matches(). - Hashtable.D1 table = new Hashtable.D1<>(4); + Hashtable.D1 table = + Hashtable.D1.createCapped(CollidingKeyEntry.class, 4); CollidingKey k1 = new CollidingKey("first", 17); CollidingKey k2 = new CollidingKey("second", 17); CollidingKeyEntry e1 = new CollidingKeyEntry(k1, 100); @@ -177,7 +182,8 @@ void hashCollisionsResolveByEquality() { @Test void hashCollisionsThenRemoveLeavesOtherIntact() { - Hashtable.D1 table = new Hashtable.D1<>(4); + Hashtable.D1 table = + Hashtable.D1.createCapped(CollidingKeyEntry.class, 4); CollidingKey k1 = new CollidingKey("first", 17); CollidingKey k2 = new CollidingKey("second", 17); CollidingKey k3 = new CollidingKey("third", 17); @@ -193,10 +199,10 @@ void hashCollisionsThenRemoveLeavesOtherIntact() { @Test void getOrCreateOnMissBuildsEntryViaCreator() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); int[] createCount = {0}; StringIntEntry created = - table.getOrCreate( + table.tryGetOrCreate( "foo", k -> { createCount[0]++; @@ -212,12 +218,12 @@ void getOrCreateOnMissBuildsEntryViaCreator() { @Test void getOrCreateOnHitSkipsCreator() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); StringIntEntry seeded = new StringIntEntry("foo", 1); table.insert(seeded); int[] createCount = {0}; StringIntEntry got = - table.getOrCreate( + table.tryGetOrCreate( "foo", k -> { createCount[0]++; @@ -230,12 +236,208 @@ void getOrCreateOnHitSkipsCreator() { @Test void getOrCreateNullKeyIsPermitted() { - Hashtable.D1 table = new Hashtable.D1<>(8); - StringIntEntry created = table.getOrCreate(null, k -> new StringIntEntry(k, 7)); + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); + StringIntEntry created = table.tryGetOrCreate(null, k -> new StringIntEntry(k, 7)); assertNotNull(created); assertNull(created.key); assertEquals(7, created.value); - assertSame(created, table.getOrCreate(null, k -> new StringIntEntry(k, 999))); + assertSame(created, table.tryGetOrCreate(null, k -> new StringIntEntry(k, 999))); + assertEquals(1, table.size()); + } + + @Test + void insertReturnsFalseOnceAtCapacity() { + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 2); + assertTrue(table.insert(new StringIntEntry("a", 1))); + assertTrue(table.insert(new StringIntEntry("b", 2))); + assertFalse(table.insert(new StringIntEntry("c", 3))); + assertEquals(2, table.size()); + assertNull(table.get("c")); + } + + @Test + void getOrCreateReturnsNullOnceAtCapacityButStillReturnsHits() { + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 2); + table.insert(new StringIntEntry("a", 1)); + table.insert(new StringIntEntry("b", 2)); + + assertNull(table.tryGetOrCreate("c", k -> new StringIntEntry(k, 3))); + assertEquals(2, table.size()); + + StringIntEntry hit = table.tryGetOrCreate("a", k -> new StringIntEntry(k, 999)); + assertEquals(1, hit.value, "existing entry is still returned even at capacity"); + } + + @Test + void tryInsertOrReplaceStillReplacesAtCapacityButRefusesFreshInsert() { + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 2); + table.insert(new StringIntEntry("a", 1)); + table.insert(new StringIntEntry("b", 2)); + + StringIntEntry replacement = new StringIntEntry("a", 99); + assertTrue( + table.tryInsertOrReplace(replacement), "replacing an existing key succeeds when full"); + assertSame(replacement, table.get("a")); + assertEquals(2, table.size()); + + assertFalse( + table.tryInsertOrReplace(new StringIntEntry("c", 3)), + "a fresh insert is refused, not thrown"); + assertEquals(2, table.size()); + assertNull(table.get("c")); + } + + @Test + void isFullReflectsCapacity() { + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 2); + assertFalse(table.isFull()); + table.insert(new StringIntEntry("a", 1)); + assertFalse(table.isFull()); + table.insert(new StringIntEntry("b", 2)); + assertTrue(table.isFull()); + table.remove("a"); + assertFalse(table.isFull()); + } + + @Test + void drainVisitsEveryEntryThenEmptiesTable() { + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); + table.insert(new StringIntEntry("a", 1)); + table.insert(new StringIntEntry("b", 2)); + Map drained = new HashMap<>(); + + table.drain(e -> drained.put(e.key, e.value)); + + assertEquals(2, drained.size()); + assertEquals(1, drained.get("a")); + assertEquals(2, drained.get("b")); + assertEquals(0, table.size()); + assertNull(table.get("a")); + assertNull(table.get("b")); + + // Table is reusable after drain. + table.insert(new StringIntEntry("c", 3)); + assertEquals(1, table.size()); + assertEquals(3, table.get("c").value); + } + + @Test + void drainWithContextPassesContextToSink() { + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); + table.insert(new StringIntEntry("a", 1)); + table.insert(new StringIntEntry("b", 2)); + Map drained = new HashMap<>(); + + table.drain(drained, (ctx, e) -> ctx.put(e.key, e.value)); + + assertEquals(2, drained.size()); + assertEquals(0, table.size()); + } + + @Test + void drainOnEmptyTableDoesNothing() { + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); + Map drained = new HashMap<>(); + table.drain(e -> drained.put(e.key, e.value)); + assertEquals(0, drained.size()); + assertEquals(0, table.size()); + } + + @Test + void tryGetOrUpdateCreatesThenAppliesUpdater() { + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); + assertTrue(table.tryGetOrUpdate("a", k -> new StringIntEntry(k, 0), e -> e.value += 5)); + assertEquals(1, table.size()); + assertEquals(5, table.get("a").value); + } + + @Test + void tryGetOrUpdateUpdatesExistingEntryInPlace() { + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); + table.insert(new StringIntEntry("a", 10)); + StringIntEntry existing = table.get("a"); + assertTrue(table.tryGetOrUpdate("a", k -> new StringIntEntry(k, 0), e -> e.value += 5)); assertEquals(1, table.size()); + assertEquals(15, existing.value); + assertSame(existing, table.get("a")); + } + + @Test + void tryGetOrUpdateReturnsFalseAtCapacityWithoutUpdating() { + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 2); + table.insert(new StringIntEntry("a", 1)); + table.insert(new StringIntEntry("b", 2)); + boolean[] updaterRan = {false}; + assertFalse( + table.tryGetOrUpdate( + "c", + k -> new StringIntEntry(k, 0), + e -> { + updaterRan[0] = true; + })); + assertFalse(updaterRan[0], "updater must not run when the create is refused"); + assertEquals(2, table.size()); + assertNull(table.get("c")); + } + + @Test + void tryGetOrUpdateAtCapacityStillUpdatesAnExistingKey() { + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 2); + table.insert(new StringIntEntry("a", 1)); + table.insert(new StringIntEntry("b", 2)); + assertTrue(table.tryGetOrUpdate("a", k -> new StringIntEntry(k, 0), e -> e.value += 7)); + assertEquals(8, table.get("a").value); } + + @Test + void tryGetOrUpdateWithContextPassesContextToUpdater() { + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); + // Boxed on purpose: an int literal would bind to the primitive-long overload instead. + assertTrue( + table.tryGetOrUpdate( + "a", k -> new StringIntEntry(k, 0), Integer.valueOf(4), (n, e) -> e.value += n)); + assertTrue( + table.tryGetOrUpdate( + "a", k -> new StringIntEntry(k, 0), Integer.valueOf(6), (n, e) -> e.value += n)); + assertEquals(1, table.size()); + assertEquals(10, table.get("a").value); + } + + @Test + void tryGetOrUpdateWithContextReturnsFalseAtCapacity() { + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 1); + table.insert(new StringIntEntry("a", 1)); + assertFalse( + table.tryGetOrUpdate( + "b", k -> new StringIntEntry(k, 0), Integer.valueOf(4), (n, e) -> e.value += n)); + assertEquals(1, table.size()); + } + + @Test + void tryGetOrUpdateWithLongContextCreatesThenAccumulates() { + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 8); + assertTrue(table.tryGetOrUpdate("a", k -> new StringIntEntry(k, 0), 4L, ADD_LONG)); + assertTrue(table.tryGetOrUpdate("a", k -> new StringIntEntry(k, 0), 6L, ADD_LONG)); + assertEquals(1, table.size()); + assertEquals(10, table.get("a").value); + } + + @Test + void tryGetOrUpdateWithLongContextReturnsFalseAtCapacityWithoutUpdating() { + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 1); + table.insert(new StringIntEntry("a", 1)); + assertFalse(table.tryGetOrUpdate("b", k -> new StringIntEntry(k, 0), 4L, ADD_LONG)); + assertEquals(1, table.size()); + assertEquals(1, table.get("a").value); + } + + @Test + void tryGetOrUpdateWithLongContextAtCapacityStillUpdatesAnExistingKey() { + Hashtable.D1 table = Hashtable.D1.createCapped(StringIntEntry.class, 1); + table.insert(new StringIntEntry("a", 1)); + assertTrue(table.tryGetOrUpdate("a", k -> new StringIntEntry(k, 0), 4L, ADD_LONG)); + assertEquals(5, table.get("a").value); + } + + private static final ObjLongConsumer ADD_LONG = (e, n) -> e.value += (int) n; } diff --git a/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java b/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java index fb621f89482..16739c4a9a5 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java @@ -15,7 +15,7 @@ class HashtableD2Test { @Test void pairKeysParticipateInIdentity() { - Hashtable.D2 table = new Hashtable.D2<>(8); + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); PairEntry ab = new PairEntry("a", 1, 100); PairEntry ac = new PairEntry("a", 2, 200); PairEntry bb = new PairEntry("b", 1, 300); @@ -31,7 +31,7 @@ void pairKeysParticipateInIdentity() { @Test void removePairUnlinks() { - Hashtable.D2 table = new Hashtable.D2<>(8); + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); PairEntry ab = new PairEntry("a", 1, 100); PairEntry ac = new PairEntry("a", 2, 200); table.insert(ab); @@ -43,21 +43,22 @@ void removePairUnlinks() { } @Test - void insertOrReplaceMatchesOnBothKeys() { - Hashtable.D2 table = new Hashtable.D2<>(8); + void tryInsertOrReplaceMatchesOnBothKeys() { + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); PairEntry first = new PairEntry("k", 7, 1); - assertNull(table.insertOrReplace(first)); + assertTrue(table.tryInsertOrReplace(first)); PairEntry second = new PairEntry("k", 7, 2); - assertSame(first, table.insertOrReplace(second)); + assertTrue(table.tryInsertOrReplace(second)); + assertSame(second, table.get("k", 7), "same key pair replaced in place"); // Different second-key: should insert new, not replace PairEntry third = new PairEntry("k", 8, 3); - assertNull(table.insertOrReplace(third)); + assertTrue(table.tryInsertOrReplace(third)); assertEquals(2, table.size()); } @Test void forEachVisitsBothPairs() { - Hashtable.D2 table = new Hashtable.D2<>(8); + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); table.insert(new PairEntry("a", 1, 100)); table.insert(new PairEntry("b", 2, 200)); Set seen = new HashSet<>(); @@ -69,7 +70,7 @@ void forEachVisitsBothPairs() { @Test void forEachWithContextPassesContextToConsumer() { - Hashtable.D2 table = new Hashtable.D2<>(8); + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); table.insert(new PairEntry("a", 1, 100)); table.insert(new PairEntry("b", 2, 200)); Set seen = new HashSet<>(); @@ -81,10 +82,10 @@ void forEachWithContextPassesContextToConsumer() { @Test void getOrCreateOnMissBuildsEntryViaCreator() { - Hashtable.D2 table = new Hashtable.D2<>(8); + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); int[] createCount = {0}; PairEntry created = - table.getOrCreate( + table.tryGetOrCreate( "a", 1, (k1, k2) -> { @@ -102,12 +103,12 @@ void getOrCreateOnMissBuildsEntryViaCreator() { @Test void getOrCreateOnHitSkipsCreator() { - Hashtable.D2 table = new Hashtable.D2<>(8); + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); PairEntry seeded = new PairEntry("a", 1, 100); table.insert(seeded); int[] createCount = {0}; PairEntry got = - table.getOrCreate( + table.tryGetOrCreate( "a", 1, (k1, k2) -> { @@ -160,7 +161,7 @@ void entryHashDiffersForDifferentKeys() { @Test void removeReturnsNullForMissingKey() { - Hashtable.D2 table = new Hashtable.D2<>(8); + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); table.insert(new PairEntry("a", 1, 100)); assertNull(table.remove("a", 2)); @@ -170,7 +171,7 @@ void removeReturnsNullForMissingKey() { @Test void clearEmptiesTable() { - Hashtable.D2 table = new Hashtable.D2<>(8); + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); table.insert(new PairEntry("a", 1, 100)); table.insert(new PairEntry("b", 2, 200)); assertEquals(2, table.size()); @@ -182,6 +183,162 @@ void clearEmptiesTable() { assertNull(table.get("b", 2)); } + @Test + void insertReturnsFalseOnceAtCapacity() { + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 2); + assertTrue(table.insert(new PairEntry("a", 1, 100))); + assertTrue(table.insert(new PairEntry("b", 2, 200))); + assertFalse(table.insert(new PairEntry("c", 3, 300))); + assertEquals(2, table.size()); + assertNull(table.get("c", 3)); + } + + @Test + void getOrCreateReturnsNullOnceAtCapacityButStillReturnsHits() { + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 2); + table.insert(new PairEntry("a", 1, 100)); + table.insert(new PairEntry("b", 2, 200)); + + assertNull(table.tryGetOrCreate("c", 3, (k1, k2) -> new PairEntry(k1, k2, 300))); + assertEquals(2, table.size()); + + PairEntry hit = table.tryGetOrCreate("a", 1, (k1, k2) -> new PairEntry(k1, k2, 999)); + assertEquals(100, hit.value, "existing entry is still returned even at capacity"); + } + + @Test + void tryInsertOrReplaceStillReplacesAtCapacityButRefusesFreshInsert() { + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 2); + table.insert(new PairEntry("a", 1, 100)); + table.insert(new PairEntry("b", 2, 200)); + + PairEntry replacement = new PairEntry("a", 1, 999); + assertTrue( + table.tryInsertOrReplace(replacement), "replacing an existing key succeeds when full"); + assertSame(replacement, table.get("a", 1)); + assertEquals(2, table.size()); + + assertFalse( + table.tryInsertOrReplace(new PairEntry("c", 3, 300)), + "a fresh insert is refused, not thrown"); + assertEquals(2, table.size()); + assertNull(table.get("c", 3)); + } + + @Test + void isFullReflectsCapacity() { + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 2); + assertFalse(table.isFull()); + table.insert(new PairEntry("a", 1, 100)); + assertFalse(table.isFull()); + table.insert(new PairEntry("b", 2, 200)); + assertTrue(table.isFull()); + table.remove("a", 1); + assertFalse(table.isFull()); + } + + @Test + void drainVisitsEveryEntryThenEmptiesTable() { + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); + table.insert(new PairEntry("a", 1, 100)); + table.insert(new PairEntry("b", 2, 200)); + Set drained = new HashSet<>(); + + table.drain(e -> drained.add(e.key1 + ":" + e.key2)); + + assertEquals(2, drained.size()); + assertTrue(drained.contains("a:1")); + assertTrue(drained.contains("b:2")); + assertEquals(0, table.size()); + assertNull(table.get("a", 1)); + assertNull(table.get("b", 2)); + } + + @Test + void drainWithContextPassesContextToSink() { + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); + table.insert(new PairEntry("a", 1, 100)); + table.insert(new PairEntry("b", 2, 200)); + Set drained = new HashSet<>(); + + table.drain(drained, (ctx, e) -> ctx.add(e.key1 + ":" + e.key2)); + + assertEquals(2, drained.size()); + assertEquals(0, table.size()); + } + + @Test + void drainOnEmptyTableDoesNothing() { + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); + Set drained = new HashSet<>(); + table.drain(e -> drained.add(e.key1 + ":" + e.key2)); + assertEquals(0, drained.size()); + assertEquals(0, table.size()); + } + + @Test + void tryGetOrUpdateCreatesThenAppliesUpdater() { + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); + assertTrue( + table.tryGetOrUpdate("a", 1, (k1, k2) -> new PairEntry(k1, k2, 0), e -> e.value += 5)); + assertEquals(1, table.size()); + assertEquals(5, table.get("a", 1).value); + } + + @Test + void tryGetOrUpdateUpdatesExistingEntryInPlace() { + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); + table.insert(new PairEntry("a", 1, 10)); + PairEntry existing = table.get("a", 1); + assertTrue( + table.tryGetOrUpdate("a", 1, (k1, k2) -> new PairEntry(k1, k2, 0), e -> e.value += 5)); + assertEquals(1, table.size()); + assertEquals(15, existing.value); + assertSame(existing, table.get("a", 1)); + } + + @Test + void tryGetOrUpdateReturnsFalseAtCapacityWithoutUpdating() { + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 2); + table.insert(new PairEntry("a", 1, 1)); + table.insert(new PairEntry("b", 2, 2)); + boolean[] updaterRan = {false}; + assertFalse( + table.tryGetOrUpdate( + "c", + 3, + (k1, k2) -> new PairEntry(k1, k2, 0), + e -> { + updaterRan[0] = true; + })); + assertFalse(updaterRan[0], "updater must not run when the create is refused"); + assertEquals(2, table.size()); + assertNull(table.get("c", 3)); + } + + @Test + void tryGetOrUpdateWithContextPassesContextToUpdater() { + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 8); + assertTrue( + table.tryGetOrUpdate( + "a", 1, (k1, k2) -> new PairEntry(k1, k2, 0), 4, (n, e) -> e.value += n)); + assertTrue( + table.tryGetOrUpdate( + "a", 1, (k1, k2) -> new PairEntry(k1, k2, 0), 6, (n, e) -> e.value += n)); + assertEquals(1, table.size()); + assertEquals(10, table.get("a", 1).value); + } + + @Test + void tryGetOrUpdateWithContextReturnsFalseAtCapacity() { + Hashtable.D2 table = Hashtable.D2.createCapped(PairEntry.class, 1); + table.insert(new PairEntry("a", 1, 1)); + assertFalse( + table.tryGetOrUpdate( + "b", 2, (k1, k2) -> new PairEntry(k1, k2, 0), 4, (n, e) -> e.value += n)); + assertEquals(1, table.size()); + } + private static final class PairEntry extends Hashtable.D2.Entry { int value; diff --git a/internal-api/src/test/java/datadog/trace/util/HashtableTest.java b/internal-api/src/test/java/datadog/trace/util/HashtableTest.java index 953453ca3aa..91bead646fd 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableTest.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableTest.java @@ -15,7 +15,9 @@ import datadog.trace.util.Hashtable.MutatingBucketIterator; import datadog.trace.util.Hashtable.MutatingTableIterator; import datadog.trace.util.Hashtable.Support; +import java.util.ArrayList; import java.util.HashSet; +import java.util.List; import java.util.NoSuchElementException; import java.util.Set; import org.junit.jupiter.api.Nested; @@ -23,16 +25,16 @@ class HashtableTest { - // ============ Support ============ + // ============ Static building blocks ============ @Nested - class SupportTests { + class StaticBuildingBlockTests { @Test void createRoundsCapacityUpToPowerOfTwo() { // The Hashtable.D1 / D2 size() reflects entries, but the bucket array length is // a power of two >= requestedCapacity. We can verify indirectly via bucketIndex masking. - Hashtable.Entry[] buckets = Support.create(5); + Hashtable.Entry[] buckets = Hashtable.create(StringIntEntry.class, 5); // Length must be a power of two >= 5 int len = buckets.length; assertTrue(len >= 5); @@ -41,51 +43,176 @@ void createRoundsCapacityUpToPowerOfTwo() { @Test void sizeForReturnsAtLeastOne() { - assertEquals(1, Support.sizeFor(0)); - assertEquals(1, Support.sizeFor(1)); + assertEquals(1, Hashtable.sizeFor(0)); + assertEquals(1, Hashtable.sizeFor(1)); } @Test void sizeForRoundsUpToPowerOfTwo() { - assertEquals(2, Support.sizeFor(2)); - assertEquals(4, Support.sizeFor(3)); - assertEquals(4, Support.sizeFor(4)); - assertEquals(8, Support.sizeFor(5)); - assertEquals(1 << 30, Support.sizeFor(1 << 30)); + assertEquals(2, Hashtable.sizeFor(2)); + assertEquals(4, Hashtable.sizeFor(3)); + assertEquals(4, Hashtable.sizeFor(4)); + assertEquals(8, Hashtable.sizeFor(5)); + assertEquals(1 << 30, Hashtable.sizeFor(1 << 30)); } @Test void sizeForRejectsCapacityAboveMax() { - assertThrows(IllegalArgumentException.class, () -> Support.sizeFor((1 << 30) + 1)); - assertThrows(IllegalArgumentException.class, () -> Support.sizeFor(Integer.MAX_VALUE)); + assertThrows(IllegalArgumentException.class, () -> Hashtable.sizeFor((1 << 30) + 1)); + assertThrows(IllegalArgumentException.class, () -> Hashtable.sizeFor(Integer.MAX_VALUE)); } @Test void sizeForRejectsNegativeCapacity() { - assertThrows(IllegalArgumentException.class, () -> Support.sizeFor(-1)); - assertThrows(IllegalArgumentException.class, () -> Support.sizeFor(Integer.MIN_VALUE)); + assertThrows(IllegalArgumentException.class, () -> Hashtable.sizeFor(-1)); + assertThrows(IllegalArgumentException.class, () -> Hashtable.sizeFor(Integer.MIN_VALUE)); + } + + @Test + void capacityForAppliesDefaultLoadFactorHeadroom() { + // 12 / 0.75 = 16 -> already a power of two. + assertEquals(16, Hashtable.capacityFor(12)); + // 5 / 0.75 = 6.67 -> truncated to 6 -> sizeFor rounds up to 8. + assertEquals(8, Hashtable.capacityFor(5)); + } + + @Test + void capacityForMatchesDefaultLoadFactorConstant() { + assertEquals(0.75f, Hashtable.DEFAULT_LOAD_FACTOR); + assertEquals( + Hashtable.capacityFor(20), Hashtable.capacityFor(20, Hashtable.DEFAULT_LOAD_FACTOR)); + } + + @Test + void capacityForAtExplicitLoadFactor() { + // 10 / 0.5 = 20 -> sizeFor rounds up to 32. + assertEquals(32, Hashtable.capacityFor(10, 0.5f)); + } + + @Test + void capacityForRejectsLoadFactorOutOfRange() { + assertThrows(IllegalArgumentException.class, () -> Hashtable.capacityFor(10, 0f)); + assertThrows(IllegalArgumentException.class, () -> Hashtable.capacityFor(10, 1f)); + assertThrows(IllegalArgumentException.class, () -> Hashtable.capacityFor(10, -0.5f)); + } + + // removeMatching and the size-tracked insertHeadEntryFor are blessed building blocks for + // external composers (e.g. client-side stats) rather than something D1/D2 delegate to, so they + // are covered directly here. + + @Test + void insertHeadEntryForTracksSizeAndRefusesAtCapacity() { + Hashtable.Entry[] buckets = Hashtable.create(2); + Hashtable.SizeManager size = new Hashtable.SizeManager(2); + + StringIntEntry a = new StringIntEntry("a", 1); + StringIntEntry b = new StringIntEntry("b", 2); + StringIntEntry c = new StringIntEntry("c", 3); + + assertTrue(Hashtable.insertHeadEntryFor(size, buckets, a.keyHash, a)); + assertTrue(Hashtable.insertHeadEntryFor(size, buckets, b.keyHash, b)); + assertEquals(2, size.estimateSize()); + + assertFalse( + Hashtable.insertHeadEntryFor(size, buckets, c.keyHash, c), + "refused once the tracker is at capacity"); + assertEquals(2, size.estimateSize(), "a refused insert must not consume a slot"); + } + + @Test + void removeMatchingUnlinksAndDecrements() { + Hashtable.Entry[] buckets = Hashtable.create(8); + Hashtable.SizeManager size = new Hashtable.SizeManager(8); + StringIntEntry a = new StringIntEntry("a", 1); + Hashtable.insertHeadEntryFor(size, buckets, a.keyHash, a); + + StringIntEntry removed = + Hashtable.removeMatching(size, buckets, a.keyHash, e -> e.matches("a")); + + assertSame(a, removed); + assertEquals(0, size.estimateSize()); + assertNull(Hashtable.bucketFor(buckets, a.keyHash)); + } + + @Test + void removeMatchingReturnsNullAndLeavesStateWhenNothingMatches() { + Hashtable.Entry[] buckets = Hashtable.create(8); + Hashtable.SizeManager size = new Hashtable.SizeManager(8); + StringIntEntry a = new StringIntEntry("a", 1); + Hashtable.insertHeadEntryFor(size, buckets, a.keyHash, a); + + assertNull( + Hashtable.removeMatching( + size, buckets, a.keyHash, e -> e.matches("nope"))); + assertEquals(1, size.estimateSize(), "a non-matching scan must not decrement"); + assertSame(a, Hashtable.bucketFor(buckets, a.keyHash)); } @Test void bucketIndexIsBoundedByArrayLength() { - Hashtable.Entry[] buckets = Support.create(16); + Hashtable.Entry[] buckets = Hashtable.create(StringIntEntry.class, 16); for (long h : new long[] {0L, 1L, -1L, Long.MIN_VALUE, Long.MAX_VALUE, 12345L}) { - int idx = Support.bucketIndex(buckets, h); + int idx = Hashtable.bucketIndex(buckets, h); assertTrue(idx >= 0 && idx < buckets.length, "bucketIndex out of range for hash " + h); } } @Test void clearNullsAllBuckets() { - Hashtable.Entry[] buckets = Support.create(4); + Hashtable.State table = Hashtable.createCapped(4); + Hashtable.Entry[] buckets = table.buckets; buckets[0] = new StringIntEntry("x", 1); buckets[1] = new StringIntEntry("y", 2); - Support.clear(buckets); + Hashtable.clear(buckets); + for (Hashtable.Entry b : buckets) { + assertNull(b); + } + } + + @Test + void drainVisitsEveryEntryThenClears() { + Hashtable.State table = Hashtable.createCapped(4); + Hashtable.Entry[] buckets = table.buckets; + buckets[0] = new StringIntEntry("x", 1); + buckets[1] = new StringIntEntry("y", 2); + Set drained = new HashSet<>(); + Hashtable.drain(buckets, e -> drained.add(e.key)); + assertEquals(2, drained.size()); + assertTrue(drained.contains("x")); + assertTrue(drained.contains("y")); for (Hashtable.Entry b : buckets) { assertNull(b); } } + @Test + void insertHeadEntrySplicesAsNewHead() { + Hashtable.State table = Hashtable.createCapped(4); + Hashtable.Entry[] buckets = table.buckets; + StringIntEntry a = new StringIntEntry("a", 1); + StringIntEntry b = new StringIntEntry("b", 2); + Hashtable.insertHeadEntryAt(buckets, 0, a); + assertSame(a, buckets[0]); + assertNull(a.next()); + + Hashtable.insertHeadEntryAt(buckets, 0, b); + assertSame(b, buckets[0]); + assertSame(a, b.next()); + assertNull(a.next()); + } + } + + // ============ Deprecated Support facade ============ + + /** + * The scaled {@code create(int, float)} factory and {@code MAX_RATIO} are deprecated-only: they + * have no blessed equivalent on {@link Hashtable} but remain in use by client-side statistics, so + * they keep dedicated coverage here. + */ + @Nested + @SuppressWarnings("deprecation") + class DeprecatedSupportTests { + @Test void maxRatioScalesTargetForLoadFactor() { // 75% load factor => bucket array sized at requestedSize * 4/3, rounded up to power of 2. @@ -103,18 +230,112 @@ void createWithScaleRoundsUpToPowerOfTwo() { } @Test - void insertHeadEntrySplicesAsNewHead() { + void createWithoutScaleDelegatesToHashtableSizeFor() { + Hashtable.Entry[] buckets = Support.create(5); + assertEquals(Hashtable.create(StringIntEntry.class, 5).length, buckets.length); + } + + @Test + void clearDelegatesToHashtableClear() { Hashtable.Entry[] buckets = Support.create(4); - StringIntEntry a = new StringIntEntry("a", 1); - StringIntEntry b = new StringIntEntry("b", 2); - Support.insertHeadEntry(buckets, 0, a); - assertSame(a, buckets[0]); - assertNull(a.next()); + buckets[0] = new StringIntEntry("a", 1); + Support.clear(buckets); + for (Hashtable.Entry b : buckets) { + assertNull(b); + } + } - Support.insertHeadEntry(buckets, 0, b); - assertSame(b, buckets[0]); - assertSame(a, b.next()); - assertNull(a.next()); + @Test + void bucketIndexDelegatesToHashtableBucketIndex() { + Hashtable.Entry[] buckets = Support.create(4); + long hash = StringIntEntry.hash("a"); + assertEquals(Hashtable.bucketIndex(buckets, hash), Support.bucketIndex(buckets, hash)); + } + + @Test + void insertHeadEntryByIndexDelegatesToHashtableInsertHeadEntryAt() { + Hashtable.Entry[] buckets = Support.create(4); + StringIntEntry entry = new StringIntEntry("a", 1); + Support.insertHeadEntry(buckets, 0, entry); + assertSame(entry, buckets[0]); + } + + @Test + void insertHeadEntryByHashDelegatesToHashtableInsertHeadEntryFor() { + Hashtable.Entry[] buckets = Support.create(4); + StringIntEntry entry = new StringIntEntry("a", 1); + Support.insertHeadEntry(buckets, entry.keyHash, entry); + assertSame(entry, Support.bucket(buckets, entry.keyHash)); + } + + @Test + void bucketDelegatesToHashtableBucketFor() { + Hashtable.Entry[] buckets = Support.create(4); + StringIntEntry entry = new StringIntEntry("a", 1); + Support.insertHeadEntry(buckets, entry.keyHash, entry); + assertSame(entry, Support.bucket(buckets, entry.keyHash)); + } + + @Test + void bucketIteratorDelegatesToHashtableBucketIterator() { + Hashtable.Entry[] buckets = Support.create(4); + StringIntEntry entry = new StringIntEntry("a", 1); + Support.insertHeadEntry(buckets, entry.keyHash, entry); + BucketIterator it = Support.bucketIterator(buckets, entry.keyHash); + assertTrue(it.hasNext()); + assertSame(entry, it.next()); + } + + @Test + void mutatingBucketIteratorDelegatesToHashtableMutatingBucketIterator() { + Hashtable.Entry[] buckets = Support.create(4); + StringIntEntry entry = new StringIntEntry("a", 1); + Support.insertHeadEntry(buckets, entry.keyHash, entry); + MutatingBucketIterator it = + Support.mutatingBucketIterator(buckets, entry.keyHash); + assertTrue(it.hasNext()); + assertSame(entry, it.next()); + it.remove(); + assertNull(Support.bucket(buckets, entry.keyHash)); + } + + @Test + void mutatingTableIteratorOverFullTableDelegatesToHashtable() { + Hashtable.Entry[] buckets = Support.create(4); + buckets[0] = new StringIntEntry("a", 1); + MutatingTableIterator it = Support.mutatingTableIterator(buckets); + assertTrue(it.hasNext()); + assertEquals("a", it.next().key); + } + + @Test + void mutatingTableIteratorOverRangeDelegatesToHashtable() { + Hashtable.Entry[] buckets = Support.create(4); + buckets[0] = new StringIntEntry("a", 1); + buckets[2] = new StringIntEntry("b", 2); + MutatingTableIterator it = Support.mutatingTableIterator(buckets, 0, 2); + assertTrue(it.hasNext()); + assertEquals("a", it.next().key); + assertFalse(it.hasNext(), "range end is exclusive"); + } + + @Test + void forEachDelegatesToHashtableForEach() { + Hashtable.Entry[] buckets = Support.create(4); + buckets[0] = new StringIntEntry("a", 1); + buckets[1] = new StringIntEntry("b", 2); + Set seen = new HashSet<>(); + Support.forEach(buckets, e -> seen.add(e.key)); + assertEquals(2, seen.size()); + } + + @Test + void forEachWithContextDelegatesToHashtableForEach() { + Hashtable.Entry[] buckets = Support.create(4); + buckets[0] = new StringIntEntry("a", 1); + Set seen = new HashSet<>(); + Support., StringIntEntry>forEach(buckets, seen, (ctx, e) -> ctx.add(e.key)); + assertEquals(1, seen.size()); } } @@ -126,9 +347,10 @@ class BucketIteratorTests { @Test void walksOnlyMatchingHash() { // Build a bucket array with two entries that share a bucket but have different hashes. - // Use Hashtable.D1 to seed; then call Support.bucketIterator directly with the matching + // Use Hashtable.D1 to seed; then call Hashtable.bucketIterator directly with the matching // hash and verify it only returns the matching entry. - Hashtable.D1 table = new Hashtable.D1<>(4); + Hashtable.D1 table = + Hashtable.D1.createCapped(CollidingKeyEntry.class, 4); CollidingKey k1 = new CollidingKey("first", 17); CollidingKey k2 = new CollidingKey("second", 17); CollidingKey k3 = new CollidingKey("third", 17); @@ -136,7 +358,7 @@ void walksOnlyMatchingHash() { table.insert(new CollidingKeyEntry(k2, 2)); table.insert(new CollidingKeyEntry(k3, 3)); // All three share the same hash (17), so a bucket iterator over hash=17 yields all three. - BucketIterator it = Support.bucketIterator(table.buckets, 17L); + BucketIterator it = Hashtable.bucketIterator(table.buckets, 17L); int count = 0; while (it.hasNext()) { assertNotNull(it.next()); @@ -147,10 +369,11 @@ void walksOnlyMatchingHash() { @Test void exhaustedIteratorThrowsNoSuchElement() { - Hashtable.D1 table = new Hashtable.D1<>(4); + Hashtable.D1 table = + Hashtable.D1.createCapped(StringIntEntry.class, 4); table.insert(new StringIntEntry("only", 1)); long h = Hashtable.D1.Entry.hash("only"); - BucketIterator it = Support.bucketIterator(table.buckets, h); + BucketIterator it = Hashtable.bucketIterator(table.buckets, h); it.next(); assertFalse(it.hasNext()); assertThrows(NoSuchElementException.class, it::next); @@ -165,7 +388,8 @@ class MutatingBucketIteratorTests { @Test void removeFromHeadOfChainUnlinks() { // Make three entries with the same hash so they chain in one bucket - Hashtable.D1 table = new Hashtable.D1<>(4); + Hashtable.D1 table = + Hashtable.D1.createCapped(CollidingKeyEntry.class, 4); CollidingKey k1 = new CollidingKey("first", 17); CollidingKey k2 = new CollidingKey("second", 17); CollidingKey k3 = new CollidingKey("third", 17); @@ -174,7 +398,7 @@ void removeFromHeadOfChainUnlinks() { table.insert(new CollidingKeyEntry(k3, 3)); MutatingBucketIterator it = - Support.mutatingBucketIterator(table.buckets, 17L); + Hashtable.mutatingBucketIterator(table.buckets, 17L); it.next(); // first match (head of chain in insertion-reverse order) it.remove(); // Two should remain @@ -198,7 +422,8 @@ void removeFromHeadOfChainUnlinks() { @Test void replaceSwapsEntryAndPreservesChain() { - Hashtable.D1 table = new Hashtable.D1<>(4); + Hashtable.D1 table = + Hashtable.D1.createCapped(CollidingKeyEntry.class, 4); CollidingKey k1 = new CollidingKey("first", 17); CollidingKey k2 = new CollidingKey("second", 17); CollidingKeyEntry e1 = new CollidingKeyEntry(k1, 1); @@ -207,7 +432,7 @@ void replaceSwapsEntryAndPreservesChain() { table.insert(e2); MutatingBucketIterator it = - Support.mutatingBucketIterator(table.buckets, 17L); + Hashtable.mutatingBucketIterator(table.buckets, 17L); CollidingKeyEntry first = it.next(); CollidingKeyEntry replacement = new CollidingKeyEntry(first.key, 999); it.replace(replacement); @@ -220,10 +445,11 @@ void replaceSwapsEntryAndPreservesChain() { @Test void removeWithoutNextThrows() { - Hashtable.D1 table = new Hashtable.D1<>(4); + Hashtable.D1 table = + Hashtable.D1.createCapped(StringIntEntry.class, 4); table.insert(new StringIntEntry("a", 1)); MutatingBucketIterator it = - Support.mutatingBucketIterator(table.buckets, Hashtable.D1.Entry.hash("a")); + Hashtable.mutatingBucketIterator(table.buckets, Hashtable.D1.Entry.hash("a")); assertThrows(IllegalStateException.class, it::remove); } } @@ -235,13 +461,15 @@ class MutatingTableIteratorTests { @Test void walksEveryEntryAcrossBuckets() { - Hashtable.D1 table = new Hashtable.D1<>(16); + Hashtable.D1 table = + Hashtable.D1.createCapped(StringIntEntry.class, 16); table.insert(new StringIntEntry("a", 1)); table.insert(new StringIntEntry("b", 2)); table.insert(new StringIntEntry("c", 3)); Set seen = new HashSet<>(); - for (MutatingTableIterator it = Support.mutatingTableIterator(table.buckets); + for (MutatingTableIterator it = + Hashtable.mutatingTableIterator(table.buckets); it.hasNext(); ) { seen.add(it.next().key); } @@ -253,22 +481,24 @@ void walksEveryEntryAcrossBuckets() { @Test void emptyTableIteratorIsExhausted() { - Hashtable.D1 table = new Hashtable.D1<>(8); - MutatingTableIterator it = Support.mutatingTableIterator(table.buckets); + Hashtable.D1 table = + Hashtable.D1.createCapped(StringIntEntry.class, 8); + MutatingTableIterator it = Hashtable.mutatingTableIterator(table.buckets); assertFalse(it.hasNext()); assertThrows(NoSuchElementException.class, it::next); } @Test void removeUnlinksBucketHead() { - Hashtable.D1 table = new Hashtable.D1<>(4); + Hashtable.D1 table = + Hashtable.D1.createCapped(CollidingKeyEntry.class, 4); CollidingKey k1 = new CollidingKey("first", 17); CollidingKey k2 = new CollidingKey("second", 17); table.insert(new CollidingKeyEntry(k1, 1)); table.insert(new CollidingKeyEntry(k2, 2)); // The head of the chain is whichever was inserted last (insert prepends). - MutatingTableIterator it = Support.mutatingTableIterator(table.buckets); + MutatingTableIterator it = Hashtable.mutatingTableIterator(table.buckets); CollidingKeyEntry head = it.next(); it.remove(); @@ -280,7 +510,8 @@ void removeUnlinksBucketHead() { @Test void removeUnlinksMidChainEntry() { - Hashtable.D1 table = new Hashtable.D1<>(4); + Hashtable.D1 table = + Hashtable.D1.createCapped(CollidingKeyEntry.class, 4); CollidingKey k1 = new CollidingKey("first", 17); CollidingKey k2 = new CollidingKey("second", 17); CollidingKey k3 = new CollidingKey("third", 17); @@ -289,7 +520,7 @@ void removeUnlinksMidChainEntry() { table.insert(new CollidingKeyEntry(k3, 3)); // Walk to the second entry, remove it. - MutatingTableIterator it = Support.mutatingTableIterator(table.buckets); + MutatingTableIterator it = Hashtable.mutatingTableIterator(table.buckets); it.next(); CollidingKeyEntry victim = it.next(); it.remove(); @@ -315,12 +546,13 @@ void removeSkipsOverEmptyBuckets() { // Three distinct keys that land in different buckets (low entry count vs large bucket array // makes empty buckets between them very likely). Verify the iterator skips empties cleanly // after a remove. - Hashtable.D1 table = new Hashtable.D1<>(64); + Hashtable.D1 table = + Hashtable.D1.createCapped(StringIntEntry.class, 64); table.insert(new StringIntEntry("alpha", 1)); table.insert(new StringIntEntry("beta", 2)); table.insert(new StringIntEntry("gamma", 3)); - MutatingTableIterator it = Support.mutatingTableIterator(table.buckets); + MutatingTableIterator it = Hashtable.mutatingTableIterator(table.buckets); it.next(); it.remove(); int remaining = 0; @@ -333,18 +565,20 @@ void removeSkipsOverEmptyBuckets() { @Test void removeWithoutNextThrows() { - Hashtable.D1 table = new Hashtable.D1<>(4); + Hashtable.D1 table = + Hashtable.D1.createCapped(StringIntEntry.class, 4); table.insert(new StringIntEntry("a", 1)); - MutatingTableIterator it = Support.mutatingTableIterator(table.buckets); + MutatingTableIterator it = Hashtable.mutatingTableIterator(table.buckets); assertThrows(IllegalStateException.class, it::remove); } @Test void removeTwiceWithoutInterveningNextThrows() { - Hashtable.D1 table = new Hashtable.D1<>(4); + Hashtable.D1 table = + Hashtable.D1.createCapped(StringIntEntry.class, 4); table.insert(new StringIntEntry("a", 1)); table.insert(new StringIntEntry("b", 2)); - MutatingTableIterator it = Support.mutatingTableIterator(table.buckets); + MutatingTableIterator it = Hashtable.mutatingTableIterator(table.buckets); it.next(); it.remove(); assertThrows(IllegalStateException.class, it::remove); @@ -355,14 +589,15 @@ void halfOpenRangeOmitsBucketsOutsideTheRange() { // CollidingKey lets us pin entries to specific buckets via controlled hashCode. 16-slot // table -> bucketIndex = hash & 15. Place entries in buckets 0, 5, and 10; iterate // [5, 10) -- should see only bucket 5. - Hashtable.D1 table = new Hashtable.D1<>(16); + Hashtable.D1 table = + Hashtable.D1.createCapped(CollidingKeyEntry.class, 16); table.insert(new CollidingKeyEntry(new CollidingKey("b0", 0), 1)); table.insert(new CollidingKeyEntry(new CollidingKey("b5", 5), 2)); table.insert(new CollidingKeyEntry(new CollidingKey("b10", 10), 3)); Set seen = new HashSet<>(); for (MutatingTableIterator it = - Support.mutatingTableIterator(table.buckets, 5, 10); + Hashtable.mutatingTableIterator(table.buckets, 5, 10); it.hasNext(); ) { seen.add(it.next().key.label); } @@ -374,25 +609,28 @@ void halfOpenRangeOmitsBucketsOutsideTheRange() { void emptyHalfOpenRangeIsExhausted() { // start == end -> immediately-exhausted iterator. Important: this is the wrap-around // pass [0, cursor) when cursor == 0 in resumable sweeps. - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = + Hashtable.D1.createCapped(StringIntEntry.class, 8); table.insert(new StringIntEntry("a", 1)); - MutatingTableIterator it = Support.mutatingTableIterator(table.buckets, 0, 0); + MutatingTableIterator it = + Hashtable.mutatingTableIterator(table.buckets, 0, 0); assertFalse(it.hasNext()); } @Test void rangeBoundsOutOfOrderThrows() { - Hashtable.D1 table = new Hashtable.D1<>(8); + Hashtable.D1 table = + Hashtable.D1.createCapped(StringIntEntry.class, 8); assertThrows( IndexOutOfBoundsException.class, - () -> Support.mutatingTableIterator(table.buckets, -1, 4)); + () -> Hashtable.mutatingTableIterator(table.buckets, -1, 4)); assertThrows( IndexOutOfBoundsException.class, - () -> Support.mutatingTableIterator(table.buckets, 4, 2)); // end < start + () -> Hashtable.mutatingTableIterator(table.buckets, 4, 2)); // end < start assertThrows( IndexOutOfBoundsException.class, () -> - Support.mutatingTableIterator( + Hashtable.mutatingTableIterator( table.buckets, 0, table.buckets.length + 1)); // end > len } @@ -400,13 +638,289 @@ void rangeBoundsOutOfOrderThrows() { void currentBucketReportsLandingIndex() { // Pin one entry to a known bucket and check currentBucket() after next() reports that // bucket. Before any next() (or after remove()), currentBucket() returns -1. - Hashtable.D1 table = new Hashtable.D1<>(16); + Hashtable.D1 table = + Hashtable.D1.createCapped(CollidingKeyEntry.class, 16); table.insert(new CollidingKeyEntry(new CollidingKey("b3", 3), 1)); - MutatingTableIterator it = Support.mutatingTableIterator(table.buckets); + MutatingTableIterator it = Hashtable.mutatingTableIterator(table.buckets); assertEquals(-1, it.currentBucket(), "before any next() currentBucket should be -1"); it.next(); assertEquals(3, it.currentBucket(), "currentBucket should report the entry's bucket"); } } + + // ============ Eviction (SizeManager) ============ + + @Nested + class EvictionTests { + + @Test + void evictOneRemovesFirstMatchAndAdvancesCursor() { + Hashtable.State table = Hashtable.createCapped(4); + Hashtable.Entry[] buckets = table.buckets; + buckets[0] = new StringIntEntry("a", 1); + buckets[1] = new StringIntEntry("b", 2); + StringIntEntry evicted = Hashtable.evictOne(table, e -> e.value == 2); + + assertEquals("b", evicted.key); + assertNull(buckets[1]); + assertNotNull(buckets[0]); + } + + @Test + void tryReserveOrEvictReservesWhileRoomRemains() { + Hashtable.State table = Hashtable.createCapped(2); + + assertTrue(Hashtable.tryReserveOrEvict(table, e -> e.value == 0)); + assertTrue(Hashtable.tryReserveOrEvict(table, e -> e.value == 0)); + assertEquals(2, table.sizeManager.estimateSize()); + } + + @Test + void tryReserveOrEvictMakesRoomWhenFull() { + Hashtable.State table = Hashtable.createCapped(2); + StringIntEntry stale = new StringIntEntry("stale", 0); + StringIntEntry hot = new StringIntEntry("hot", 1); + assertTrue(Hashtable.insertHeadEntryFor(table, stale.keyHash, stale)); + assertTrue(Hashtable.insertHeadEntryFor(table, hot.keyHash, hot)); + assertTrue(table.sizeManager.isFull()); + + // Full, but one entry is evictable -- the slot it frees becomes the reservation. + assertTrue(Hashtable.tryReserveOrEvict(table, e -> e.value == 0)); + assertEquals(2, table.sizeManager.estimateSize(), "one out, one reserved"); + Set remaining = new HashSet<>(); + Hashtable.forEach(table.buckets, e -> remaining.add(e.key)); + assertFalse(remaining.contains("stale"), "the evictable entry is gone"); + assertTrue(remaining.contains("hot"), "the hot entry survived"); + } + + @Test + void tryReserveOrEvictRefusesWhenFullAndNothingEvictable() { + Hashtable.State table = Hashtable.createCapped(1); + StringIntEntry hot = new StringIntEntry("hot", 1); + assertTrue(Hashtable.insertHeadEntryFor(table, hot.keyHash, hot)); + + assertFalse(Hashtable.tryReserveOrEvict(table, e -> e.value == 0)); + assertEquals(1, table.sizeManager.estimateSize(), "a refused reservation consumes nothing"); + Set remaining = new HashSet<>(); + Hashtable.forEach(table.buckets, e -> remaining.add(e.key)); + assertTrue(remaining.contains("hot"), "nothing was evicted"); + } + + @Test + void removeMatchingOverStateNeedsNoTypeWitness() { + Hashtable.State table = Hashtable.createCapped(4); + StringIntEntry a = new StringIntEntry("a", 1); + Hashtable.insertHeadEntryFor(table, a.keyHash, a); + + StringIntEntry removed = Hashtable.removeMatching(table, a.keyHash, e -> e.matches("a")); + + assertSame(a, removed); + assertEquals(0, table.sizeManager.estimateSize()); + } + + @Test + void stateAccessorsAndInsertReservedRoundTrip() { + Hashtable.State table = Hashtable.createCapped(4); + assertTrue(Hashtable.isLikelyEmpty(table)); + assertEquals(0, Hashtable.estimateSize(table)); + + // Reserve first, build second -- a refused reservation must cost no allocation. + assertTrue(Hashtable.tryReserveOrEvict(table, e -> e.value == 0)); + StringIntEntry a = new StringIntEntry("a", 1); + Hashtable.insertReserved(table, a.keyHash, a); + + assertEquals( + 1, Hashtable.estimateSize(table), "insertReserved must not count the entry twice"); + assertFalse(Hashtable.isLikelyEmpty(table)); + assertSame(a, Hashtable.bucketFor(table, a.keyHash), "typed, no witness needed"); + + Set seen = new HashSet<>(); + Hashtable.forEach(table, e -> seen.add(e.key)); + assertEquals(1, seen.size()); + assertTrue(seen.contains("a")); + + Set viaContext = new HashSet<>(); + Hashtable.forEach(table, viaContext, (ctx, e) -> ctx.add(e.key)); + assertTrue(viaContext.contains("a")); + } + + @Test + void clearOverStateEmptiesSpineAndResetsCount() { + Hashtable.State table = Hashtable.createCapped(4); + StringIntEntry a = new StringIntEntry("a", 1); + Hashtable.insertHeadEntryFor(table, a.keyHash, a); + assertEquals(1, table.sizeManager.estimateSize()); + + Hashtable.clear(table); + + assertEquals(0, table.sizeManager.estimateSize()); + assertNull(table.buckets[Hashtable.bucketIndex(table.buckets, a.keyHash)]); + } + + @Test + void evictOneReturnsNullWhenNothingMatches() { + Hashtable.State table = Hashtable.createCapped(4); + Hashtable.Entry[] buckets = table.buckets; + buckets[0] = new StringIntEntry("a", 1); + assertNull(Hashtable.evictOne(table, e -> e.value == 999)); + assertNotNull(buckets[0]); + } + + @Test + void evictAllKeepsCountConsistentWhenThePredicateThrows() { + Hashtable.State table = Hashtable.createCapped(8); + for (int i = 0; i < 4; i++) { + StringIntEntry e = new StringIntEntry("k" + i, i); + assertTrue(Hashtable.insertHeadEntryFor(table, e.keyHash, e)); + } + assertEquals(4, Hashtable.estimateSize(table)); + + // Removes some entries, then blows up. The count must reflect what actually left the table. + assertThrows( + IllegalStateException.class, + () -> + Hashtable.evictAll( + table, + e -> { + if (e.value == 3) { + throw new IllegalStateException("boom"); + } + return true; + })); + + int counted = Hashtable.estimateSize(table); + Set actuallyThere = new HashSet<>(); + Hashtable.forEach(table, e -> actuallyThere.add(e.key)); + assertEquals( + actuallyThere.size(), counted, "count must match the spine after a partial evictAll"); + } + + @Test + void drainDetachesEntriesSoASinkCannotPinTheChain() { + // Two entries forced into one bucket, so the drained pair is chained. + Hashtable.State table = Hashtable.createCapped(4); + CollidingKeyEntry first = new CollidingKeyEntry(new CollidingKey("first", 17), 1); + CollidingKeyEntry second = new CollidingKeyEntry(new CollidingKey("second", 17), 2); + assertTrue(Hashtable.insertHeadEntryFor(table, first.keyHash, first)); + assertTrue(Hashtable.insertHeadEntryFor(table, second.keyHash, second)); + + List drained = new ArrayList<>(); + Hashtable.drain(table, drained::add); + + assertEquals(2, drained.size()); + assertEquals(0, Hashtable.estimateSize(table), "drain resets the tracked count"); + for (CollidingKeyEntry e : drained) { + assertNull(e.next(), "a retained entry must not pin the rest of its chain"); + } + } + + @Test + void evictOneAdvancesCursorEvenWhenNothingMatches() { + Hashtable.State table = Hashtable.createCapped(4); + StringIntEntry a = new StringIntEntry("a", 1); + assertTrue(Hashtable.insertHeadEntryFor(table, a.keyHash, a)); + + // Nothing is evictable, so the scan fails -- but the cursor must still move on. + assertNull(Hashtable.evictOne(table, e -> e.value == 999)); + assertEquals(1, Hashtable.estimateSize(table), "a failed scan evicts nothing"); + + // The cursor has stepped past where the entry sits, so finding it again needs the + // wrap-around pass; that it is still found proves the step did not strand it. + StringIntEntry evicted = Hashtable.evictOne(table, e -> e.value == 1); + assertEquals("a", evicted.key); + assertEquals(0, Hashtable.estimateSize(table)); + } + + @Test + void evictOneWrapsAroundToStartOfTable() { + Hashtable.State table = Hashtable.createCapped(4); + Hashtable.Entry[] buckets = table.buckets; + buckets[0] = new StringIntEntry("a", 1); + buckets[3] = new StringIntEntry("d", 4); + // First eviction matches bucket 3, advancing the cursor there. + StringIntEntry first = Hashtable.evictOne(table, e -> e.value == 4); + assertEquals("d", first.key); + + // Only remaining candidate is bucket 0, before the cursor -- requires wrap-around. + StringIntEntry second = Hashtable.evictOne(table, e -> e.value == 1); + assertEquals("a", second.key); + } + + @Test + void drainRemovesAllMatchesAndResetsCursor() { + Hashtable.State table = Hashtable.createCapped(4); + Hashtable.Entry[] buckets = table.buckets; + buckets[0] = new StringIntEntry("a", 1); + buckets[1] = new StringIntEntry("b", 2); + buckets[2] = new StringIntEntry("c", 3); + Hashtable.evictOne(table, e -> e.value == 3); + + int removed = Hashtable.evictAll(table, e -> e.value < 3); + + assertEquals(2, removed); + assertNull(buckets[0]); + assertNull(buckets[1]); + + // drain resets the cursor to the start, so a fresh scan finds bucket 0 without wrapping. + buckets[0] = new StringIntEntry("a2", 1); + StringIntEntry evicted = Hashtable.evictOne(table, e -> e.value == 1); + assertEquals("a2", evicted.key); + } + + @Test + void resetZeroesCursor() { + Hashtable.State table = Hashtable.createCapped(4); + Hashtable.Entry[] buckets = table.buckets; + buckets[3] = new StringIntEntry("d", 4); + Hashtable.evictOne(table, e -> e.value == 4); + + table.sizeManager.reset(); + + buckets[0] = new StringIntEntry("a", 1); + StringIntEntry evicted = Hashtable.evictOne(table, e -> e.value == 1); + assertEquals("a", evicted.key); + } + } + + // ============ Table ============ + + @Nested + class StateTests { + + @Test + void createTableSizesBucketsWithHeadroomAndCapsSize() { + Hashtable.State table = Hashtable.createCapped(4); + + int len = table.buckets.length; + assertTrue(len >= 4, "backing array must have load-factor headroom over capacity"); + assertEquals(0, len & (len - 1), "length must be a power of two"); + assertNotNull(table.sizeManager); + assertNotNull(table.sizeManager); + assertEquals(4, table.sizeManager.capacity()); + assertFalse(table.sizeManager.isFull()); + } + + @Test + void tableSizeTrackerRespectsCapacity() { + Hashtable.State table = Hashtable.createCapped(1); + + assertTrue(table.sizeManager.tryReserve()); + assertTrue(table.sizeManager.isFull()); + assertFalse(table.sizeManager.tryReserve()); + } + + @Test + void tableSizeManagerOperatesOnItsOwnBuckets() { + Hashtable.State table = Hashtable.createCapped(4); + table.buckets[0] = new StringIntEntry("a", 1); + + StringIntEntry evicted = + (StringIntEntry) + table.sizeManager.evictOne(table.buckets, e -> ((StringIntEntry) e).value == 1); + + assertEquals("a", evicted.key); + assertNull(table.buckets[0]); + } + } }